A 1/65k probability to fail
Checkpreimage(.) had a 1/65k probability to fail. If an attacker can guess what the spent of an utxo will be, then he can spend it in a way this next spent fails (unless the transaction is malleated). This attack is realistic, it takes 3min to craft a malicious spent.
I got a 312$payout (2BSV) for this "0day". So if you find one too, you should probably be a white hat.
Introduction to op push tx
The goal of OP_PUSH_TX is to have access to the current transaction. This is done with OP_CHECKSIG, because checksig checks a signature of the current transaction is correct, so you just sign "the top element of the stack" with script, and verify the signature, if it's accepted then the top element was the transaction.
Article about op push tx from sCrypt.
ECDSA signature 101
Here is how to compute an ECDSA signature: Elliptic Curve Digital Signature Algorithm - Wikipedia
This is the computation needed to compute the signature s:
However, bitcoin is weird, integers are little endian (or big, whatever, they are just in the "wrong" order), so you need to convert between little/big endian before doing any computation (the signature need to be in big endian).
Wait, the script code of txpreimage is weird
Ok so far so good, how do we do the conversion ? Let's examine the generated scriptcode by sCrypt. To find it just Ctrl+F "hash256", because the message signed is the hash of the message (according to the wikipedia page). Here it is:
So it seems OP_1 OP_SPLIT OP_1 OP_SPLIT ... do this conversion. Let's use their awesome debugger to be sure. Just set a breakpoint before the OP_1 OP_SPLIT thing, execute step by step, and yeah it confirms our intuition !
And it seems the number of OP_1 OP_SPLIT is hardcoded 👀 can it actually fail ? It would if the byte sequence is too short... Would be quite unlikely nobody has noticed that before, right ?
Finding the bug
I tried my best, and no it can't. The result of OP_HASH256 is 32byte.
It makes sense, OP_HASH256 takes any byte sequence, and return a byte sequence. Only integer operations can modify the size, for instance OP_0 is of size 0, and when you add 1 it becomes OP_1 of size 1.
So is somewhere some integer operations made ? After all the signature is computing with integers 👀 let's inspect the script code again.
The signature computation from wikipedia does an addition, so let's Ctrl+F OP_ADD:
Some OP_ADD, OP_MUL, and a OP_MOD. Looks like the computation of the signature. Just to verify let's stop just before the mod and examine the stack:
So this 41...fff00 should be the n of the signature. A quick google check about what ECDSA does bitcoin uses bring Secp256k1 - Bitcoin Wiki and yes this is our n !
So now the result of OP_MOD can be smaller than 32bytes, that may make the OP_1 OP_SPLIT thing crash, indeed OP_2 OP_2 OP_MOD returns OP_0, not 00 (you can check here https://scrypt.studio).
Ok what do have after this OP_MOD ? Just keep scrolling the asm code, couple of arithmetic operations, couple of OP_IFs, some OP_CAT probably to build the signature (because bitcoin uses the DER format so you need to add some infos, anyway boring stuff), and finally our OP_1 OP_SPLIT thing again!
It seems there are no OP_NUM2BIN after these arithmetic operations, that means the result can be a shorter byte sequence and OP_1 OP_SPLIT may fail!
Crafting the exploit
I took the nodejs deployment script and made it simulate what the script code would do (so I compute the signature in the nodejs file). To do so, again step by step debugger (because it's pretty subtle to get right).
Now I can malleate the transaction to have this OP_1 OP_SPLIT to fail. Turns out there is only 31 occurrences of OP_1 OP_SPLIT, so if the byte sequence is of length 31 it works, so you need a byte sequence of size 30. Hence the signature needs to be smaller than 256^30 (256 is the number of different value for a byte), there are 256^32 signature possible, hence a 1/256^2 = 1/65k probability to find it.
I already heard about this malleation thing for optimal push tx, so I just re use the same script: https://github.com/sCrypt-Inc/boilerplate/blob/master/tests/js/optimalPushtx.scrypttest.js
(Here is also the optimal tx article https://xiaohuiliu.medium.com/optimal-op-push-tx-ded54990c76f)
Some adjustments later, it works!
Here is if you want to try it, it takes 3min to run, so make sure to get the file on your pc it will be faster (and maybe increase loop bound if it doesn't find it): Op push tx exploit - Replit.
You should see this error:
Wait, where is the exploit if only your transaction fails ?
Here is the nice part, instead of simulating my transaction (to make it fail), I can simulate your next transaction. So I will first craft a weird transaction, and only then you will make your regular transaction, and it will fail... unless you also malleate it.
If it's "only" a 1/65k probability, why does no one observed it before ?
I'm not really sure. I think if someone already had this issue on his test script that deploy to testnet, he would have just restarted again his script and it would have work (the utxo used at the start is different), so because this bug is very hard to reproduce it's hard to report it. And 1/65k isn't much.
Also, if a contract is frozen for a specific spent, it's not for all possible spent (only 1 or 2 if extremely unlucky), so if Alice try to spend it and fails, then Bob (because he has a different utxo) can spend it. However some contract doesn't require external utxo, so idk, maybe I'm wrong (but sCrypt issued a fix, so ...).
What is the correct way to implement OP_PUSH_TX then ?
Mine of course https://github.com/frenchfrog42/Baguette/blob/main/compilation.rkt#L559:L568.
The fix done by sCrypt is the following: they change s to 32bytes, then reverse, and then cut extra leading 0s.
You can also use Tx.checkpreimageOpt, which is way shorter, and very similar to my implementation (my implem is heavily inspired from their).
End word
Please try my compiler, it's awesome: https://replit.com/@frenchfrog42/Baguette.
And please buy sCrypt premium licence. It's awesome. Would have been impossible to craft this exploit without it. So please buy one, and use the special promo code "BAGUETTE" for 10% off (limited to 3use only, yes it's a try to have a sense of urgency). Here is the link to buy their premium version sCrypt - Visual Studio Marketplace.
And, please, be careful. Contracts are immutable.