Hack.lu CTF 2011 Write-up : FluxScience

Mon 26 September 2011 by JB

This was probably one of the most entertaining challenges of this CTF.A file data.flux is provided. The goal is to analyze a Windows binary to be ableto decrypt this file.

A few informations are given:

Thanks to a former employee of FluxScience (one of our competitors), wemanaged to get hands on some important files which might help us revealingcompany secrets. Attached you will find the files. The employee who providedthem got fired. You might be lucky and find his account still working:FLUX-38B273DD75860083-0B3DD6B02EC5B9B1-4AFFBAC2EB8B4D17 He might not havethe necessary permission to decrypt the personal data data.flux. he stolethem from his boss, _GLaDOS_. Would you mind helping us by finding theircompany secret?

The sections of the binary seem to contains hints:

.go      00401000 0040A000 R . X . L para 0001 public CODE 32 0000     0000
.find    0040A140 0040D000 R . . . L para 0002 public DATA 32 0000     0000
.the     0040D000 00410000 R W . . L para 0003 public DATA 32 0000     0000
.rsrc    00410000 00411000 R . . . L para 0005 public DATA 32 0000     0000
Intel(R) 00411000 00413000 R W X . L para 0004 public CODE 32 0000     0000

Removing code obfuscation

The code is slightly obfuscated. Jumps and calls are emulated using a simplestack-based virtual machine. All the jumps/calls have this form:

Intel(R):004115FD E8 45 FD FF FF                 call    vm_start
Intel(R):004115FD                ; ---------------------------------------------------------------------------
Intel(R):00411602 02                             db    2
Intel(R):00411603 52                             db  52h ; R
Intel(R):00411604 10                             db  10h
Intel(R):00411605 40                             db  40h ; @
Intel(R):00411606 00                             db    0
Intel(R):00411607 03                             db    3
Intel(R):00411608 04                             db    4
Intel(R):00411609 04                             db    4
Intel(R):0041160A 00                             db    0
Intel(R):0041160B                ; ---------------------------------------------------------------------------
Intel(R):0041160B E9 3A 00 00 00                 jmp     loc_41164A

vm_start reads the data located just below the call, here 02 52 10 40 00 03 0404 00. The virtual machine has only 10 opcodes (thanks to alex for the list):

  • 0: O_JMP jump to the value on the top of the stack
  • 1: O_CALL call the value on the top of the stack
  • 2: O_LOAD load a 32 bit value
  • 3: O_FLAG test a EFLAG value
  • 4: O_IMUL multiplies the two values on the top of the stack
  • 5: O_ADD same with an addition
  • 6: O_SUB same with a subtraction
  • 7: O_NEG negate value (pushes 1 if zero, else 0)
  • 8: O_AND logical and between two values on the top of the stack
  • 9: O_OR same with logical or

In the example, the VM code can be disassembled this way:

02 52 10 40 00 load 0x00401052 # jump destination
03 04          test ZF
04             imul
00             jump

VM pushes the jump address. It then tests if the Z flag is set, which willreturn or value of 0 or 1. It multiplies this value with the jump address, andjumps to this address if it is not 0. Hence this code is similar to:jz 401052h.

The same method is used to emulate the other kind of jumps. The emulated opcodesare "call", "jmp", "jz", "jnz", "jb", "jl" and "jle". A IDA script has beenwritten to de-obfuscate the whole binary:

#!/usr/bin/env python

import idaapi

O_JMP  = 0
O_CALL = 1
O_LOAD = 2
O_FLAG = 3
O_IMUL = 4
O_ADD = 5
O_SUB = 6
O_NEG = 7
O_AND = 8
O_OR = 9

VM_START_EA = 0x411347

flags_list = { 0:'OF', 2:'CF', 4:'ZF', 8:'SF', 10:'PF' }

class JumpDecoder(object):
    def __init__(self, ea):
        self.ea = ea
        self.read = 5
        self.instr = None
        self.dest = None
        self.vm_bytes = ""

        self.analyze()

    def analyze(self):
        while 1:
            opcode = idaapi.get_full_byte(self.ea + self.read)
            self.read += 1

            if opcode in [O_FLAG, O_IMUL, O_ADD, O_SUB, O_NEG, O_AND, O_OR]:
                self.vm_bytes += chr(opcode)
                if opcode == O_FLAG: # O_FLAG has one operand
                    self.vm_bytes += chr(idaapi.get_full_byte(self.ea + self.read))
                    self.read += 1

            elif opcode in [O_JMP, O_CALL]:
                self.instr = opcode
                break
            elif opcode == O_LOAD: # jmp destination
                self.dest = idaapi.get_full_long(self.ea + self.read)
                self.read += 4
            else:
                print "unknown opcode!"
                break

    def deobfuscate(self):
        jmp_equivalent = {
            '': "jmp",
            '\x03\x04\x04': "jz",
            '\x03\x02\x04': "jb",
            '\x03\x04\x07\x04': "jnz",
            '\x03\x00\x03\x08\x06\x07\x07\x04': "jl",
            '\x03\x00\x03\x08\x06\x07\x07\x03\x04\x09\x04': "jle"
        }

        if self.dest is not None and self.instr is not None:
            if self.instr == O_CALL:
                opcode = "call"
            else:
                if jmp_equivalent.has_key(self.vm_bytes):
                    opcode = jmp_equivalent[self.vm_bytes]
                else:
                    print "%08X: undefined" % self.ea # never reach here
        else:
            print "error occurred"
            return

        print "%08X: %s %08Xh" % (self.ea, opcode, self.dest)

        for i in range(self.read):
            idaapi.assemble(self.ea+i, 0, self.ea+i, 1, "nop")
        idaapi.assemble(self.ea, 0, self.ea, 1, "%s %d" % (opcode, self.dest))

        idaapi.do_unknown_range(self.ea, self.read, idaapi.DOUNK_EXPAND)
        idaapi.create_insn(self.ea)

        idaapi.do_unknown(self.ea + self.read, idaapi.DOUNK_EXPAND)
        idaapi.create_insn(self.ea + self.read)

        jmp_ea = GetOperandValue(self.ea + self.read, 0) # get jump destination
        idaapi.do_unknown(jmp_ea, idaapi.DOUNK_EXPAND)
        idaapi.create_insn(jmp_ea)

        idaapi.do_unknown(self.dest, idaapi.DOUNK_EXPAND)
        idaapi.create_insn(self.dest)

def main():

    while 1:
        found = False
        for ea in CodeRefsTo(VM_START_EA, 1):
            found = True
            d = JumpDecoder(ea)
            d.deobfuscate()
        if found == False:
            break
        idaapi.analyze_area(0, 0xffffffff)
    pass

if __name__ == '__main__':
    main()

The IDA database is fully patched, making the code more readable. I preferred towork on a clean executable. For this, I produced a DIF file with IDA thatspecifies all the patches that have been made to the database, and I injectedthem in the binary. Finally, a working, clean executable is obtained.

Overview of the algorithm

The account number is composed of 3 blocks (block1, block2, block3) of 64 bits.Each block is encrypted using XTEA. The encryption key is derived from CPUinformation. Computation is done in sub_401060, which produces a 128 bit key.Let's name it tea_key1.

A second key tea_key2 is composed of the last 64 bit block of the accountnumber, and the last 64 bits of the CPU key.

The decryption of the account number is done this way:

tea_key1 = block3 + cpu_key[8:]
tea_key2 = cpu_key

block1 = xtea_decrypt(tea_key1, block1, 32)
block1 = xtea_decrypt(tea_key2, block1, 33)
block2 = xtea_decrypt(tea_key2, block2, 34)
block3 = xtea_decrypt(tea_key2, block3, 35)

XTEA functions are a bit modified: the third argument is the number of rounds,here variable.

Three checks are made on the resulting blocks:

  • block2 value, once decrypted, must be "_GLaDOS_".
  • Last 32 bits of block3 must match a modified Adler32 hash of the first 32 bits of block3
  • First 32 bits of block3, xored with the last 32 bits of the CPU key, must match the modified Adler32 hash of the 128 bits of block1 + block2.

Basically, the provided test account will not be correctly decrypted, as the CPUkey will not be the same as _GLaDOS_'s CPU. So this will not help us for themoment.

I decided to write an account generator that will pass these checks. Thepseudocode is quite obvious:

tea_key2 = cpu_key

block1 = "UNKNOWN!"
block2 = "_GLaDOS_"
h1 = adler32(block1 + block2) ^ cpu_key[-4:]
h2 = adler32(h1)
block3 = h1 + h2

block2 = xtea_encrypt(tea_key2, block2, 34)
block3 = xtea_encrypt(tea_key2, block3, 35)
tea_key1 = block3 + cpu_key[8:]
block1 = xtea_encrypt(tea_key2, block1, 33)
block1 = xtea_encrypt(tea_key1, block1, 32)

The generated serial passes the three checks. It is then easier to follow therest of the algorithm.

The charset of block1 is checked: it must match [A-Za-z_]. It is compared to thesession username, retrieved with GetUserNameA, and must be 8 chars long. Thispart has to be patched.

Tricky things start now. A new XTEA key is derived from the decrypted buffer andthe CPU key. This key is used to decrypt the whole data.flux file. A check isdone on the decrypted file:

  • first dword must match the modified Adler32 hash of the account name.
  • second dword must be 0xdeadbeef.

This is bad, data.flux is not correctly decrypted, and we cannot bruteforce a128 bit key. Good bye...

The decryption key for data.flux is composed of:

  • block3, decrypted with the CPU key
  • the first 32 bits of the username xored with their last 32 bits. For example, "UNKNOWN!" gives 0x4e4b4e55 ^ 0x214e574f = 0x6f05191a.
  • the first 32 bits of "_GLaDOS_" xored with their last 32 bits.

As we do not know the CPU key present of _GLaDOS_ computer, we cannot compute theXTEA key used to decrypt data.flux. And we are screwed.

Retrieving the CPU key

How is computed the CPU key? It consists in 128 bits, i.e 4 dwords. Thepseudo-code to generate it is:

a,b,c,d = cpuid(0x80000002)
cpu_key[0] = a
cpu_key[1] = b
cpu_key[3] = c ^ d
a,b,c,d = cpuid(0x80000003)
cpu_key[3] ^= a ^ b ^ c ^ d
a,b,c,d = cpuid(0x80000004)
cpu_key[3] ^= a ^ b ^ c ^ d
cpu_key[2] = key[3] ^ 0x78756C66 # xulf

cpuid returns the processor brand string, for values of 0x80000002, 0x80000003and 0x80000004. On my computer I get:

"Intel(R) Core(TM)2 Duo CPU     E8400  @ 3.00GHz"

cpu_key[0] and cpu_key[1] depend only on the value of cpuid(0x80000002). Hence cpu_key[0] + cpu_key[1] = "Intel(R)" here.

HINT! HINT!: "Intel(R)" is the name of the last section of the binary, so itseems we are on the good way!

The two last dwords have to be computed. As cpu_key[2] = key[3] ^ 0x78756C66, abruteforce on 32 bits will give the result. The plaintext for block2 needs to be"_GLaDOS_", so we can try all the possible XTEA keys for the test account numberto retrieve the CPU key:

/* XTEA decryption, ripped from http://en.wikipedia.org/wiki/XTEA */
void decipher(unsigned int num_rounds, unsigned long* v, unsigned long* k) {
    unsigned long v0=v[0], v1=v[1], i;
    /* delta has a custom value */
    unsigned long delta=0x61c88646, sum=delta*num_rounds;
    for(i=0; i<num_rounds; i++) {
        v1 -= ((v0 << 4 ^ v0 >> 5) + v0) ^ (sum + k[sum>>11 & 3]);
        sum -= delta;
        v0 -= ((v1 << 4 ^ v1 >> 5) + v1) ^ (sum + k[sum & 3]);
    }
    v[0]=v0; v[1]=v1;
}

int brute_cpuid()
{
    /* encrypted block2, in the test account number */
    unsigned char encrypted[] = { 0xb0,0xd6,0x3d,0x0b, 0xb1,0xb9,0xc5,0x2e };
    unsigned long block[2];
    unsigned char tea_key[16] = { 'I','n','t','e','l','(','R',')' };
    unsigned long seed1, seed2;
    unsigned long i, j;
    int found = 0;

    for(i = 0; i < 0xffffffff; i++)
    {
        seed1 = i;
        seed2 = i ^ 'xulf';
        memcpy(tea_key + 8, &seed1, 4);
        memcpy(tea_key + 12, &seed2, 4);
        memcpy(block, encrypted, 8);

        decipher(34, block, (unsigned long *)tea_key);
        if(memcmp(block, "_GLaDOS_", 8) == 0)
        {
            found = 1;
            break;
        }
    }
    if(found)
    {
        printf("seed = 0x%x\n", i);
        for(j = 0; j < 16; j++)
            printf("%02x", tea_key[j]);
        printf("\n");
    }
    return 0;
}

The bruteforce is quite fast. Finally we obtain:

seed = 0x13715e62
496e74656c285229625e71130432046b

Computing _GLaDOS_ account number

Now, let's try the test account number again, this time replacing the CPU keywith the correct one. The account is correctly decrypted. The decrypted bufferis:

00000000  57 68 65 61 74 6C 65 79 5F 47 4C 61 44 4F 53 5F  Wheatley_GLaDOS_
00000010  D8 37 E8 58 31 01 B7 03                          .7.X1...

So Wheatley is the stealer! But, again, the account number does not decryptproperly data.flux. Why? Remember that the file belonged to _GLaDOS_, and not toWheatley. A new account number has to be forged, this time with the username"_GLaDOS_". The account generator described above can be reused: only the CPUkey needs to be changed.

The full source code for the account number generation is:

#!/usr/bin/env python

import struct

# ripped from http://code.activestate.com/recipes/496737-python-xtea-encryption/
def xtea_encrypt(key, block, n=32):
    v0,v1 = struct.unpack("<2L", block)
    k = struct.unpack("<4L", key)
    sum, delta, mask = 0, 0x61c88646, 0xffffffff
    for round in range(n):
        v0 = (v0 + (((v1<<4 ^ v1>>5) + v1) ^ (sum + k[sum & 3]))) & mask
        sum = (sum + delta) & mask
        v1 = (v1 + (((v0<<4 ^ v0>>5) + v0) ^ (sum + k[sum>>11 & 3]))) & mask
    return struct.pack("<2L",v0, v1)

def adler32(input):
    a, b = 1, 2 # be careful: b=2

    for c in input:
        if c & 0x80:
            c |= 0xffffff00
        a = (a + c) % 0xfff1
        b = (a + b) % 0xfff1
    return a + (b << 16)

def keygen(username):
    cpu_key = bytes.fromhex("496e74656c285229625e71130432046b") # glados cpu key
    tea_key2 = cpu_key

    block1 = username
    block2 = b"_GLaDOS_"

    xor_value, = struct.unpack('<L', cpu_key[-4:])
    h1 = adler32(block1 + block2)
    h1 ^= xor_value
    h2 = adler32(struct.pack('<L', h1))

    block2 = xtea_encrypt(tea_key2, block2, 34)
    block3 = struct.pack('<2L', h1, h2)
    block3 = xtea_encrypt(tea_key2, block3, 35)
    tea_key1 = block3 + cpu_key[8:]
    block1 = xtea_encrypt(tea_key2, block1, 33)
    block1 = xtea_encrypt(tea_key1, block1, 32)

    serial = "FLUX"
    for block in [block1, block2, block3]:
        a, b  = struct.unpack('<2L', block)
        serial += "-{0:08X}{1:08X}".format(a,b)
    return serial

print(keygen(b"_GLaDOS_"))

The account number for _GLaDOS_ is:FLUX-04644DBE65073E9E-0B3DD6B02EC5B9B1-F01864F663B0B371 . It correctly decryptsdata.flux and the challenge is over. Almost over...

Final round

The output file is named flux.dat. Once the decryption is finished, the programdisplays a message box:

The decrypted buffer is partly corrupted. Good luck anyway.

Here is the content of the file:

00000000  50 0B 03 04 14 00 01 00 00 00 00 00 00 00 82 23  P.............‚#
00000010  73 0F 00 00 00 00 30 00 00 00 12 00 00 00 63 6F  s.....0.......co
00000020  6D 70 61 6E 79 2D 73 65 63 72 65 74 2E 74 78 74  mpany-secret.txt
00000030  2B BF 46 F7 C4 D5 A9 F6 BE E9 C6 DD F5 6F 8A 3A  +¿F÷ÄÕ©ö¾éÆÝõoŠ:
00000040  A4 7B 8C C3 B1 2C 13 87 8B C5 6E 89 1C A6 6D 4F  ¤{ŒÃ±,.‡‹Ån‰.¦mO
00000050  12 89 56 16 4F F4 5D 82 88 EF A7 FB 99 DE 59 61  .‰V.Oô]‚ˆï§û™ÞYa
00000060  79 6D 24 3B 11 34 C4 73 95 CB A2 68 50 40 01 02  ym$;.4Äs•Ë¢hP@..
00000070  14 00 14 00 01 00 00 00 00 00 00 00 82 23 73 0F  ............‚#s.
00000080  3C 00 00 00 00 00 00 00 12 00 00 00 00 00 00 00  <...............
00000090  00 00 20 00 00 00 00 00 00 00 63 6F 6D 70 61 6E  .. .......compan
000000A0  79 2D 73 65 63 72 65 74 2E 74 78 74 00 4B 05 06  y-secret.txt.K..
000000B0  00 00 00 00 01 00 01 00 40 00 00 00 6C 00 00 00  ........@...l...
000000C0  00 00                                            ..

It is quite obvious that it is a zip file, which has been partly destroyed. Thearchive contains a single file, company-secret.txt.

The two first magic bytes should be 50 4B ("PK") insted of 50 0B. The sameproblem is found at offsets 6D (40 insted of 4B) and AC (00 instead of 50).

The compressed stream goes from offset 0x30 to 0x6c. Its size should be locatedin the compressed size field of the file header, at offset 0x12, which iscurrently 0x00000000.

Finally, the "uncompressed size" field of the central directory at offset 0x84has a zero value, while it must match the uncompressed size value of the fileheader at offset 0x16 (0x00000030).

Once these changes have been made, flux.dat is:

00000000  50 4B 03 04 14 00 01 00 00 00 00 00 00 00 82 23  PK............‚#
00000010  73 0F 3C 00 00 00 30 00 00 00 12 00 00 00 63 6F  s.<...0.......co
00000020  6D 70 61 6E 79 2D 73 65 63 72 65 74 2E 74 78 74  mpany-secret.txt
00000030  2B BF 46 F7 C4 D5 A9 F6 BE E9 C6 DD F5 6F 8A 3A  +¿F÷ÄÕ©ö¾éÆÝõoŠ:
00000040  A4 7B 8C C3 B1 2C 13 87 8B C5 6E 89 1C A6 6D 4F  ¤{ŒÃ±,.‡‹Ån‰.¦mO
00000050  12 89 56 16 4F F4 5D 82 88 EF A7 FB 99 DE 59 61  .‰V.Oô]‚ˆï§û™ÞYa
00000060  79 6D 24 3B 11 34 C4 73 95 CB A2 68 50 4B 01 02  ym$;.4Äs•Ë¢hPK..
00000070  14 00 14 00 01 00 00 00 00 00 00 00 82 23 73 0F  ............‚#s.
00000080  3C 00 00 00 30 00 00 00 12 00 00 00 00 00 00 00  <...0...........
00000090  00 00 20 00 00 00 00 00 00 00 63 6F 6D 70 61 6E  .. .......compan
000000A0  79 2D 73 65 63 72 65 74 2E 74 78 74 50 4B 05 06  y-secret.txtPK..
000000B0  00 00 00 00 01 00 01 00 40 00 00 00 6C 00 00 00  ........@...l...
000000C0  00 00                                            ..

Open it, extract company-secret.txt and... it is password protected. Rememberthere where two hints in the section names. Only one has been used. So .go .find.the .rsrc! I made a raw dump of the section, and search for strings.

$ strings moonstone.panel.exe.section-3.dmp displays only the manifest. Nothinginteresting there. Let's try unicode strings:

$ strings -el moonstone.panel.exe.section-3.dmp
FluxScience Moonstone Vault Panel
MS Shell Dlg
Enter credentials below.
Let's make lemonade!

"lemonade" and "lemonade!" seemed to be good candidates, but it fails.Actually the password is "FluxScience". The content of the decrypted file is:

The solution is the MD5 hash of GLaDOS' login.

Flag is md5("FLUX-04644DBE65073E9E-0B3DD6B02EC5B9B1-F01864F663B0B371")--> b6c91946501f2b0e00cc0a0189dc7c2c

Thanks to the whole fluxfingers team for this really great CTF.