[WRITE-UP] PwnAdventure3 CTF
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
