Analysis

In this Reverse Engineering challenge, we are given a Windows executable:

1
2
$ file partialencryption.exe
partialencryption.exe: PE32+ executable (console) x86-64, for MS Windows, 5 sections

Running it without arguments shows the expected usage:

1
2
$ wine partialencryption.exe
./chal <flag>

Testing a short input gives a different failure message:

1
2
$ wine partialencryption.exe test
Nope

And a wrong input with the expected shape gives:

1
2
$ wine partialencryption.exe 'HTB{AAAAAAAAAAAAAAAAA}'
No

The binary does not expose the flag through normal strings:

1
2
3
4
5
6
7
$ strings -n 4 partialencryption.exe
VirtualAlloc
VirtualProtect
VirtualFree
IsDebuggerPresent
putchar
...

The interesting imports are VirtualAlloc, VirtualProtect, and VirtualFree. This usually means the program is building or decrypting code at runtime, marking it executable, and then calling it.

Opening the binary in radare2, main is small and calls several custom functions:

0x1400015c1      call fcn.140001130
0x1400015e8      call fcn.1400012b0
0x14000160f      call fcn.1400013b0
0x140001636      call fcn.1400011f0

Each function receives pointers to putchar, exit, argc, argv, and a small status variable. This hinted that the real logic is hidden inside decrypted code chunks.

Decryption

The helper at 0x140001050 decrypts chunks from .data.

The high-level logic is:

1
2
3
4
5
6
7
VirtualAlloc(size)
for each 16-byte block:
    build a 16-byte key where every byte is the block index
    decrypt the encrypted block using AES-NI instructions
    write the plaintext block into the allocated buffer
VirtualProtect(buffer, size, PAGE_EXECUTE_READ)
return buffer

The core AES-NI transform is in fcn.140001000:

movdqu          xmm0, [rdx]
aeskeygenassist xmm0, xmm0, 0
movdqu          xmm1, [rdx]
aeskeygenassist xmm1, xmm1, 0x10
movdqu          xmm2, [rcx]
pxor            xmm2, xmm1
aesdeclast      xmm1, xmm0
movdqa          xmm0, xmm1
ret

Instead of trying to manually execute all of the runtime code, I dumped the encrypted chunks from .data and used the same AES-NI operations to recover the plaintext code.

The important chunk addresses were visible from the calls to the decryptor:

0x140004000, size 0x70
0x140004070, size 0x40
0x1400040b0, size 0x30
0x1400040e0, size 0x30
0x140004110, size 0x30
0x140004140, size 0x1a0
0x1400042e0, size 0x1e0
0x1400044c0, size 0x270
0x140004730, size 0x100

I used a small C helper to apply the same AES-NI transform:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdint.h>
#include <string.h>
#include <wmmintrin.h>

static __m128i transform(const uint8_t *cipher, uint8_t idx) {
    uint8_t key[16];
    memset(key, idx, sizeof(key));

    __m128i k = _mm_loadu_si128((const __m128i *)key);
    __m128i r0 = _mm_aeskeygenassist_si128(k, 0x00);
    __m128i r1 = _mm_aeskeygenassist_si128(k, 0x10);
    __m128i x = _mm_loadu_si128((const __m128i *)cipher);

    x = _mm_xor_si128(x, r1);
    x = _mm_aesdeclast_si128(x, r0);
    return x;
}

Compiled with AES support:

1
2
3
4
5
6
7
8
9
10
11
$ gcc -O2 -maes -msse4.1 dump_partial.c -o dump_partial
$ ./dump_partial partialencryption.exe
/tmp/chunk_140004000.bin
/tmp/chunk_140004070.bin
/tmp/chunk_1400040b0.bin
/tmp/chunk_1400040e0.bin
/tmp/chunk_140004110.bin
/tmp/chunk_140004140.bin
/tmp/chunk_1400042e0.bin
/tmp/chunk_1400044c0.bin
/tmp/chunk_140004730.bin

Disassembling the first decrypted chunk showed that it prints the usage string one character at a time:

mov cl, 0x2e
call [rsp+0x30]
mov cl, 0x2f
call [rsp+0x30]
mov cl, 0x63
call [rsp+0x30]
mov cl, 0x68
call [rsp+0x30]
...

Those bytes spell:

1
./chal <flag>

So the decrypted chunks are valid executable code, and some of them contain the validation logic.

Extraction

The validator chunks compare argv[1] against hardcoded bytes. A typical comparison looks like this:

movsx eax, byte [rax+rcx]
cmp eax, 0x48
jz valid
mov dword [rsp], 1

Here 0x48 is ASCII H.

To avoid manually reading every comparison, I used Capstone to extract the index and expected byte from each decrypted chunk:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from capstone import *
from capstone.x86 import *
from pathlib import Path

md = Cs(CS_ARCH_X86, CS_MODE_64)
md.detail = True

for f in sorted(Path("/tmp").glob("chunk_*.bin")):
    code = f.read_bytes()
    checks = []
    last_index = None

    for insn in md.disasm(code, 0):
        if insn.mnemonic == "imul" and len(insn.operands) == 3:
            ops = insn.operands
            if ops[0].type == X86_OP_REG and insn.reg_name(ops[0].reg) == "rcx":
                if ops[2].type == X86_OP_IMM:
                    last_index = ops[2].imm & 0xff

        if insn.mnemonic == "cmp" and last_index is not None:
            ops = insn.operands
            if ops[0].type == X86_OP_REG and ops[1].type == X86_OP_IMM:
                checks.append((last_index, ops[1].imm & 0xff))
                last_index = None

    if checks:
        print(f.name, checks, "".join(chr(c) for _, c in checks))

The extracted comparisons were:

1
2
3
4
chunk_140004140.bin [(0, 72), (1, 84), (2, 66), (3, 123), (21, 125)] HTB{}
chunk_1400042e0.bin [(4, 87), (5, 51), (6, 105), (7, 82), (8, 100), (9, 95)] W3iRd_
chunk_1400044c0.bin [(10, 82), (11, 85), (12, 110), (13, 84), (14, 49), (15, 109), (16, 51), (17, 95)] RUnT1m3_
chunk_140004730.bin [(18, 68), (19, 69), (20, 67)] DEC

Putting the bytes back at their original indexes gives:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
flag = list("?" * 22)

checks = [
    (0, 72), (1, 84), (2, 66), (3, 123), (21, 125),
    (4, 87), (5, 51), (6, 105), (7, 82), (8, 100), (9, 95),
    (10, 82), (11, 85), (12, 110), (13, 84), (14, 49),
    (15, 109), (16, 51), (17, 95),
    (18, 68), (19, 69), (20, 67),
]

for i, c in checks:
    flag[i] = chr(c)

print("".join(flag))

Output:

1
HTB{W3iRd_RUnT1m3_DEC}

Finally, I validated the recovered flag with Wine:

1
2
$ wine partialencryption.exe 'HTB{W3iRd_RUnT1m3_DEC}'
Yes

Flag

HTB{W3iRd_RUnT1m3_DEC}