PARADIGM CTF 2022 Question Analysis 3 - Lockbox2 Analysis

Numen Cyber Labs
Numen Cyber Labs
Published in
7 min readSep 2, 2022

Firstly, analyze the Setup contract, shown as below:

From above figure can see, it imports Lockbox2.sol contract at the beginning, leaving it alone for now. Go to next, it defines Setup contract which initializes a new lockbox2 contract. After then, it is a isSolved function. This function isview modified and not on the chain. It returns the non-value of the return value of the lockbox2 contract of the lockedfunction which is a bool type. So it seems that the premise of capture the flag is to make the locked function of lockbox2 return false.

Next, Lockbox2 contract:

Let’s analyze the code line by line. At first, A global variable-locked is defined and initialized as true. Second, it’s solved function without parameters. For this function, It first declares a bool type array successes with a length of 5. The subscripts from 0 to 4 correspond to the five return values. Continue to look at each line, which is to call the stage1–5 functions of the current contract, and the calldata data starts from the 4th bit of msg.data. Starting from the fourth digit, that is, not counting the function signature. Then assign the success of each call as a bool value to the successes array. Then there is a loop through the array of bool type, if each is true, continue to run, set locked to false. Only the solve entry can change the locked variable. It seems that the key problem is the 5 calls of the solve function.

Continue to analyze the following 5 calls.

Stage1,it requires the length of msg.data less than 500.

Stage2,The parameter passed in is a uint256 array with length 4. In the first 4 lines of incoming msg.data, a line of 32 bytes, each line represents a number. And then all 4 numbers must be satisfied the conditions that it cannot divide any number except 1 and itself. This is easy to be constructed.

Stage3,The incoming parameters are 3 uint256 type data. First, mstore is called, and b is stored in the position of a in the stack, b represents the data, and a represents the position in the stack. Then a program call, where the sum of a, b represents an address, and then the staticcall function statically calls this address. We cannot construct such an address, so the return must be empty.

Next line, it requires the length of the return value needs to be equal to the incoming parameter c. According to stage2, it is known that c must not be able to divide any number except 1 and itself, and the length of the return value here must be 0, which seems impossible. Because I noticed that there is an mstore at the beginning, which is stored anywhere in memory, this mstore must be meaningful. First of all, through the data, we learned about the characteristics of solidity. Since the return value of the call is empty, it will be stored in a special location 0x60 (https://docs.soliditylang.org/en/latest/internals/layout_in_memory.html#layout-in-memory) Next, we debug to verify.

It can be seen that after the static call, mload reads the value at the 0x60 position in the stack, so we only need to store data in the specified position in mstore, and then let stage3 pass. We can pass in 0x61, 0x0101, 0x1.

First save 0101 to 0x61 through mstore. Because mstore is used, 32 bytes of data are stored, 0x0101 high-order bits are filled with 0, and 32 bytes are filled. Because the 60 position has a total of 32 bytes, but mstore starts from the 61 position, and the entire 60 can not be stored, so 80 positions will be occupied, shown as below figure.

Now that the 60 position is successfully controlled, mstore(a,b) will fetch the number at 0x60 due to it is before the declaration of bytes memory data and returned data is empty. So mstore is used to control the input parameters to pass stage3.

Stage4,stage4 passes in two parameters, both of them are bytes type. Firstly, a contract is created with parameter aand saved on the chain. It will return an address. Then perform a static call with parameter b. the following code is a require judgment which requires the code of the newly generated contract must equals to the public key of tx.origin. This is also easier to construct.

Next we print tx.origin,and found there are two more 0s in front of tx.origin. That means when constructing the public key, the first two digits are required to be ‘00’.

We first use the above script to run a public key and a private key, and output a public key whose first two digits are 0.

Then we need to construct an opcode to keep the public key content on the chain.

PUSH1 0x40DUP1PUSH1 0x06PUSH1 0x0CODECOPYPUSH1 0RETURN

Concatenate the above opcode with the constructed public key. First explain the meaning of opcode, push 40 into the stack, copy it, and then push both 06 and 0 into the stack. At this point, the contents of the stack are 0, 0b, 40, and 40 from the top of the stack down. codecopy receives 3 parameters, 0, 0b, 40 from the top of the stack which represent the target position, the current position and the code length respectively. The code of public key is 64 bytes, so the length is 0x40. Because the content of the public key is output of the opcode, so the current position of the public key is the sum of the length of the opcode bytecode, that is 11=0x0b. Afterwards, we need to copy the public key (code) from 0b to 0. Put 0 on the stack, and now there are 0, 40 in the stack. Then execute return command, and return the content from position 0 to 40. So far, the opcode deploys the content of the public key to the chain. Full version likes below:

That means, a should be passed in the bytes array, b shall be passed in 0x0.

Stage5, the last one. It calls back the solve function of the current contract. But if it fails to return, the first pass succeeds and the second pass fails. We control the gas when we consider the call. This is done later when debugging, which is easier to handle. First construct the data. First look at the function signature of solve.

Then it is to construct the data. First, you need to get 4 uint256 type data, which satisfies >=1, and can only be divided by 1 and itself. The above has been constructed.

This structure can meet stage2, stage3, the reasons have been explained above.Next stage4. Because there are 3 integers only ,missing one, stage 2 is not satisfied, so it has to find another number.

First of all, let’s find out how the bytes string is stored in memory. Although stage4 lets you pass 2 strings, but only a is useful. For string value, it stores the length first, and then store the content. The msg.data in the first line represents the offset, so our next construction goes to line 4, which just conflicts with the fourth parameter of stage2. Because it starts from the 61th position, the 4th line is definitely not full, we can add 0 to the following data. So we construct data with the length of 0100, and let its performance in the 4th line be 01, which satisfies stage2.

We only need to construct as above to satisfy stage1, 2, and 3. The data required by Stage 4, it can be done by abi encoding. Then fill it with 0 until the length up to 0100 bytes. The full data shown as below:

Controlled by contract call, and passing in our msg.data. Now we only need to find the gas cost of stage5. Shown as below figure:

We print the result of gas here directly, the result is 409548.

Since it I a bit troublesome to find, it’s better to engage in directly, like below:

Then we see that the result of locked is false after executing solve. Capturing the flag.

--

--

Numen Cyber Labs
Numen Cyber Labs

Numen Cyber Technology is a Cybersecurity vendor and solution provider based in Singapore.We dedicate ourselves in Web3 Security and Threat Detection & Response