In this post I’m using frida1 reversing framework and angr2 binary analysis framework to solve a couple of challenges in the PwnAdventure33 CTF game, before moving to regular reverse engineering.

The main objective for the analysis is the dynamically linked shared library “GameLogic”. I used both the Mach-O (.dylib) and ELF (.so) files depending on the task.

I started reversing it using Hopper and hooking interesting functionality using frida.

otool -L "$(locate MacOS/PwnAdventure3)"
/Applications/Pwn Adventure 3.app/Contents/PwnAdventure3/PwnAdventure3.app/Contents/MacOS/PwnAdventure3:
        @loader_path/GameLogic.dylib (compatibility version 0.0.0, current version 0.0.0)

Following javascript enables speed, fly, teleport and mana hack. The hack hooks the Player::Chat(char const*) function for the user to provide input to enable or disable a cheat, e.g. !speed 2000.

To load it using frida:

frida -l frida.js -n PwnAdventure3

Where frida.js is displayed below.

NOTE: Note that I used the MacOS version so the offsets will (probably) differ on other OSes.

'use script';

var _ptrVector3  = Memory.alloc(12); // allocate 12 bytes of memory on the heap
var _ptrPVector3 = Memory.alloc(12); // allocate 12 bytes of memory on the heap

// native function call to void Actor::SetPosition(Vector3 const&)
var fnSetPosition = new NativeFunction(Module.findExportByName("GameLogic.dylib", "_ZN5Actor11SetPositionERK7Vector3"), 'void', ['pointer', 'pointer']);

function setPosition(that, x, y, z) {
    Memory.writeFloat(ptr(_ptrVector3).add(0), x);
    Memory.writeFloat(ptr(_ptrVector3).add(4), y);
    Memory.writeFloat(ptr(_ptrVector3).add(8), z);
    fnSetPosition(that, _ptrVector3);
}

// native function call to Vector3 Actor::GetPosition()
var fnGetPosition = new NativeFunction(Module.findExportByName("GameLogic.dylib", "_ZN5Actor11GetPositionEv"), 'pointer', ['pointer']);

// hook int Player::Chat(char const*)
Interceptor.attach(Module.findExportByName("GameLogic.dylib", "_ZN6Player4ChatEPKc"), {
    onEnter: function (args) { // 0 => this; 1 => cont char*
        var msg = Memory.readCString(args[1]).split(" ");
        console.log("[Chat]: " + msg);

        switch(msg[0]) {

        // Enable speed hack with "!speed 1000"
        // Offset 0x270 taken from "int Player::GetWalkingSpeed()"
        case "!speed":
            Memory.writeFloat(ptr(args[0]).add(0x270), parseFloat(msg[1]));
            break;

        // Enable fly hack with "!jump 1000"
        // Offset 0x274 taken from "int Player::GetJumpSpeed()"
        case "!fly":    
            Memory.writeFloat(ptr(args[0]).add(0x274), parseFloat(msg[1]));
            break;

        // Enable teleport hack with "!teleport 0 0 0"
        case "!teleport":
            _ptrPVector3 = fnGetPosition(args[0]);
            setPosition(args[0], parseFloat(args[1]), parseFloat(args[2]), parseFloat(args[3]));
            break;

        case "!return": // Get position
            fnSetPosition(args[0], _ptrPVector3);
            break;
        }
    }
});

// hook int Player::Tick(float)
Interceptor.attach(Module.findExportByName("GameLogic.dylib", "_ZN6Player4TickEf"), {
    onEnter: function (args) { // 0 => this
        // Enables mana hack
        // Offset 0x1c8 taken from "int Player::GetMana()"
        Memory.writeInt(ptr(args[0]).add(0x1c8), 99);
    }
});

// World::SpawnActorAtNamedLocation(Actor*, char const*)
Interceptor.attach(Module.findExportByName("GameLogic.dylib", "_ZN5World25SpawnActorAtNamedLocationEP5ActorPKc"), {
    onEnter: function (args) { // 0 => this; 1 => Actor*; 2 => char const*
        console.log("World::SpawnActorAtNamedLocation(): " + Memory.readCString(args[2]));
    }
});

// int AIState::GetTarget()
Interceptor.attach(Module.findExportByName("GameLogic.dylib", "_ZNK7AIState9GetTargetEv"), {
    onLeave: function (retval) {
        retval.replace(0);
    }
});

// int Player::CanJump()
Interceptor.attach(Module.findExportByName("GameLogic.dylib", "_ZN6Player7CanJumpEv"), {
    onLeave: function (retval) {
        retval.replace(1); // Fly hack by int Player::CanJump() = 1
    }
});

// int BearChest::GetMinimumTimeRemaining()
Interceptor.attach(Module.findExportByName("GameLogic.dylib", "_ZN9BearChest23GetMinimumTimeRemainingEv"), {
    onLeave: function (retval) { // 0 => this
        console.log("Memory.readInt(retval)");
        // console.log(Memory.readInt(ptr(args[0]).add(0xa0)));
    }
});

for (var i = 0; i < hooks.length; i++) {
    var tmp_i = hooks[i];
    var addr = Module.findExportByName("GameLogic.dylib", tmp_i);
    console.log(tmp_i + " at  address: " + addr);
    Interceptor.attach(addr, {
        onLeave: function (ret) { // 0 => this
            console.log("onLeave (" + tmp_i + "): " + ret);
        }
    });
}

I have also added a angr binary analysis script to solve the DLC key in KeyVerifier::VerifyKey(std::string const&), exported mangled symbol _ZN11KeyVerifier9VerifyKeyERKSs @ 0x00208a40. The function returns (eax = 0x1) if correct DLC key is inserted.

I used pypy3 to improve performance, hooked a couple functions, but my laptop was slow so I didn’t have much hope on this approach.

NOTE: Note that I moved to Linux ELF shared library (.so) instead of Mach-O due to angr’s buggy support of Mach-O executables. I also use unicorn optimization.

Figure 1, see below, displays the call flow graph of KeyVerifier::VerifyKey(std::string const&), where the entry and exist are indicated in yellow, return success branch (eax 0x1) in green and blacklisted branches in red.

#!/usr/bin/env pypy3

import angr
import claripy
import sys
import logging
import time

logging.getLogger("angr").setLevel("WARNING")

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

project = angr.Project("libGameLogic.so",
  load_options={
    "auto_load_libs" : True,
    'main_opts' : {
      'base_addr' : 0x4000000
    }
})

func_addr = project.loader.find_symbol("_ZN11KeyVerifier9VerifyKeyERKSs").rebased_addr

fixed_size = 25
DLC = claripy.BVV(b'\x00' * fixed_size, fixed_size * 8)
logger.debug(DLC)

def dlc_size(state):
    state.regs.rax = fixed_size

project.hook(0x04208e10, angr.SIM_PROCEDURES['libc']['memset']())
project.hook(0x04208e60, angr.SIM_PROCEDURES['libc']['memcpy']())
#project.hook(0x041210a0, angr.SIM_PROCEDURES['stubs']['ReturnUnconstrained']())
project.hook(0x0411ee50, dlc_size)

state = project.factory.call_state(func_addr, DLC, add_options=angr.options.unicorn)

sm = project.factory.simgr(state)

find = 0x04208dfb
avoid = [ 0x04208afe, 0x04208ace, 0x04208b36, 0x04208dd4, 0x04208ba0 ]

logger.debug(f"call function: {hex(func_addr)}")
logger.debug(f"find address: {hex(find)}")

#sm.use_technique(angr.exploration_techniques.Threading())
sm.use_technique(angr.exploration_techniques.Explorer())

start = time.time()

logger.debug("run explorer")
sm.explore(find=find, avoid=avoid, step_func=lambda lpg: lpg.drop(stash='deadended'))
logger.debug("solution found")

stop = time.time()
print(f"Time elapsed: {stop - start}")

print(sm.found)
found = sm.found[-1]

def ffilter(state, byte):
  a = state.solver.And(byte >= 0x41, byte <= 0x5a)
  b = state.solver.And(byte >= 0x30, byte <= 0x39)
  return state.solver.Or(a, b)

for byte in DLC.chop(8):
  found.add_constraints(ffilter(found, byte))
logger.info("min: %d" % found.solver.min(DLC))

keys = found.solver.eval_atleast(DLC, 100, cast_to=bytes)

for key in keys:
  print(key.split(b'\x00')[0].decode())

I let it run for the night, without any result the morning after… well I tried. I realised I have too look further into the code. But time flied and I had other things to work on.

months later ..

By looking into the first call in VerifyKey(), the exported symbol cfZTUjEJ(char input,uint8_t *len), I saw that it validates the DLC key against a “0123456789ABCDEFHJKLMNPQRTUVWXYZ” string. Given the length (32 bytes) I assumed it can be some kind of BASE32 encoding scheme. The cfZTUjEJ() function is called within the grey block in figure 1, validating the DLC key be removing character '-' and white spaces to verify that the key material is 25 uppercase alphanumeric characters. It isn’t that interesting…

Figure 1: Call flow graph of KeyVerifier::VerifyKey(std::string const&) function.

The orange block in figure 1 calculates a checksum over the DLC[0:23] and stores it in in the last byte DLC[24], decompiled below.

chksum = 0;
j = 0;
while (j < 24) {
  chksum = chksum + DLC[j];
  j = j + 1;
}
chksum = chksum & 0x1f;
if (DLC[24] == chksum) {
  ...

The purple blocks implements the custom BASE32 decoding, decompiled below.

memset(DLC_decoded,0,15);
k = 0;
while (k < 24) {
  l = 0;
  while (l < 5) {
    if ((DLC[k] & 1 << ((uint8_t)l & 0x1f)) != 0) {
      l = k * 5 + l >> 3;
      DLC_decoded[l] = DLC_decoded[l] | (uint8_t)(1 << ((uint8_t)k * 5 + (uint8_t)l & 7));
    }
    l = l + 1;
  }
  k = k + 1;
}

The hard part is the implemented in the blue blocks, decompiled below. I had to consult Internet to fully understand it, since I didn’t see the 0x10001 exponent. But it appears to be a RSA decryption routine with modulus n 0x3c9921ac0185b3aaae37e1b and exponent e 0x10001.

memcpy(DLC_0_15,DLC_decoded,12);
DLC_0_15[11] = DLC_0_15[11] & 3;
memcpy(DLC_0_15 + 0xc,DLC_decoded + 0xb,4);
DLC_0_15._12_4_ = DLC_0_15._12_4_ >> 2 ^ 0x2badc0de;
memcpy(rsa_expected,DLC_0_15 + 12,4);
memcpy(rsa_expected + 4,(uint8_t *)"PWNADV3",7);
rsa_modulus[11] = 0x03;
rsa_modulus[10] = 0xc9;
rsa_modulus[9] =  0x92;
rsa_modulus[8] =  0x1a;
rsa_modulus[7] =  0xc0;
rsa_modulus[6] =  0x18;
rsa_modulus[5] =  0x5b;
rsa_modulus[4] =  0x3a;
rsa_modulus[3] =  0xaa;
rsa_modulus[2] =  0xe3;
rsa_modulus[1] =  0x7e;
rsa_modulus[0] =  0x1b;
rsa_exponent[0] = 0x01;
rsa_exponent[1] = 0x00;
rsa_exponent[2] = 0x01;
rsa_decrypt(rsa_modulus,12,rsa_exponent,3,rsa_decrypted,DLC_0_15);
m = 0;
while (m < 12) {
  if (rsa_decrypted[m] != rsa_expected[m]) {
    return false;
  }
  m = m + 1;
}
return_value = true;

I factored the modulus n using factordb4 to get p and q with:

factordb $(python -c "print(0x3C9921AC0185B3AAAE37E1B)")
33759901540733 34719860683127

Perfect. Now I can calculate d using Euler’s Totient formula:

#!/usr/bin/env python3

from math import gcd

p = 33759901540733
q = 34719860683127
e = 0x10001
phi = (p-1) * (q-1)

def egcd(a, b):
    if a == 0:
        return b, 0, 1
    else:
        g, y, x = egcd(b % a, a)
        return g, x - (b // a) * y, y

def modinv(a, m):
    g, x, y = egcd(a, m)
    if g != 1:
        raise Exception('modular inverse does not exist')
    else:
        return x % m

d = modinv(e, phi)
print(d)

The RSA private key d is 117398124862438392921820577 (0x611c0519e05065e8f38da1).

After RSA decryption, the routine checks if the decrypted array contains the string “PWNADV3”. Also, the routine contains a check for the first DWORD of the decrypted array. It takes the last 4 bytes from the BASE32-decoded bytearray and performs an XOR-operation with a value of 0x2badc0de and shifts it 2 bits to the right. The result of this expression should be equal to the first DWORD after RSA decryption.

#!/usr/bin/env python3

from math import floor, ceil
from random import randint

def base32encode(s):
    r_len = ceil(len(s) * 8 / 5)
    r = bytearray(b'\x00' * r_len)
    for i in range(len(s)*8):
        if s[floor(i/8)] & (1 << (i & 0b111)):
            r[floor(i/5)] |= (1 << i % 5)
    return r

def main():
    PWNADV3 = b'PWNADV3'
    rnd = randint(0,0x3FFFFFFF)
    xor = (rnd ^ 0x2badc0de) >> 2
    PWNADV3 += xor.to_bytes(4, 'big')
    msg = int.from_bytes(PWNADV3, 'big')

    rsa_encrypted = pow(msg, 0x611c0519e05065e8f38da1, 0x3c9921ac0185b3aaae37e1b)

    rnd_bytes = rnd.to_bytes(4, 'little')
    buffer1 = bytearray(rsa_encrypted.to_bytes(12, 'little'))
    buffer1[11] = buffer1[11] | int.from_bytes(rnd_bytes[:1], 'little')
    buffer1 += rnd_bytes[1:]

    e = base32encode(buffer1)

    chksum = 0
    DLC = str()
    base32alphabet = '0123456789ABCDEFHJKLMNPQRTUVWXYZ'
    for k in e:
        DLC += base32alphabet[k]
        chksum += ord(base32alphabet[k])
    DLC += base32alphabet[chksum & 0x1f]
    print(DLC)

if __name__=='__main__':
    main()

BJ36UZ7A0JQH4U5L3X6DK7H6Y

References
  1. https://frida.re 

  2. https://angr.io 

  3. https://pwnadventure.com 

  4. https://pypi.org/project/factordb-python