[Insomni’hack 2017] encryptor – reverse engineering
insomni’hack is an annual security conference in Geneva, Switzerland. Each year, they host a teaser competition prior to the conference. Here’s a writeup from one of the problems, which was to reverse engineer a cryptographic algorithm from a binary executable, worth 400 points (a hard problem). Our team was the only team out of 300+ teams to solve this challenge.
We are provided an encryption program and a ciphertext file. The encryption program takes no parameters (i.e. no external key) so we “only” have to implement a decryption program.
Opening the program in IDA, we see that it’s almost entirely AVX2 instructions, which IDA doesn’t decompile. We’ll have to attack the assembly directly. Below, a “yword” is a 256-bit (32-byte) quantity, named after the ymmN
256-bit AVX2 registers.
The main
function has a fairly simple outer structure:
load 19 constant ywords to [rbp-0x930..rbp-0x6d0] ("constants") load code bytes from the text section to [rbp-0xb50..rbp-0xad0] ("passkey") vzeroupper call func1 to generate keys in [rbp-0x6d0..rbp-0x50] ("keys") f = open("/tmp/plaintext") buf = f.read() while 1: block = buf[pos:pos+0x80] if len(block) < 0x80: break inblock = rearrange block with AVX2 instructions outblock = func2(inblock, keys, constants) buf[pos:pos+0x80] = rearrange outblock pos += 0x80 open("/tmp/ciphertext", "wb").write(buf)
We can run the program in gdb
and dump out the constants
, passkey
, and keys
just before it reads the input file. Also, with a little bit of GDB sleuthing, we figure out that the block rearrangements just amount to reversing the 16-bit words in the block, i.e.
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
becomes
1E 1F 1C 1D 1A 1B 18 19 16 17 14 15 12 13 10 11 0E 0F 0C 0D 0A 0B 08 09 06 07 04 05 02 03 00 01
which is a self-inverting function. So, now all we have to do is reverse engineer func2
(note that we didn’t even look at func1
, because it’s only called to generate the keys
). func2
appears to take a single block of 0x80 = 128 bytes and encrypt it; no block chaining is used (so two consecutive identical blocks encrypt to the same identical output blocks).
func2
is just a huge function with a normal function prologue and epilogue, with 625 AVX2 instructions in the middle – no jumps, conditionals, or even any non-AVX instructions in between. This seems like a super-daunting task to reverse – each instruction operates on 32 bytes of data, so plugging the whole mess into Z3 will probably fail (I didn’t even try).
So, what I did instead was just try to visualize how data flowed through these instructions. I wrote analyze.py
to create a data flow graph, where nodes are the instructions (and mov
s are elided for simplicity). The first few analyze.py
attempts produced really bad graphs because I was leaving constants as separate inputs, even though they were used by many, many instructions (see flow_bad.pdf
). After moving constants into the node labels, I got a much nicer graph (flow.pdf
) which immediately revealed some high-level structure.
First, it’s obvious from the flow graph (at a high level, zoomed all the way out) that there are eight repeats of the same basic structure, suggesting an 8-round cipher (albeit one with 1024-bit block sizes). Second, one particular pattern stands out frequently: the use of a vpmullw/vpmulhuw/vpor - vpsubusw/vpsubw - vpcmpeqw - vpsrlw/vpand/vpaddw/vpsubw
network to turn two inputs into a single output. Because every instruction in this pattern operates either word-wise or bitwise, we can analyze the network’s effect on a single pair of 16-bit inputs. As it turns out (through some lucky guessing and checking), this network computes (x * y) % 65537
, but where 0 is swapped for 65536. It’s a very clever function, and it’s invertible if you know one of the inputs.
At this point, I started to code the cipher in the forward direction to check my understanding – see the first half of decrypt.py
. By simply tracing the flow of data through one round and reimplementing the AVX instructions in Python, it was fairly easy to build the forward encryption algorithm (liberal amounts of debugging and tracing with gdb
were used to check that each step was implemented correctly). After a couple hours, I got it working (producing identical output to the original program), so it was time to run the algorithm in reverse.
The round function breaks down as
r0 = vmul65537(invecs[0], getvec(keys, keyoff + 0)) r1 = vpaddw(invecs[1], getvec(keys, keyoff + 0x20)) r2 = vpaddw(invecs[2], getvec(keys, keyoff + 0x40)) r3 = vmul65537(invecs[3], getvec(keys, keyoff + 0x60)) x0 = vpxor(r0, r2) x1 = vpxor(r1, r3) y0 = vmul65537(x0, getvec(keys, keyoff + 0x80)) y1 = vmul65537(vshufnet(vpaddw(y0, x1)), getvec(keys, keyoff + 0xa0)) y2 = vpaddw(y1, y0) o0 = vpxor(y1, r0) o1 = vpshufb(vpxor(y1, r2), getvec(constants, 0x200)) o2 = vpshufb(vpxor(y2, r1), getvec(constants, 0x220)) o3 = vpshufb(vpxor(y2, r3), getvec(constants, 0x240))
where vshufnet
is a complicated function mapping a single input to a single output involving a bunch of shuffles and XORs. The vpshufb
s, which permute the first input’s bytes according to the second input, are all invertible thanks to the particular constants chosen.
By calculating o0 ^ invshuf(o1)
we can recover r0^r2 = x0
, which lets us get y0
. Similarly, invshuf(o2) ^ invshuf(o3)
gives r1^r3 = x1
, which yields y1
and then y2
(just by running the forward calculations for y0
, y1
, and y2
). With y1
and y2
, we can calculate r0
through r3
and thereby invert the round function.
Finally, we just invert all eight round functions to produce a decryptor – note that at no point do we have to invert vshufnet
. When we’re done, we get a nice plaintext JPG, plaintext.jpg
, with our flag:
INS{XuejiaLai_StudentOfJim_YOUROCK}