TISC is a two-week online sequential style CTF competition hosted by CSIT, where participants solve a series of 12 challenges. Over 1300 participants took on the challenges, with the top three levels of challenges having a cash prize pool of $10,000 each to be shared between participants who’ve successfully cleared the respective levels.
I was given the opportunity to create the challenge at level 7, a Web3 reverse engineering/binary exploitation challenge. I’ll be sharing the writeup, as well as source code and my thoughts, at the end of the blog post. You can check out the statistics for the ctf here. 17 people managed to solve my challenge; thank you friends.
Challenge
📘 Challenge description:
While scanning their network, you chance upon a tool that checks for the validity of a secret passphrase. You know that they use this phrase for establishing communications between one another, but the one you have is way outdated.
It’s time for an update.
Link to source code (including setup files, files given to the participants, and solution script)
The website is stupid simple. It’s just one text input with a submit button, and if your passphrase isn’t correct, “Invalid” is flashed on the screen. Participants don’t actually know what’s displayed when the passphrase is correct so we’ll leave that as a surprise for later.
Writeup
TLDR
SSTI to information leak to side channel attack via gas usage
NLDR (Not long did read) - Stage 1: SSTI to information leak
Trivially, the input is vulnerable to SSTI. Using standard CTF techniques to enumerate the machine and achieve RCE will be futile as the only instance of the flag is stored on the private blockchain network. Also, there’s a WAF that blocks strings more than 32 characters long. This length check was honestly so I didn’t have to deal with any ABI shenanigans when calling the contract, but I’m glad it had the unintended effect of discouraging people from going down the SSTI rabbit hole.
@app.route('/submit', methods=['POST'])
def submit():
password = request.form['password']
try:
if len(password) > 32:
return render_template_string("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Check Result</title>
</head>
<body>
<h1>Keyphrase too long!</h1>
<a href="/">Go back</a>
</body>
</html>
""")
# Rest of endpoint here
return render_template_string("""
...
""", response_data=response_data)
Looking at app/main.py
, we can see that the whole of response_data
is passed into Jinja’s context instead of just the output. The object format can be seen in server/connect_to_testnet.py
:
{
"output": output,
"contract_address": setup_contract.address,
"setup_contract_bytecode": os.environ['SETUP_BYTECODE'],
"adminpanel_contract_bytecode": os.environ['ADMINPANEL_BYTECODE'],
"secret_contract_bytecode": os.environ['SECRET_BYTECODE'],
"gas": gas
}
We can leak all this information using the payload {{ response_data }}
.
Stage 2: Reverse engineering the smart contract bytecode
From the previous stage, we get:
{'output': False,
'setup_contract_address': '0x5FC8d32690cc91D4c39d9d3abcBD16989F875707',
'setup_contract_bytecode': '0x608060405234801561001057600080fd5b5060405161027838038061027883398101604081905261002f9161007c565b600080546001600160a01b039384166001600160a01b031991821617909155600180549290931691161790556100af565b80516001600160a01b038116811461007757600080fd5b919050565b6000806040838503121561008f57600080fd5b61009883610060565b91506100a660208401610060565b90509250929050565b6101ba806100be6000396000f3fe608060405234801561001057600080fd5b506004361061002b5760003560e01c8063410eee0214610030575b600080fd5b61004361003e366004610115565b610057565b604051901515815260200160405180910390f35b6000805460015460408051602481018690526001600160a01b0392831660448083019190915282518083039091018152606490910182526020810180516001600160e01b0316635449534360e01b17905290518493849316916100b99161012e565b6000604051808303816000865af19150503d80600081146100f6576040519150601f19603f3d011682016040523d82523d6000602084013e6100fb565b606091505b50915091508061010a9061015d565b600114949350505050565b60006020828403121561012757600080fd5b5035919050565b6000825160005b8181101561014f5760208186018101518583015201610135565b506000920191825250919050565b8051602080830151919081101561017e576000198160200360031b1b821691505b5091905056fea2646970667358221220e0f8333be083b807f8951d4868a6231b41254b2f6157a9fb62eff1bcefafd84e64736f6c63430008130033',
'adminpanel_contract_bytecode': '0x60858060093d393df35f358060d81c64544953437b148160801b60f81c607d1401600214610022575f5ffd5b6004356098636b35340a6060526020606020901b186024356366fbf07e60205260205f6004603c845af4505f515f5f5b82821a85831a14610070575b9060010180600d146100785790610052565b60010161005e565b81600d1460405260206040f3',
'secret_contract_bytecode': '0xREDACTED',
'gas': 29307}
There are 3 contracts deployed to the network: Setup
, AdminPanel
, and Secret
. Secret
’s bytecode has been redacted, probably because it contains the keyphrase. We still have the bytecode from the other two contracts, however, and can see how our keyphrase is being checked.
Before going into reverse engineering, we can get a better idea of Setup
by looking at the server files provided, giving us a better place to start.
uint256 deployerPrivateKey = DEPLOYER_PRIVATE_KEY;
vm.startBroadcast(deployerPrivateKey);
Setup setup = new Setup(address(adminPanel), address(secret));
console2.log("Setup contract deployed to: ", address(setup));
vm.stopBroadcast();
# server/contracts/script/Deploy.s.sol
passwordEncoded = '0x' + bytes(password.ljust(32, '\0'), 'utf-8').hex()
try:
gas = setup_contract.functions.checkPassword(passwordEncoded).estimate_gas()
output = setup_contract.functions.checkPassword(passwordEncoded).call()
Setup
’s constructor function takes in two addresses, that of AdminPanel
and Secret
. It has another external function checkPassword(bytes32)
that takes in the keyphrase and outputs a boolean.
We can pass Setup
’s bytecode into any Ethereum Virtual Machine decompiler (this writeup uses free and publically available decompilers like heimdall and dedaub, but if you want to look at the disassembly that’s fine too, the contracts in this challenge are really small) and look at the output. Since the bytecode includes the constructor, only the deployment code appears at first:
uint160 ___function_selector__; // STORAGE[0x0] bytes 0 to 19
uint160 stor_1_0_19; // STORAGE[0x1] bytes 0 to 19
function function_selector() public payable {
...
___function_selector__ = MEM[MEM[64]];
stor_1_0_19 = MEM[MEM[64] + 32];
MEM[0:442] = 0x6080...;
return MEM[0:442];
}
We see the addresses of AdminPanel
and Secret
saved into EVM storage, and some bytecode getting returned from the function. This will be the deployed bytecode of Setup
where our checkPassword
function is. We can copy the string and decompile it once more:
bytes32 store_b;
bytes32 store_a;
function Unresolved_410eee02(uint256 arg0) public payable returns (bool) {
uint256 var_a = arg0;
address var_b = address(store_a);
uint256 var_c = 0x44 + (var_d - var_d);
uint256 var_d = var_d + 0x64;
uint224 var_e = 0x5449534300000000000000000000000000000000000000000000000000000000 | (uint224(var_f));
uint256 var_g = 0;
(bool success, bytes memory ret0) = address(store_b).call{ gas: gasleft(), value: var_g }(abi.encode());
...
}
bytes32 store_b
is AdminPanel
’s address, and bytes32 store_a
is Secret
’s. Function Unresolved_410eee02
is almost certainly checkPassword
, and our keyphrase is being passed in as arg0
. A call is being done on a function in AdminPanel
using solidity’s ABI specification, which you can read here. Knowing how the ABI works is pretty much the crux of this stage since the AdminPanel contract has been obfuscated with a non-standard function selector and calldata retrieval method. Details of the CALL instruction are as follows:
Stack Input | Description |
---|---|
gas | Amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one. |
address | The account whose context to execute. |
value | Value in wei to send to the account. |
argsOffset | Byte offset in the memory in bytes, the calldata of the sub context. |
argsSize | Byte size to copy (size of the calldata). |
retOffset | Byte offset in the memory in bytes, where to store the return data of the sub context. |
retSize | Byte size to copy (size of the return data). |
In the evm, when a call is made to a smart contract, both the function to call and its arguments are passed as one long bytestring referred to as args
. The first 4 bytes of this bytestring are reserved for the function signature, which specifies the function to call within the contract. All data after the signature are the function arguments, and each argument takes exactly 32 bytes. This is, of course, assuming that the contract was compiled with these standards in mind. The AdminPanel
contract was not.
Let’s reverse engineer it along with Setup
to help us understand how the call is done. Removing the constructor and decompiling is finnicky with most online tools (as the instructions were written manually), but luckily this contract is tiny and the disassembly is a mere 88 instructions long (as the instructions were written manually). Simply referring to the evm opcode reference and tracing the stack is enough to debug most problematic decompiler outputs and give you this function:
function (bytes4 function_signature, uint256 varg1, uint256 varg2) public payable {
require(0x544953437B == args[0:5]);
require(0x7D == args[16]);
v0 = varg2.delegatecall(0x66fbf07e).gas(msg.gas);
i = num_of_chars_correct = 0;
while (1) {
if ((keccak256(0x6b35340a) << 152 ^ varg1)[v1] == v0[v1]) {
num_of_chars_correct += 1;
}
i += 1;
if (i == 13) {
return num_of_chars_correct == 13;
}
}
}
It’s apparent that this function is doing a character-by-character comparison of two strings. The first string is the first argument to the function XORed with some key, and the second is the result of a delegatecall to the contract at the address specified by our second argument. Cross-referencing the Setup
contract, it’s clear that the second argument is the address to the Secret
contract, and the first is our submitted keyphrase. Note that there are also checks on the args
bytestring as a whole at the start of the function
The args
used to call AdminPanel
would therefore have to look something like this:
Size | Data | Description |
---|---|---|
4 bytes | 0x54495343 | Function signature |
32 bytes | 0x7B??????????????????????7D000000… | Keyphrase |
32 bytes | 0x????… | Address of Secret contract |
Chaining all these arguments together into the ABI encoded bytestring, we get:
0x544953437B??????????????????????7D0000...??????...
Note how the function signature and the keyphrase form a contiguous section that decodes to TISC{???????????}
. This is obviously the flag, but we can’t look at the Secret
contract to see what its being compared to. What now?
Stage 3: Side channel attack using calculated gas estimate
This premise, combined with the odd character-by-character comparison between the strings instead of using the EQ instruction, intuitively tells us that there’s some form of blind attack we need to find some side channel for, but coming up with a definite solution is tricky. The key thing to note here is how the evm calculates gas prices for each transaction.
When you make a transaction or call a function, there are gas fees involved that are paid to the validators. How this gas fee is calculated is based on the evm instructions ran per transaction. Each opcode corresponds to a set gas value, and throughout the execution of the code, these gas values are tallied up to give a numerical estimate for the computational cost of the transaction. This number is then multiplied by the current cost of gas to get the final fee.
In the debugging information we managed to leak earlier, we can see the total gas value our transaction has used. The interesting thing about this value is that it changes based on the path the transaction takes down the control flow of the program, as the number of instructions ran through varies. We can use this idea to do a side-channel attack using the gas value as our metric.
When a successful character comparison is done, the control flow deviates and runs one extra line of code:
num_of_chars_correct += 1;
In the disassembly output, this deviation corresponds to these instructions:
0x70: JUMPDEST 1 gas
0x71: PUSH1 0x1 3 gas
0x73: ADD 3 gas
0x74: PUSH2 0x5e 3 gas
0x77: JUMP 8 gas
which adds up to 18 gas total. This means that for every correct character in our keyphrase, the gas value will increase by 18. Submitting
{00000000000}{{ response_data }}
and reading the gas value gives us 33365, but submitting
{g0000000000}{{ response_data }}
gives us a value of 33383, telling us that our flag starts with g. We can do the same to all the characters in the flag with the script in solve.py
to get the flag!
TISC{g@s_Ga5_94S}
Thoughts
I came up with the idea of doing a gas side channel attack in my sleep.
I was stressed the hell out coming up with an idea to best all other previous CTF challenges I’ve made thus far (and I’ll be the first to say they’re all really good challenges), and I was convinced that EVM was the way to go since I’d been doing so much research into it lately, but nothing really clicked. I tried making an EVM pwn before this, but I had to do so many weird things that people wouldn’t normally do to make a pointer overwrite possible, and I don’t like it when CTF challenges just do the most unbelievable things that no sane developer would do just to get a point across.
It was a real eureka moment for me, using the gas estimate as a means to count instructions. I don’t think I’ve seen this exploit being documented online before (partially because not a lot of people deploy contracts to private networks), and it was a good opportunity to introduce the concept of the evm and gas calculations to participants. I was so excited (ask anyone in the office on that day) since I had been struggling on a challenge idea for literal weeks.
Making the challenge
If you look at the contracts’ source code, you’ll notice that instead of solidity, they’re written in an evm-level language called Huff. I’ll put the contract code here for convenience:
/* AdminPanel.huff */
/* Interface */
#define function checkPassword(address,bytes32) nonpayable returns (bytes32)
/* Methods */
#define macro CHECK_PASSWORD() = takes (0) returns (5) {
0x04 calldataload // [password]
// XOR with secret key
0x98 // [length, plaintext]
0x6b35340a 0x60 mstore // [length, plaintext]
0x20 0x60 sha3 // [hash, length, plaintext]
swap1 shl xor // [xoredPlaintext]
0x24 calldataload // [secretAddr, password]
// Store function signature in memory
0x66fbf07e 0x20 mstore
// Retrieve password from external contract
0x20 0x00 0x04 0x3C dup5 gas delegatecall
pop 0x00 mload // [secret, secretAddr, password]
// Push itercount and correctCount to the stack
0x00 0x00 // [correctCount, itercount, secret, secretAddr, password]
iterativeCheck:
dup3 dup3 byte // [secretNth, correctCount, itercount, secret, secretAddr, password]
dup6 dup4 byte // [flagNth, secretNth, correctCount, itercount, secret, secretAddr, password]
eq correctChar jumpi // [correctCount, itercount, secret, secretAddr, password]
iterativeCheckAfterJump:
swap1 0x01 add // [itercount++, correctCount, secret, secretAddr, password]
dup1 0x0D eq end jumpi // [itercount, correctCount, secret, secretAddr, password]
swap1 // [correctCount, itercount, secret, secretAddr, password]
iterativeCheck jump
correctChar:
0x01 add // [correctCount++, itercount, secret, secretAddr, password]
iterativeCheckAfterJump jump
end:
dup2 0x0D eq // [correct?, itercount, correctCount, secret, secretAddr, password]
0x40 mstore // [itercount, correctCount, secret, secretAddr, password]
0x20 0x40 return
}
#define macro MAIN() = takes (0) returns (1) {
// Check for flag format
0x00 calldataload // ["TISC{###########}"]
dup1 0xD8 shr // ["TISC{", "TISC{###########}"]
0x544953437B eq // [1, "TISC{###########}"]
dup2 0x80 shl 0xF8 shr // ["}", 1, "TISC{###########}"]
0x7D eq // [1, 1, "TISC{###########}"]
add 0x02 eq checkPassword jumpi
0x00 0x00 revert
checkPassword:
CHECK_PASSWORD()
}
/* Secret.huff */
/* Interface */
#define function secretP1ssp00r() nonpayable returns (bytes32)
/* Methods */
#define macro SECRET_P1SSP00R() = takes (0) returns (0) {
// Store "{g@s_Ga5_94S}" encrypted in memory for return
0xDEAF50391118A37595C50AC9F700000000000000000000000000000000000000 0x00 mstore
// End Execution
0x20 0x00 return
}
#define macro MAIN() = takes (0) returns (0) {
// Identify which function is being called.
0x00 calldataload 0xE0 shr
dup1 __FUNC_SIG(secretP1ssp00r) eq secretP1ssp00r jumpi
0x00 0x00 revert
secretP1ssp00r: // So that people cant find this on 4 byte signature databases
SECRET_P1SSP00R()
}
The reason for this is that I was running out of time to submit the challenge, so I couldn’t draft an applicable control flow graph that solidity would compile to before the deadline. Because of this, I chose to implement the most textbook example of a function vulnerable to side channel attacks, which involved writing the instructions manually in huff since I didn’t have time to deal with compiler shenanigans.
Next time I make this challenge, it’ll be a huge CFG with multiple paths so you actually have to do some math to arrive at the solution.