Unlocking the Lockbox2 | ParadigmCTF’22

zemse

zemse

Posted on August 24, 2022

Unlocking the Lockbox2 | ParadigmCTF’22

This weekend was fun, thanks to Paradigm for organising a great CTF again, that has very original and really tough challenges. Got to learn a lot while attempting/solving some of them. This CTF sometimes reminds me of IITJEE exam (a not-for-noobs high school/10+2 level exam in India).

Lockbox (the first) was very interesting challenge. I attempted it last year during the CTF, found it very tough. This year’s challenge isn’t any less. From seeming like you can do it, then making you feel like it’s literally impossible, Lockbox kept the reputation with it's 2nd iteration.

The Lockbox2 challenge is available here. In this post, we'll understand the challenge and solve it (based on how I approached it during the CTF). I've posted links wherever necessary. As a heads up, this is a quite long post, it's intended for beginners/intermediates to be able to follow. For experts, feel free to skim through the post or directly skip to the final solution here.

I'd like to thank @rileyholterhus, the Lockbox2 puzzle creator, to do a quick review of this post and provide valuable suggestions 🙏.

The Setup contract deploys a Lockbox2 contract, that contains a variable called locked, which is instantiated as true. The challenge requires this locked to be set as false. We can see that if get the solve() method to execute successfully, it’d set locked variable as false. But there are some preceding conditions which depend on input, which if not met, the solve() tx would fail. The challenge is all about crafting an input that passes the combination of conditions, similar to cracking combination locks!

A scene from Bollywood movie Happy New Year, Boman Irani attempting to crack a rotary combination lock of the vault

Crafting inputs and improving it sounds fun. However, interestingly, the solidity solve() method does not take any input!

function solve() external {
Enter fullscreen mode Exit fullscreen mode

If the solve method doesn't accept any input, how are we supposed to insert the crafted input?!

Meme expressing this is just the first of all problems we will face

The thing here is, you can basically pass more calldata than needed, solidity does not really care about it (ik this is pretty obvious now, if I’d show this to myself of two years ago I'd have no clue).

So if we use the usual libs that rely on ABI, the generated calldata will be just the 4 bytes selector and we won’t be able to able to have them encode it for us. So we have to get our hands dirty and do it ourselves. Check out the Contract ABI Spec in the Solidity docs.

First, we will go through the challenge just to get it’s overview and understand it. Then we can get started into crafting the calldata all together.

Overview of solve method

The solve method makes 5 delegate calls to the self contract, to invoke the logic for those 5 stages.

It’s easily visible that calldata beyond the 4 bytes msg.data[4:] is passed to the particular stage method.

Here, delegatecall preserves the msg.sender. The intention could be to just add some friction for those who have not played enough with delegatecall. Or if not, we'll get to know shortly ;)

Stage 1

Quite straight forward, the calldata must be under 500 bytes.

Stage 2

Looks at first 4 words and expects them to either be a prime number or simply 1. Each of those 4 numbers should small enough (like up to 1000), so that the for-loop can iterate within tx gas limit.

Stage 3

Looks at first 3 words as a, b and c. Makes a staticcall to address(a + b). Note that from previous stage, the words are quite small numbers (~1000) and hence their sum would also be small. This means we can’t target this staticcall to a contract that we could practically deploy. This has to either be a precompile address or empty account.

If it is a precompile, then we have just a handful of choices.

A staticcall to empty account would be successful and return empty data (you can verify this by doing a simple eth_call to random address, it won’t give json rpc error response).

Btw, this one has an interesting mstore(a, b) over there. This logic basically stores value b at memory location a. By initial thought, memory location cannot be very huge and maybe it just want's to restrict a to be sufficiently small, but isn't that implied from stage2? Can't make sense of it so far, let's move on for now.

Stage 4

Things get nasty here since now it involves dynamic types. It is important to understand how solidity handles dynamic types, specifically bytes in calldata for this case. Check out dynamic types section in the Contract ABI spec here.

For simplicity, let’s ignore previous stages, just look at this in order to understand what this stage needs in order to pass through.

The gist of the conditions:

  1. It takes two byte strings, a and b.
  2. Deploys a contract using a as the creation code. The bytecode should be a ECDSA public key (length 64) of the tx.origin (the EOA wallet that we will use to interact). Note that ECDSA of ecdsa public key gives ethereum address (after chopping of the upper 12 bytes).
  3. Makes a staticcall to the contract just deployed, using b as the calldata, this call should be successful.

We can easily deploy a contract containing any code we want. Here, we want to have the tx.origin’s public key as the bytecode. So one thing is we should fix a EOA wallet. And then the challenge is getting the static call to succeed. Easiest approach I could think of is having first byte as zero (STOP opcode), so that it halts execution and staticcall passes.

If you notice, this does not involve b at all. So basically b taking any value can work, which is one less headache.

Making this stage pass individually is straight forward. It will be challenging to make combination of all stages pass.

Stage 5

It internally makes a msg call to solve function which should fail somehow. Though there’s a difference of msg.sender in both solve() invocation, msg.sender is really not consumed anywhere to make a difference. At first it might seem strange, how it is possible for one thing to work first and then not work in the same env conditions?

The key to this stage, is the fact that in a CALL only 63/64 of the available gas is passed (source evm.codes). If we pass just enough gas limit, slightly less gas would be passed such that internal message call fails due to out of gas, passing the check in stage 5. Also it is important to note that 1/64 gas should be enough to perform later operations, else it will not work. It also makes sense why there's locked variable is set to false, instead of having a solved variable which is set to true like in other challenges, since it'd cost less gas.

Solving the challenge

Step 1 - Solve Stage 4

I started from this stage because it has the most complex calldata, and getting other stage passed could look like minor tweaking in the calldata (this is what I expected, I believe it was a right decision).

In stage4, we need to write a bytecode that returns public key which has first byte as zero.

Okay, but for that we need a public key, and for that we need a wallet/private key in order to be the tx.origin.

I’ll just use ethers.js in my terminal (using ethers-repl) to get a wallet real quick. You can use anything which gets the job done.

sohamzemse@MacBook-Pro % ethers
ethers-repl> while((w = ethers.Wallet.createRandom()).publicKey.slice(4,6) !== '00') {}
undefined
ethers-repl> w.privateKey
'0x9b067b56552d3369e7762f0f92051db20a2db034e8e9fc803a71e64ae5b163b4'
ethers-repl> w.publicKey
'0x0400a86410b6215c11e36c6a60d02277415f69393b692b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a68452a4311cc7238d7431fa'
Enter fullscreen mode Exit fullscreen mode

Note that the public key here has a 0x04 prefix byte, which is to signify that this is an uncompressed ECDSA public key. The actual public key follows after the prefix byte.

Now we need a code that creates contract with the public key as the bytecode. It's pretty opinionated, you can use solidity/yul/vyper/huff/etk/whatever. I happen to be an evm nerd and I was in a hurry, so couldn't stop myself from writing down the following raw evm code.

$ vim lockbox_stage4.evm
=========lockbox_stage4.evm========================
push1 0x40 // 64 bytes
dup1
push1 0xb // offset in this code
push1 0 // offset in memory
codecopy
push1 0
return
// public key
00a86410b6215c11e36c6a60d02277415f69393b692b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a68452a4311cc7238d7431fa
===================================================

$ evm-run lockbox_stage4.evm
code 604080600B6000396000f300A86410B6215C11E36C6A60D02277415F69393B692B6799805EE75969DF78D25398733FC3E0438BBCBB9E37FA1FF5DA79660324A68452A4311CC7238D7431FA
Returned: 00a86410b6215c11e36c6a60d02277415f69393b692b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a68452a4311cc7238d7431fa
gasUsed: 30
Enter fullscreen mode Exit fullscreen mode

I'm using evm-run for assembling + running evm code. (Sidenote: you can checkout ETK which helps with labels as well).

We can see that the bytecode on execution spits out the public key in the returndata (so that it becomes the contract bytecode). Okay, so we got the code that should be passed as to the first input a. And in case of b we can pass anything, we can just use empty bytes for now i.e. 0x.

Now it's time to encode the calldata that should work with stage4. Notice that ethers.js strictly requires "0x" prefix, so we have to add that at the start of the bytecode.

ethers-repl> encoded = defaultAbiCoder.encode(['bytes', 'bytes'], ['0x604080600B6000396000f300A86410B6215C11E36C6A60D02277415F69393B692B6799805EE75969DF78D25398733FC3E0438BBCBB9E37FA1FF5DA79660324A68452A4311CC7238D7431FA', '0x'])
'0x000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000004b604080600b6000396000f300a86410b6215c11e36c6a60d02277415f69393b692b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a68452a4311cc7238d7431fa0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'
ethers-repl> 
ethers-repl> wordify(encoded)
[
  '0000000000000000000000000000000000000000000000000000000000000040',
  '00000000000000000000000000000000000000000000000000000000000000c0',
  '000000000000000000000000000000000000000000000000000000000000004b',
  '604080600b6000396000f300a86410b6215c11e36c6a60d02277415f69393b69',
  '2b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a6',
  '8452a4311cc7238d7431fa000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000'
]
ethers-repl> 
ethers-repl> selector('solve()')
'0x890d6908'
Enter fullscreen mode Exit fullscreen mode

Putting all of this together, I'm locally using hardhat:

// use the private key we generated earlier (for public key to have first byte zero)
const wallet = new Wallet('0x9b067b56552d3369e7762f0f92051db20a2db034e8e9fc803a71e64ae5b163b4', hre.ethers.provider);
// fund this wallet with some eth to pay for tx fees
const hardhatAccount0 = (await hre.ethers.getSigners())[0];
await hardhatAccount0.sendTransaction({
  to: wallet.address,
  value: parseEther("1"),
});

// we are not using the usual abi encoding tools, because this challenge involves manipulating the calldata to make the all stages passed
await wallet.sendTransaction({
  to: contract.address,
  gasLimit: 29_000_000, // pass some huge gas limit, bcz estimate has some issues
  data: '0x890d6908' + // 4 byte selector for solve() method
  [
    '0000000000000000000000000000000000000000000000000000000000000040',
    '00000000000000000000000000000000000000000000000000000000000000c0',
    '000000000000000000000000000000000000000000000000000000000000004b',
    '604080600b6000396000f300a86410b6215c11e36c6a60d02277415f69393b69',
    '2b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a6',
    '8452a4311cc7238d7431fa000000000000000000000000000000000000000000',
    '0000000000000000000000000000000000000000000000000000000000000000'
  ].join('')
});
Enter fullscreen mode Exit fullscreen mode

Now execute the tx, and you should see stage4 pass. I’m using Hardhat framework to view the execution trace, using the hardhat-tracer plugin. (Sidenote: A great alternative is using Foundry and it also has tracing built-in).

Screenshot of execution trace generated using hardhat-tracer

The stage4 is highlighted in the screenshot above, you can see that rest of the stages fail with empty revert data as UnknownError, while there is no error in case of stage4.

Stage4 done, now lets get others!

Step 2 - Solve Stage 2

The actual condition in the code first checks if the value is greater than or equal to one and then goes into a for-loop that requires word % j != 0 for all 2 <= j < word. This step basically translates to the requirement of the first 4 words to be a prime number or 1 and the number cannot be huge, otherwise the for loop will go out of gas.

Okay, lets look at our calldata:

"0x890d6908" + // 4 byte selector for solve() method
[
  "0000000000000000000000000000000000000000000000000000000000000040", // arr[0]
  "00000000000000000000000000000000000000000000000000000000000000c0", // arr[1]
  "000000000000000000000000000000000000000000000000000000000000004b", // arr[2]
  "604080600b6000396000f300a86410b6215c11e36c6a60d02277415f69393b69", // arr[3]
  "2b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a6",
  "8452a4311cc7238d7431fa000000000000000000000000000000000000000000",
  "0000000000000000000000000000000000000000000000000000000000000000",
].join(""),
Enter fullscreen mode Exit fullscreen mode

We can clearly see that arr[3] is huge. Also note that the first three words are not prime numbers either. We need to fix this.

Let’s see how dynamic types are encoded in the case of (bytes a, bytes b). The first word contains the location where content of a starts. And second word contains the location where content of b starts. This means we can move the content of a anywhere we want.

One easy idea is to simply move the data bit forward, by two words, in order to have arr[3] to take a value that’s within iterative limits as well as making arr[2] and arr[3] independent of the byte strings. So basically we can put any kind of stuff we want over there, the contract be won’t bothered about it at all.

"0x890d6908" + // 4 byte selector for solve() method
[
  "0000000000000000000000000000000000000000000000000000000000000080", // arr[0], changed from 0x40 to 0x80 (+0x40)
  "0000000000000000000000000000000000000000000000000000000000000100", // arr[1], changed from 0xc0 to 0x100 (+0x40)
  "0000000000000000000000000000000000000000000000000000000000000001", // arr[2] (just inserted)
  "0000000000000000000000000000000000000000000000000000000000000001", // arr[3] (just inserted)
  "000000000000000000000000000000000000000000000000000000000000004b",
  "604080600b6000396000f300a86410b6215c11e36c6a60d02277415f69393b69",
  "2b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a6",
  "8452a4311cc7238d7431fa000000000000000000000000000000000000000000",
  "0000000000000000000000000000000000000000000000000000000000000000",
].join(""),
Enter fullscreen mode Exit fullscreen mode

We have inserted two words just after the first two words. We can put anything there, but having 1 in there, simply works for now.

Also we need the first two words to be a prime number. So the first two words are 0x80 (128) and 0x100 (256). Let's see some prime numbers just after them (so we can push them bit further). We know 131 and 257 are primes.

"0x890d6908" + // 4 byte selector for solve() method
[
  "0000000000000000000000000000000000000000000000000000000000000083", // arr[0], changed from 0x80 to 0x83 (+0x03)
  "0000000000000000000000000000000000000000000000000000000000000101", // arr[1], changed from 0x100 to 0x101 (+0x01)
  "0000000000000000000000000000000000000000000000000000000000000001", // arr[2]
  "0000000000000000000000000000000000000000000000000000000000000001", // arr[3]
  "000000", // a is moved further by 3 bytes
  "000000000000000000000000000000000000000000000000000000000000004b",
  "604080600b6000396000f300a86410b6215c11e36c6a60d02277415f69393b69",
  "2b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a6",
  "8452a4311cc7238d7431fa000000000000000000000000000000000000000000",
  "00", // b is moved further by 1 byte
  "0000000000000000000000000000000000000000000000000000000000000000",
].join(""),
Enter fullscreen mode Exit fullscreen mode

Now with this, we can see Stage2 passes! Following is a screenshot of trace generated on hardhat using hardhat-tracer, and the delegate call to stage2 is highlighted for visibility, we can see it does not revert.

Terminal screenshot of stage2 passing

Ah, finally stage2 passed! Actually, we've done a lot so far. Pads on the back!

Step 3 - Solve Stage3

Here, a static call is made to a + b, which in our case currently it's 0x83 + 0x101 = 0x184. That's definitely not a precompile address atm, can be checked by making an eth_call.

If it's not a precompile, then the static call would still success, however result would be empty data. And this stage requires that the third word should equal to the length of the return data.

The third word can be anything only that stage2 restricts the third word to a non-zero value (1 or prime). It means there should be some return data. But how? We definitely cannot deploy a contract at an address with so many zeros! So it means the a + b needs to be a precompile for this to work.

I couldn't find an up-to-date list of precompiles, had to look into client implementations. Here is the list in EthereumJS/EVM. We can clearly see precompiles are only upto 0x12. Also the return data length should be a prime number, when called with empty data. We can just try this using eth_call. For that, I'm quickly jumping into ethers-repl on my terminal.

$ ethers
ethers-repl> await mainnet.call({to: '0x0000000000000000000000000000000000000012', data: '0x'})
// {"jsonrpc":"2.0","id":44,"error":{"code":-32602,"message":"invalid 1st argument: transaction 'data': value was too short"}}
Enter fullscreen mode Exit fullscreen mode

Couldn't really find any precompile that works with empty calldata and gives a return data with prime length. It means we cannot use a precompile. After playing around a bit, it was fairly convincing that forget prime length return data, there is no way this static call could return a non-zero length return data.

But wait, didn't we just eliminated all the possibilities? Is this challenge even solvable?

*Looks at the scoreboard, single integer number of people have solved it*

Okay, means definitely there's some advanced stuff going on here and something is missing in my observations.

Oh! could it be related to that weird mstore(a, b) which I believed it was there for no reason. Hmm, after a careful observation, I could see that we do not need to have the static call return a data with prime length, the check is data.length == c, where data.length is taken from memory. So we have to use the mstore(a, b) to write at a location and manipulate the value of data.length. To know where in the memory data.length is, I'm just using hardhat-tracer to print MLOAD operations.

$ npx hardhat test --trace --opcodes MLOAD
Enter fullscreen mode Exit fullscreen mode

Terminal screenshot of display of MLOAD opcodes during the execution

Here, we can see after the static call to the 0x00..184 address, there is an MLOAD at location 0x60 for reading data.length. Memory location 0x60 is a special one because it always points to zero, it's used as the initial value for dynamic memory arrays when they are empty (source).

Note that mstore(a, b) is done before the declaration of bytes memory data. And since the return data is empty, solidity uses 0x60 as the location for the data variable. Solidity docs mention that this memory slot should not be written to, since it'd cause empty arrays to assume non-zero length. This helps us!

Now comes another tricky part, we have to figure out how to rearrange our calldata. I'm reverting our calldata changes back to step3 initial, and then moving it further by one word (instead of two words as we previously did). We have to do this because the mstore(a, b) writes at memory slot a, and we want it to be 0x60.

"0x890d6908" + // 4 byte selector for solve() method
[
  "0000000000000000000000000000000000000000000000000000000000000060", // 0x40 to 0x60
  "00000000000000000000000000000000000000000000000000000000000000e0", // 0xc0 to 0xe0
  "0000000000000000000000000000000000000000000000000000000000000000", // inserted here
  "000000000000000000000000000000000000000000000000000000000000004b",
  "604080600b6000396000f300a86410b6215c11e36c6a60d02277415f69393b69",
  "2b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a6",
  "8452a4311cc7238d7431fa000000000000000000000000000000000000000000",
  "0000000000000000000000000000000000000000000000000000000000000000",
].join(""),
Enter fullscreen mode Exit fullscreen mode

Okay, but what about previous stage where these first 4 words need to be primes? We might slightly change them. We know that 0x61 and 0xe3 is a prime. So let's change the first two words to ensure they are prime. That will require shifting the content of towards right by one byte.

"0x890d6908" + // 4 byte selector for solve() method
[
  "0000000000000000000000000000000000000000000000000000000000000061", // 0x60 to 0x61
  "00000000000000000000000000000000000000000000000000000000000000e3", // 0xe0 to 0xe3
  "0000000000000000000000000000000000000000000000000000000000000001", // keeping this 1
  "00000000000000000000000000000000000000000000000000000000000000004b", // one byte extra here
  "604080600b6000396000f300a86410b6215c11e36c6a60d02277415f69393b69",
  "2b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a6",
  "8452a4311cc7238d7431fa000000000000000000000000000000000000000000",
  "0000000000000000000000000000000000000000000000000000000000000000000000", // three bytes extra here
].join(""),
Enter fullscreen mode Exit fullscreen mode

However, you can see that 4th word is zero. Previously it was length of the byte string a in stage4. Since the length was under 1 byte, the 4th word became zero. Just to recall, byte string a is the creation code of a contract that gets deployed in stage4. Is there a way we can increase the length, by adding some redundant code without affecting behaviour?

Turns out that the answer is yes, we can! This is the evm code we previously used. The current code length is 0x4b, and we want it to exceed 0x100, so that the "1" gets in the 4th word.

ethers-repl> wordify('00'.repeat(0x100 - 0x4b))
[
  '0000000000000000000000000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000',
  '000000000000000000000000000000000000000000'
]
Enter fullscreen mode Exit fullscreen mode

We can just put the above zeros after our bytecode

push1 0x40 // 64 bytes
dup1
push1 0xb // offset in this code
push1 0 // offset in memory
codecopy
push1 0
return
// public key
00a86410b6215c11e36c6a60d02277415f69393b692b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a68452a4311cc7238d7431fa
// add any code after this, it is not used
// redundant code which makes bytecode length 0x100
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
000000000000000000000000000000000000000000

===================================================

$ evm-run lockbox_stage4.evm
code 604080600B6000396000f300A86410B6215C11E36C6A60D02277415F69393B692B6799805EE75969DF78D25398733FC3E0438BBCBB9E37FA1FF5DA79660324A68452A4311CC7238D7431FA00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
Returned: 00a86410b6215c11e36c6a60d02277415f69393b692b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a68452a4311cc7238d7431fa
Enter fullscreen mode Exit fullscreen mode

Now we have to repeat the steps.

ethers-repl> bytecode = '604080600B......00000000' // paste here
ethers-repl> hexDataLength(bytecode)
256 // this is 0x100
ethers-repl> encoded = defaultAbiCoder.encode(['bytes', 'bytes'], [bytecode, '0x'])
ethers-repl> wordify(encoded)
[
  '0000000000000000000000000000000000000000000000000000000000000040',
  '0000000000000000000000000000000000000000000000000000000000000160',
  '0000000000000000000000000000000000000000000000000000000000000100',
  '604080600b6000396000f300a86410b6215c11e36c6a60d02277415f69393b69',
  '2b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a6',
  '8452a4311cc7238d7431fa000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000',
  '0000000000000000000000000000000000000000000000000000000000000000'
]
Enter fullscreen mode Exit fullscreen mode

We have to now repeat the steps similar to previous. I'll just paste the updated calldata.

"0x890d6908" + // 4 byte selector for solve() method
[
  "0000000000000000000000000000000000000000000000000000000000000061", // 0x60 to 0x61
  "0000000000000000000000000000000000000000000000000000000000000161", // 0x160 to 0x161
  "0000000000000000000000000000000000000000000000000000000000000001", // keeping this 1
  "000000000000000000000000000000000000000000000000000000000000000100", // one byte extra here
  "604080600b6000396000f300a86410b6215c11e36c6a60d02277415f69393b69",
  "2b6799805ee75969df78d25398733fc3e0438bbcbb9e37fa1ff5da79660324a6",
  "8452a4311cc7238d7431fa000000000000000000000000000000000000000000",
  "0000000000000000000000000000000000000000000000000000000000000000",
  "0000000000000000000000000000000000000000000000000000000000000000",
  "0000000000000000000000000000000000000000000000000000000000000000",
  "0000000000000000000000000000000000000000000000000000000000000000",
  "0000000000000000000000000000000000000000000000000000000000000000",
  "0000000000000000000000000000000000000000000000000000000000000000",
].join(""),
Enter fullscreen mode Exit fullscreen mode

Note that here, 0x161 is written to memory location 0x61, which basically writes 1 to the 0x60 word (data.length memory slot), the rest 61 is written to next slot and it does not matter.

Now we've got stage1, stage2, stage3, stage4 passing, only stage5 is failing.

Terminal screenshot of first 4 stages passing, while stage 5 failing

Step 5 - Solve Stage 5

This stage makes a call to the solve() method again, and in the trace above, we can see everything passes but only the stage5 check, which wants the solve() invocation from stage5 to fail.

It's based on the fact that when an execution context makes a CALL, it can at max give 63/64 of the gas available. We have to carefully pass a value of gasLimit such that when 63/64 of the gas available is passed to the solve() internal message call, it's not enough for it and it goes out of gas. That only fails the internal msg call, and 1/64 gas is available for the stage5's execution context to proceed. After trying for some values, I found 560_000 to be just enough to work.

Terminal screenshot of all stages of challenge solved

Yay! The challenge is solved!

Se* is cool but have you ever tried catching a flag :P

The feeling when you catch the flag

Stay tuned to @paradigm_ctf for the future editions. Hopefully, we’ll see Lockbox3!

Also, if you made this far, thanks! Btw I’m Soham, and I mostly do open-source contributions to some ethereum dev tools. As a disclosure: hardhat-tracer, evm-run, ethers-repl mentioned above are some of my github projects. Oh yeah, also I'm attending my first Devcon this year as a Devcon Scholar, would love to meet if you are coming too! Looking forward to buidl more stuff. My social links: github, twitter.

💖 💪 🙅 🚩
zemse
zemse

Posted on August 24, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related