MidnightSunCTF Finals 2021 - Climb & Crackme (4 solves & 2 solves)
Эльбрус Reversing
September 20, 2021
rev
elbrus
e2k
Элбрус reversing
Description:
I played on the team “An Equal Mix” for MidnightSunCTF Finals 2021 and we got 5th place. Unfortunately the competition was held virtually (instead of in Sweden) so the start time ended up being relatively unfavorable for our US-based team (6am eastern and 3am pacific!).
This write up covers two of the challenges from finals: “Climb the mountain” and “Crack me if you can.” Both of these were reversing challenges written for the esoteric, Russian Эльбрус (Elbrus) CPU. I ended up spending most of the CTF working on these two problems due to several reasons discussed below but I learned a lot about this strange architecture and developed some new skills related to patching.
A brief guide to reversing Эльбрус
For a more complete (external) guide, see https://github.com/nrdmn/elbrus-docs
The most notable feature about Эльбрус assembly is the use of VLIW instructions. A single 512-bit word can encode several instructions that execute simultaneously. In objdump
disassembly, these instructions appear in blocks like this:
10818: 13 90 00 04 20 04 00 c0 01 80 c6 8c 05 00 00 50
ipd 3
sxt,0,sm 6, %r0, %db[1]
call %ctpr1, wbs = 0x5
10828: 12 40 00 04 83 83 c1 10 f8 ff ff 4f 00 00 00 00
adds,0 1, %r3, %r3
disp %ctpr1,107e8 <start+0xc8>
10838: 81 01 00 04 22 c8 83 20
nop 3
cmpbsb,0 %r3, 8, %pred2
10840: 01 10 00 00 42 04 00 c0
ct %ctpr1 ? %pred2
ipd 3
Most of the time this simultaneity is not relevant for reversing purposes and it is sufficient to pretend that instructions are executed one after the other.
Registers
Эльбрус has 32- and 64-bit regigisters %r0
, %r1
, … and %dr0
, %dr1
, … as well as mobile-base registers (kind of like stack arguments) %b[0]
, %b[1]
, … and %db[0]
, %db[1]
, …
Basic instructions
Most instructions are familiar to someone with experience in any RISC architecture. e2k-objdump
includes information about instruction packing/encoding which usually can be ignored. For example here are some e2k
instructions and their function:
adds,0 1, %r3, %r3
: Add 1 to%r3
and store in%r3
addd,0,sm 0, _f64,_lts0 0x1af2d0, %db[1]
: Add0
to0x1af2d0
and store in%db[1]
std,2,sm %db[0], [ %dr1 ]
: Store the 64-bit value%db[0]
in the address%dr1
ldw,0 [ _f64,_lts0 0x1b0968 ], %r8
: Load a 32-bit value from address0x1b0968
into%r8
Calls
Subroutine calls require a disp
instruction followed later by a call
instruction. For example, the following two blocks set up and invoke a call to start
:
{
nop 4
disp %ctpr1,10720 <start>
}
{
ipd 3
call %ctpr1, wbs = 0x4
}
disp %ctpr1
configures the control transfer preparation register %ctpr1
and the following call %ctpr1
invokes the jump. Note that the call
can occur much later in a given program.
In e2k-lcc
calling convention, arguments are passed in the mobile-base registers or on the stack. For example, examine the following assembly code:
{
getsp,0 _f32,_lts1 0xffffffe0, %dr1
disp %ctpr1,6aec0 <__libc_write>
setwd wsz = 0x8, nfx = 0x1
setbn rsz = 0x3, rbs = 0x4, rcur = 0x0
}
{
addd,0,sm 0, _f32,_lts2 0x25, %db[2]
addd,1,sm 0, _f64,_lts0 0x190264, %db[1]
addd,2,sm 0, 1, %db[0]
}
{
std,2,sm %db[2], [ 16 + %dr1 ]
std,5,sm %db[1], [ 8 + %dr1 ]
}
{
nop 2
std,2,sm %db[0], [ %dr1 ]
}
{
ipd 3
call %ctpr1, wbs = 0x4
}
This code prepares a call to __libc_write
and then configures three arguments 1
, 0x190264
, and 0x25
which are placed on the stack by three std
instructions. Finally the call
invokes the subroutine.
Equivalent C code would look like:
__libc_write(1, (const char *)0x190264, 0x25);
Conditionals
Conditional logic is obtained through the use of predicates
. Compare instructions set predicate registers which can be used to conditionally execute subsequent instructions.
Take the following assembly in the func3
subroutine for example:
{
ldw,0 [ _f64,_lts0 0x1af2dc ], %r1
disp %ctpr1,10708 <func3+0x1a0>
}
{
nop 2
cmpesb,0 %r1, _f32,_lts0 0x3baeb3e2, %pred7
}
{
ct %ctpr1 ? %pred7
ipd 3
}
The first block prepares a control transfer preparation register with a label address (func3+0x1a0
). The second block executes %pred7 = (%r1 == 0x3baeb3e2)
. The third block conditionally invokes ct %ctpr1
(a control transfer) if %pred7
is true. Note that instructions can also execute if a predicate is false, i.e. ct %ctpr1 ? ~ %pred7
would be the inverted variant.
Part 1: Climb the Mountain
I started working on this problem at 8am eastern (2 hours into the competition). My only previous experience with Elbrus was a CSAW problem from 2019 which I didn’t solve so I was determined to overcome that hurdle.
In the old CSAW problem, the challenge author had provided an e2k-linux-gnu
binutils toolchain containing things like objdump
and gcc
(https://drive.google.com/file/d/1rdlSKGkOXC9ejXaWC2mUKZd6GYxdtHLt/view?usp=sharing) so I used this objdump to disassemble the climb
binary.
Luckily for us, the binary is not stripped so we have function names and we can see that libc has been statically linked.
The challenge-relevant part of the binary is surprisingly short and we only have the following functions to deal with: swap
, func1
, func2
, func3
, start
and main
.
main
In main
, we see a call to __libc_write
(provided in the example above) which simply prints Mountain Base, the hike starts here.
followed by a call to start
.
start
At a cursory glance, start
invokes __libc_read(0, 0x1b2638, 0x20)
then func1
, then func2
a bunch of times and finally func3
. There is some nuance in the middle part so I decided to start with the actual func
subroutines first.
func1
The first function proved to be relatively short as you can see:
{
adds,0,sm 0, 0, %r2
disp %ctpr1,10428 <func1+0x90>
setwd wsz = 0x5, nfx = 0x1
}
{
nop 3
cmplsb,0 %r2, _f16s,_lts0hi 0x20, %pred0
}
{
ct %ctpr1 ? ~ %pred0
ipd 3
}
[func1+0x30]
{
sxt,0 2, %r2, %dr0
sxt,1 2, %r2, %dr1
adds,2 1, %r2, %r2
disp %ctpr1,103c8 <func1+0x30>
}
{
nop 2
ldb,0 [ %dr0 + _f64,_lts1 0x1b2638 ], %r0
cmplsb,1 %r2, _f16s,_lts0hi 0x20, %pred1
}
{
sxt,0 0, %r0, %dr0
}
{
nop 2
ldb,0 [ %dr0 + _f64,_lts0 0x1af1c0 ], %r0
}
{
ct %ctpr1 ? %pred1
ipd 3
stb,2,sm %r0, [ %dr1 + _f64,_lts0 0x1b2638 ]
}
[func+0x90]
{
nop 5
return %ctpr3
}
{
ct %ctpr3
ipd 3
}
I hand-reversed this into the following C-pseudocode:
uint8_t user[0x20]; // 0x1b2638
uing8_t lookup[0x100]; // 0x1af1c0
void func1() {
for (int i = 0; i < 32; ++i) {
user[i] = lookup[user[i]];
}
}
In essense it is simply applying a map on the user input buffer. If we example the bytes at address 0x1af1c0
, we see that this is the reverse-sbox from AES.
func2
The second function performed a parity check given two input values. I cross-referenced the usage in start
to identify the parameters and then obtained this psueocode:
uint32_t boxes[8]; // 0x1af2c0
void func2(int idx, uint32_t val) {
int parity;
for (int i = 0; i < 32; ++i) {
parity ^= ((boxes[idx] >> i) & 1) & ((val >> i) & 1);
}
// shift box and push parity bit
boxes[idx] = (parity << 0x1f) | (boxes[idx] >> 1);
}
func3
This third function simply compares the values in boxes
with fixed, target values and prints Ok
if they match.
back to start
I returned back to the start
method and figured out that there is some boxes
parameter with 8 x 4-byte values (as seen in func2
).
In essense, start
does the following:
void start() {
func1();
for (int n = 0; n < 32; ++n) {
for (int idx = 0; idx < 8; ++idx) {
func2(idx, user[idx]);
}
// swap boxes by index
swap(0,4);
swap(1,2);
swap(6,7);
swap(3,5);
}
func3();
}
So overall, this seems to be some cipher that uses our (presumably flag) input to mutate values in boxes
which are then compared to a fixed output. I didn’t recogize this cipher as an off-the-shelf implementation of anything (and neither did my crypto teammates) so I set about implementing a z3 version.
z3 implementation
I quickly translated this C psueocode into Python and then into z3 statements but I was getting unsat
despite double checking everything. At 1:34 pm eastern (3.5 hours into this problem), I posted the following z3 script into our team discord and my teammated hpvm started to help debug the problem:
boxes = [
0x13371337, 0x69696969, 0x51335133, 0x90909090,
0x73317331, 0xb4b3b4b3, 0xc453c453, 0xd34dd43d
]
target = [
0x268dee8d, 0xc444444, 0x28d078d0, 0x31b1b1b3,
0x7ac17ad, 0x143814, 0x4267a267, 0x3baeb3e2
]
user = [BitVec('d%d' % x, 8) for x in range(32)]
def swap(a,b):
boxes[a], boxes[b] = boxes[b], boxes[a]
def func2(idx, val):
eq = boxes[idx] & val
parity = BitVecVal(0, 1)
for i in range(32):
parity ^= Extract(i,i,eq)
boxes[idx] = Concat(parity, Extract(30,0,LShR(boxes[idx], 1)))
def start():
for r2 in range(0x20):
for r3 in range(8):
user_val = Concat(
user[(r3*4)+3],
user[(r3*4)+2],
user[(r3*4)+1],
user[(r3*4)+0],
)
func2(r3, user_val)
swap(0, 4)
swap(1, 2)
swap(6, 7)
swap(3, 5)
start()
s = Solver()
for i in range(8):
s.add(boxes[i] == target[i])
s.check()
the long climb
Unfortunately, this z3 script should have solved the problem but there were several bugs in the challenge which changed the functionality of the cipher and we ended up spending 14 more hours on this challenge.
The first issue was with the func2
call. See if you can spot the problem with this C code:
uint8_t user[32];
void func2(int idx, uint32_t val);
void start() {
func1();
for (int n = 0; n < 32; ++n) {
for (int idx = 0; idx < 8; ++idx) {
func2(idx, user[idx]); // !!!
}
swap(0,4);
swap(1,2);
swap(6,7);
swap(3,5);
}
func3();
}
The issue is that the second argument to func2
should be a 32-bit word, but only the first byte of each 32-bit
block is being read. This means that the output buffer only depends on every four bytes in the input (i.e. there’s no way that you can solve for the whole input).
In Elbrus assembly, the issue was a single instruction:
ldb,0 [ %dr0 + _f64,_lts0 0x1b2638 ], %r0
should have been
ldw,0 [ %dr0 + _f64,_lts0 0x1b2638 ], %r0
I.e. ldb
-> ldw
.
I reached out to the author about this bug at 1:44 pm eastern after spotting it, but since the author had validated the flag by running the binary, it seemed like a false alarm. Unfortunately a consequence of this bug is that the original flag would still work but so would a bunch of false positive flags. Up until now, I had been doing everything statically so I assumed I was just misreading the assembly.
Around this point I found the OpenE2K project which provided a qemu-e2k
and I started working on setting this up to help debug. hpmv also started reading through the disassembly in case I had missed or misinterpreted anything. Once I got qemu working, I started looking for a suitable e2k-gdb
(which I think exists but for the life of me I can’t find). After a few hours, I gave up on gdb and just started patching print statements into the binary to debug. Once we had a version that just printed the final box values, hpvm confirmed very convincingly that only the first byte in each 4-byte block was affecting the output values (since now we could see the output values directly). I reached out to the author again at 6:14 pm eastern, this time with better evidence, and the author agreed that this was a bug.
Once the author fixed all the bugs, I ran my z3 script again but it still didn’t work, this time it was sat
but the output was jibberish. hpmv and I started debugging a bit more. We modified the outer loop to run just once instead of 32 times and noticed that our parity calculating seemed to be off.
We spent an embarassingly long time trying to figure this issue out, involving many different patched versions and various python scripts to run different combinations of user-input and custom intial box values. Around 10pm eastern this challenge was solved by the team Armia Prezesa
which meant it was definitely solveable.
Around 2am eastern, hpvm ran some really nice tests where he took a fixed user value (U
) and checked all the 1-bit box values (B
) that would result in a parity bit being set in func2
and produced beautiful visualizations like the following:
==============
U 1------- ----1--1 ----1--1 -------1
B 1------- -------- -------- --------
B -------- ----1--- -------- --------
B -------- -------1 -------- --------
B -------- -------- 1------- --------
B -------- -------- -1------ --------
B -------- -------- --1----- --------
B -------- -------- ---1---- --------
B -------- -------- -------1 --------
B -------- -------- -------- 1-------
B -------- -------- -------- -1------
B -------- -------- -------- --1-----
B -------- -------- -------- ---1----
B -------- -------- -------- ----1---
B -------- -------- -------- -----1--
B -------- -------- -------- ------1-
Surprisingly, the flippable bits do not seem to correlate 1:1 as we would expect and after some investigating, we found that sign bits seem to have some weird, cross-byte behavior. This prompted me to re-read the assembly and I found the culprit:
{
shls,0 %r3, 2, %r0
sxt,1,sm 6, %r3, %db[0]
disp %ctpr1,10438 <func2>
}
{
sxt,0 6, %r0, %dr5
adds,1 1, %r0, %r6
adds,2 2, %r0, %r7
adds,3 3, %r0, %r0
}
{
ldb,0 [ %dr5 + _f64,_lts0 0x1b2638 ], %r5
sxt,1 6, %r6, %dr6
sxt,2 6, %r7, %dr7
sxt,3 6, %r0, %dr0
}
{
ldb,0 [ %dr6 + _f64,_lts0 0x1b2638 ], %r6
ldb,2 [ %dr7 + _f64,_lts2 0x1b2638 ], %r7
}
{
nop 1
ldb,0 [ %dr0 + _f64,_lts0 0x1b2638 ], %r0
}
{
sxt,0 0, %r5, %dr5 (!!!)
}
{
sxt,0 0, %r6, %dr6 (!!!)
sxt,1 0, %r7, %dr7 (!!!)
sxt,2 0, %r0, %dr0 (!!!)
}
{
shls,0 %r6, 8, %r6
shls,1 %r7, _f16s,_lts0hi 0x10, %r7
shls,2 %r0, _f16s,_lts0lo 0x18, %r0
}
{
adds,0 %r5, %r6, %r5
}
{
adds,0 %r5, %r7, %r5
}
{
adds,0 %r5, %r0, %r4
}
{
ipd 3
sxt,0,sm 6, %r4, %db[1]
call %ctpr1, wbs = 0xe
}
The byte values are sign-extended before being added together! On my initial read through, I didn’t think this would affect anything since the %rx
registers are supposedly 32 bits so sign extending should be zero. But evidently this was not working as expected. It’s not obvious to me if this is actually what is supposed to happen in the assembly or if this behavior is a qemu-e2k
bug (because it was cobbled together by hobbiests from sparse Russian documentation) but in our debug-patched binary, each byte was being invididually sign-extended to 32-bits before being shifted and added. We identified this by patching in print statements to func2
and looking at the arguments.
For example, if the user buffer looked like [0x80, 0x80, 0x80, 0x80, ...]
the value actually passed to func2
would be:
0xffffff80
+ 0xffff80
+ 0xff80
+ 0x80
=
0x7f7f7f80
This wouldn’t be a problem if our input was passed directly since ASCII doesn’t use the sign bit, however our input passes through an sbox
first so we end up with many “negative” values.
Once we figured this out, we modified the z3 script to sign extend the user values and got multiple solutions. The side-effect of this sign-extension is that there are multiple ways to obtain the same 4-byte user value and hence there are multiple solutions. However we were able to identify key-words in the possible solutions and do a “z3-crib-drag” to pull out the flag:
midnight{1nv3rt3d_sb0x's_4r3_1n}
We ended up getting the second of four solves for this challenge.
Part 2: Crack me if you can
“Crack me if you can” was the second of the two Elbrus reversing challenges, released at 8pm eastern (14 hours into the ctf). I started working on this around 10pm eastern (taking a break from Climb
) and solved it in 2 hours.
I started this challenge the same as before, using e2k-objdump
to disassemble it. Just like the other challenge, this one was compiled statically with libc and has symbols. The functions of interest are: health
, gen
, add
, get
, run
, check
, and main
.
main
The main
function is rather straightforward:
int main() {
gen(0x30);
add();
__libc_read(0, 0x1b2608, 0x24);
if (health()) {
// *COUGH*, you have poor health...
_IO_printf(0x1905d4);
} else {
run();
check();
}
}
I decided to try to start working through each other function in the calling order.
gen
Unfortunately, gen
proved to be more difficult than expected, with two calls to __libc_malloc
and a ton of buffer manipulation:
{
getsp,0 _f32,_lts1 0xffffffe0, %dr2
setwd wsz = 0xc, nfx = 0x1
setbn rsz = 0x3, rbs = 0x8, rcur = 0x0
}
{
addd,0 %dr2, _f32,_lts2 0x20, %dr1
addd,1 0, _f64,_lts0 0x190530, %dr4
disp %ctpr1,546b8 <__libc_malloc>
}
{
ldd,0 [ %dr4 ], %dr5
addd,1,sm 0, _f16s,_lts0hi 0xe8, %db[0]
ldd,2 [ 8 + %dr4 ], %dr4
}
{
nop 1
addd,0 %dr1, _f16s,_lts0hi 0xfff0, %dr6
}
{
std,2,sm %dr5, [ %dr6 ]
std,5,sm %dr4, [ 8 + %dr6 ]
}
{
std,2,sm %db[0], [ %dr2 ]
}
{
ipd 3
call %ctpr1, wbs = 0x8
}
{
sxt,0 2, %b[0], %dr4
addd,1,sm 16, 0, %db[0]
disp %ctpr1,546b8 <__libc_malloc>
}
{
nop 3
std,2,sm %dr4, [ %dr1 + _f16s,_lts0hi 0xfff8 ]
std,5,sm %db[0], [ %dr2 ]
}
{
ipd 3
call %ctpr1, wbs = 0x8
}
{
sxt,0 2, %b[0], %dr3
ldd,2 [ %dr6 ], %dr4
ldd,3 [ 8 + %dr6 ], %dr5
return %ctpr3
}
{
nop 1
addd,0 0, %dr3, %dr6
addd,1,sm 0, %dr3, %dr0
}
{
nop 2
std,2,sm %dr4, [ %dr6 ]
std,5,sm %dr5, [ 8 + %dr6 ]
}
{
ct %ctpr3
ipd 3
}
I translated to this C pseudocode but was having a hard time making sense of it at a higher level:
void gen(int size /* ?? */) {
int *dr2; // stack?
int *dr1 = dr2 + 0x20;
void *buf_a = __libc_malloc(0xe8);
int dr5 = 29;
int dr4 = 0;
int *dr6 = dr1 - 0x10;
dr6[0] = dr5;
dr6[1] = dr4;
dr2[0] = 0xe8;
dr4 = buf_a;
void *buf_b = __libc_malloc(0x10);
*(dr1 - 8) = dr4;
dr2[0] = 0x10;
int dr3 = buf_b;
dr4 = dr6[0];
dr5 = dr6[1];
dr6 = dr3;
int dr0 = dr3;
dr6[0] = dr4;
dr6[1] = dr5;
}
Since I was approaching my 15th hour overall of staring at Elbrus assembly, I decided to try a dynamic reversing approach.
check
From the name, I guessed the check
function would somehow check the flag so I quickly scanned it and saw a simple buffer comparison of 36 bytes between 0x1af250
and 0x1b25d8
, neither of which is our raw input buffer (0x1b2608
). So our input is probably being processed into one of these buffers and then compared against a fixed target.
Most of my experience with patching at this point relied on either disassemblers like Binary Ninja or programatically modifying certain instructions at the assembly level. However, since there is no existing interactive disassembler for Elbrus and I don’t understand it well enough to program assembly for it, I used the e2k-gcc
tool to compile C code and then simply copied the instructions directly into the binary. Admittedly, this technique is not particularly hard and I’m sure many other people are familiar with it. However, it was new to me so I decided to include it in this writeup in the hopes it will be helpful to others.
In short, we write a C program like the following:
// Patch into check
void foo() {
void (*_target)() = (void (*)())(0x53b10);
_target();
}
// Patch into 0x53b10
int main() {
void (*__libc_write)(int fd, const void *buffer, unsigned long size) = (void (*)())(0x6b188);
void (*_exit)(int r) = (void (*)())(0x68cf8);
void *buf1 = (void *)0x1af250;
void *buf2 = (void *)0x1b25d8;
__libc_write(1, buf1, 12 * 4);
__libc_write(1, buf2, 12 * 4);
_exit(0);
}
In the binary, I patch foo
into the start of check
and patch main
into a random, unused libc function. In this case check
is too small to hold all three of the calls so we need to use a short trampoline to jump into a separate area.
I patched manually by compiling this C program and then examining the function start and end addresses in objdump
, using a short python script to overwrite sections of the original binary.
I ran this several times with different inputs and observed that the first buffer was fixed while the second buffer depends on our input. The first buffer looks like this:
$ echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" | ./qemu-e2k/build/qemu-e2k ./crack_mod | xxd
00000000: 0119 1700 4f0a 0000 35e4 0000 ad13 0000 ....O...5.......
00000010: d9ab 0000 c516 0000 c93c 0500 f97c 0000 .........<...|..
00000020: eb0f 0600
Interestingly there are a lot of null bytes and this actually looks like a little-endian int array instead of a char array.
run
I searched for other references to the computed buffer 0x1b25d8
and found several in the run
function. I hand-decompiled to the following code:
static char *user; // 0x1b2608
static int* lookup; // 0x1af1c0
static int* output; // 0x1b25d8
void run() {
int dr0; // ??
int out_idx = 0;
int curr_char = 0; // prev lookup index
int acc = 1;
for (int i = 0; i < 0x24; ++i) {
char u = user[curr_char];
acc *= get(dr0, u);
curr_char = lookup[r3 * 4];
if (i == 0) continue;
if (i % 3 == 0) {
output[out_idx++] = acc;
acc = 1;
}
}
output[out_idx++] = acc;
}
The one part I couldn’t figure out was what %dr0
contained. I’m pretty sure this is a pointer that is passed as an argument but I didn’t end up figure out this structure. The only usage is in the call to get
so I didn’t worry about reversing that in too much detail at this stage.
In short, the rest of the code does the following: Iterate through the user
buffer in an order determined by the lookup
table. Compute a value by multiplying three consecutive results of get
and store them, in order, in the output
buffer.
Note that the first output
value is actually the product of four steps and the final output
value is the product of two steps. All the intermediate values are three steps.
Additionally, this code seems to verify that our output buffer is an int
array.
Simultaneity
As a side note, this was one function where the parallel execution of instructions actually ended up being important. See the following code:
{
adds,0,sm 0, 1, %r6
stw,2,sm %r6, [ %dr1 + _f64,_lts0 0x1b25d8 ]
}
Here %r6
is our acc
variable and this corresponds to acc = 1
and output[out_idx] = acc
. At first, I interpreted this as setting acc
to 1 before storing it. However, since these instructions execute at the same time, the stored value of %r6
is actually the original value and not the new value of 1.
If we enter an input like aaaaa...
, we get the following processed buffer:
[0x51, 0x1b, 0x1b, ..., 0x9]
Entering bbb...
we get: [0x271, 0x7d, 0x7d, ..., 0x19]
Entering ccc...
we get: [0x961, 0x157, 0x157, ..., 0x31]
If we look at these values we see that the buffers are the following:
a: [3**4, 3**3, 3**3, ..., 3**2]
b: [5**4, 5**3, 5**3, ..., 5**2]
c: [7**4, 7**3, 7**3, ..., 7**2]
However, for d
we get: [0x3931, 0x533, 0x533, ..., 0x79]
i.e. [11**4, 11**3, 11**3, ..., 11**2]
.
Hmm, this looks like sequential prime numbers!
To verify, I tested xxx...
and got [0x546d981, 0xded21, 0xded21, ..., 0x24c1]
i.e. [97**4, 97**3, 97**3, ..., 97**2]
.
health
During testing, I noticed that some input characters resulted in the *COUGH*, you have poor health...
message. Specifically, I could only enter lowercase a-z
and _{}
.
A quick gance at health
confirms this behavior and in the binary data section we see the following string: abcdefghijklmnopqrstuvwxyz_{}
. So for our prime values as before, a
corresponds to the 1st prime (starting at 3), b
to the 2nd, z
to the 26th and _{}
to the 27-29th respectively.
get
Without even looking at this function, we’ve identified that it returns the Nth prime number where n
is a user char.
Solution
Now that we understand the encoding procedure, we can use something like z3 to solve this.
import struct
from z3 import *
alph = 'abcdefghijklmnopqrstuvwxyz_{}'
primes = []
for i in range(3,1000):
skip = False
for j in range(2,i):
if i % j == 0:
skip = True
break
if skip:
continue
primes.append(i)
def char_factors(v):
if v == 1:
return []
for i in range(len(primes)):
if v % primes[i] == 0:
return [alph[i]] + char_factors(v // primes[i])
return []
dat = open('./crackme', 'rb').read()
order = list(struct.unpack('<36I', dat[0x19f1c0:0x19f1c0+(0x24*4)]))
target = list(struct.unpack('<12I', dat[0x19f250:0x19f250+(12*4)]))
inp = [BitVec('b%d' % x, 8) for x in range(36)]
# Which indexes will be used for a particular output value?
ranges = []
for i in range(0, len(order), 3):
ranges.append(order[i:i+3])
ranges[0].append(0)
s = Solver()
# Possible values for an index.
poss = {x: [] for x in range(36)}
for i in range(12):
r = ranges[i]
fac = char_factors(target[i])
for k in range(len(r)):
for j in range(k):
s.add(inp[r[k]] != inp[r[j]])
for f in fac:
poss[r[k]].append(inp[r[k]] == ord(f))
for k in poss:
s.add(Or(poss[k]))
# Enumerate 100 solutions
for i in range(100):
if s.check() != sat:
break
m = s.model()
flag = bytes([m[x].as_long() for x in inp])
print(flag)
# Prevent this specific result.
s.add(Not(And([inp[i] == flag[i] for i in range(36)])))
However, we get a bunch of solutions:
b'ciduaehlnc{aml_nnkd_qnirerti_eehgit}'
b'miq_rgtt{irhcl_}_knueniaenacndeheild'
b'miq_rgtt{irh_l_}cknueniaenacndeheild'
b'miq_rgtt{irh_l_}cknueniaenacndheeild'
b'miq_rgtt{irhnl_}cknueniaenac_dheeild'
b'miq_rgtt{irhnl_}cknueniaenac_deheild'
b'miq_igtt{irhnl_}cknuenraenac_dheeild'
b'miq_igtt{irh_l_}cknuenraenacndheeild'
b'miq_igtt{irhnl_}cknuenraenac_deheild'
b'miq_igtt{irh_l_}cknuenraenacndeheild'
b'miq_igtt{irhnl_}_knuenraenaccdheeild'
b'miq_rgtt{irhnl_}_knueniaenaccdheeild'
b'miq_rgtt{irhnl_}_knueniaenaccdeheild'
b'miq_igtt{irhnl_}_knuenraenaccdeheild'
b'miq_igtt{irhcl_}nknuenraenac_deheild'
b'miq_igtt{irhcl_}_knuenraenacndeheild'
b'miq_igtt{irh_l_}nknuenraenaccdeheild'
b'miq_rgtt{irhcl_}nknueniaenac_deheild'
...
Even if we constrain the flag format, we get a bunch:
key = b'midnight{'
for i in range(len(key)):
s.add(inp[i] == (key[i]))
end = b'}'
for i in range(1,len(end)+1):
s.add(inp[-i] == end[-i])
b'midnight{eranihdcknuq_raintc_ee_ell}'
b'midnight{iracl_n_kdue_raentcnqeheil}'
b'midnight{iracl_d_knue_raentcnqeheil}'
b'midnight{iracled_knue_raentcnq_heil}'
b'midnight{iracked_lnue_raentcnq_heil}'
b'midnight{irack_d_lnue_raentcnqeheil}'
b'midnight{iracked_lnue_arentcnq_heil}'
b'midnight{iracled_knue_arentcnq_heil}'
b'midnight{iracl_d_knue_arentcnqeheil}'
b'midnight{irack_d_lnue_arentcnqeheil}'
b'midnight{irack_n_ldue_arentcnqeheil}'
b'midnight{irack_n_ldue_raentcnqeheil}'
b'midnight{iracken_ldue_arentcnq_heil}'
b'midnight{iraclen_kdue_arentcnq_heil}'
b'midnight{iraclen_kdue_raentcnq_heil}'
b'midnight{iracl_n_kdue_arentcnqeheil}'
b'midnight{iracken_ldue_raentcnq_heil}'
b'midnight{inack_d_lnue_raertcnqeheil}'
b'midnight{inack_n_ldue_raertcnqeheil}'
b'midnight{inack_d_lnue_arertcnqeheil}'
b'midnight{inack_n_ldue_arertcnqeheil}'
b'midnight{inacl_d_knue_arertcnqeheil}'
b'midnight{inacl_n_kdue_arertcnqeheil}'
However we can pick out some words like cracked
and hell
and guess the positions to constrain a bit more:
b'midnight{cracked_in_quraintene_hell}'
b'midnight{cracked_inue_raentinq_hell}'
b'midnight{cracked_inue_arentinq_hell}'
b'midnight{cracked_in_euarentinq_hell}'
b'midnight{crackednin_euarenti_q_hell}'
b'midnight{crackedninue_arenti_q_hell}'
b'midnight{crackednin_euarinte_q_hell}'
b'midnight{cracked_in_euarintenq_hell}'
b'midnight{cracked_inue_arintenq_hell}'
b'midnight{crackedninue_arinte_q_hell}'
b'midnight{crackedninue_rainte_q_hell}'
b'midnight{crackedninue_raenti_q_hell}'
b'midnight{crackednin_eurainte_q_hell}'
b'midnight{cracked_in_euraintenq_hell}'
b'midnight{cracked_in_euraentinq_hell}'
And finally we get something in English: midnight{cracked_in_quarentine_hell}
.
We got 1st blood for this challenge.
p.s.
If anyone knows where to find e2k-gdb
please let me know!