Easy RE

Reversing a non-stripped Linux binary with length-based branch obfuscation and magic-number division.

Reverse EngineeringBDSecCTFMedium

1. Initial Triage

The provided file is e4sy_RE.bdsec. Despite the unusual extension, file identifies it as a standard Linux binary:

e4sy_RE.bdsec: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ... not stripped

So step one is just cp e4sy_RE.bdsec e4sy_RE && chmod +x e4sy_RE and treat it as a normal ELF.

Since it's not stripped, objdump -t and objdump -d give full symbol names, which makes this much easier than a stripped binary.

Running it shows a banner, a "lucky number" printed via rand()/srand(time^clock) (a red herring — it's never checked against anything), and a flag prompt:

[*] Your lucky number: 41544
Enter the flag:

Grepping strings shows no plaintext flag — the check is done arithmetically, so we need to actually reverse the comparison logic in main.


2. Control Flow Overview

main is the only interesting function (objdump -d shows just main, plus libc PLT stubs and startup glue). Disassembling it reveals that after reading the input with fgets and trimming the trailing newline with strcspn, the code branches entirely on the length of the input string:

strlen == 0x29 (41) -> real check        (main+0x21b, "the" path)
strlen == 0x1d (29) -> decoy check #1    (main+0x102 area)
strlen == 0x1a (26) -> decoy check #2    (main+0x3cf area)
strlen == 0x18 (24) -> decoy check #3    (main+0x4f1 area)
anything else       -> immediate failure

Each of the four length classes runs its own independent obfuscated cipher and compares the result against its own embedded constant array (expected.0, expected.1, expected.2, expected.3). Three of the four are decoys; feeding a correctly-shaped string of the wrong length just walks you into a dead-end path that always prints [-] Incorrect flag.

The genuinely solvable path is the 41-character branch, which matches the expected BDSEC{...} flag length. This is a common CTF trick to waste solvers' time reversing the wrong function, so identifying "which length is real" up front saves a lot of effort.


3. Reversing the 41-Byte Branch

The real branch (main+0x21b onward) runs a loop for i = 0 .. 40 over the input buffer. Below is the annotated disassembly of the core loop body, with meaning worked out instruction by instruction:

mov    rax, rdi        ; rax = i
mov    rcx, rdi        ; rcx = i
add    r8, 1           ; advance input pointer
and    eax, 0x7        ; eax = i & 7
movzx  esi, [r13+rax]  ; esi = key_part_a[i % 8]
xor    sil, [rbp+rax]  ; sil ^= key_part_b[i % 8]
xor    sil, [r8-1]     ; sil ^= input[i]
add    rdi, 1          ; i++
...                    ; (magic-number division tricks -> rcx = i % 7)
add    ecx, 1          ; rcx = (i % 7) + 1
rol    sil, cl         ; rotate sil left by (i%7)+1 bits
...                    ; (magic-number division tricks -> rdx = (13*i_old) % 41)
mov    eax, r10d       ; eax = r10   (r10 tracks 11*i across iterations)
add    r10d, 0xb       ; r10 += 11   (prep for next iteration)
xor    eax, 0x23       ; eax = (11*i) ^ 0x23
add    esi, eax        ; sil += that value  (mod 256)
mov    [rsp+rdx], sil  ; STORE at permuted destination index rdx
cmp    rdi, 0x29
jne    loop

The two "magic number" multiply/shift blocks are compiler-generated reciprocal division — a well-known pattern for turning x / 7 and x / 41 into a multiply+shift instead of an actual div instruction. Recognizing this pattern (constants like 0x4924924924924925 for division by 7, 0xc7ce0c7ce0c7ce0d for division by 41) avoids having to hand-simulate those blocks; they're just:

rcx = i % 7
dest = (13 * i) % 41        ; note: this uses i *before* increment

So, cleanly, per character i (0-indexed) the transform is:

key      = key_part_a[i % 8] ^ key_part_b[i % 8]
mixed    = input[i] ^ key
rotated  = rol8(mixed, (i % 7) + 1)
add_val  = ((11 * i) & 0xFF) ^ 0x23
out_byte = (rotated + add_val) & 0xFF
dest     = (13 * i) % 41
buffer[dest] = out_byte

Crucially, the destination index and every keystream value depend only on the loop counter i, never on the input's actual byte values. That means the permutation and keystream can be computed independently of the flag; we don't need to brute-force anything, just invert the arithmetic.

The comparison

After the transform loop, the code does this:

movdqa xmm0, [key_2460]      ; 16 bytes
movdqa xmm1, [key_2470]      ; 16 bytes
pxor   xmm0, [rsp]           ; buffer[0:16]  XOR key_2460
pxor   xmm1, [rsp+0x10]      ; buffer[16:32] XOR key_2470
por    xmm0, xmm1
... (shift/or fold tree) ... ; collapse 32 bytes down to 1 byte (dl)
; then a second loop:
for k in 32..41:
    dl |= buffer[k] ^ expected_3[k]
test dl, dl
je success

The SSE "fold" (psrldq + por repeated) is just an optimized way of checking "are all 32 bytes zero" without a real loop — if dl ends up 0, every byte matched.

Dumping the actual bytes from .rodata confirms:

key_2460        == expected.3[0:16]
key_2470        == expected.3[16:32]

So the two "keys" are literally just the first 32 bytes of the expected.3 constant, duplicated by the compiler for the SIMD comparison. The full 41-byte target buffer is simply expected.3 in its entirety; no separate reconstruction needed.


4. Inverting the Cipher

Since dest(i) and the keystream are independent of the input, we can invert everything symbolically:

target      = expected.3[dest(i)]          # buffer[dest(i)] required value
add_val     = ((11*i) & 0xFF) ^ 0x23
after_add   = (target - add_val) & 0xFF    # undo the "+= add_val"
rot         = (i % 7) + 1
pre_rotate  = ror8(after_add, rot)         # undo the rol8
input[i]    = pre_rotate ^ key_part_a[i%8] ^ key_part_b[i%8]

Full solver script

data      = open('e4sy_RE', 'rb').read()
key_a     = data[0x2458:0x2458+8]
key_b     = data[0x2450:0x2450+8]
expected3 = data[0x2420:0x2420+0x29]   # 41 bytes

def ror8(v, r):
    r %= 8
    return ((v >> r) | (v << (8 - r))) & 0xFF

flag = [0] * 41
for i in range(41):
    dest       = (13 * i) % 41
    target     = expected3[dest]
    add_val    = ((11 * i) & 0xFF) ^ 0x23
    after_add  = (target - add_val) & 0xFF
    rot        = (i % 7) + 1
    pre_rotate = ror8(after_add, rot)
    flag[i]    = pre_rotate ^ key_a[i % 8] ^ key_b[i % 8]

print(bytes(flag))

(Note: .rodata starts at both file offset and virtual address 0x2000, since this is a non-relocated section in a PIE binary loaded at its default base; so raw file offsets can be used directly to index into .rodata symbols like key_part_a, key_part_b, and expected.3.)

Output

b'BDSEC{e4SY_r3v3rS3_eNg1N33r1nG_cH4LL4ng3}'

5. Verification

Feeding the recovered string back into the binary confirms the solve:

$ echo "BDSEC{e4SY_r3v3rS3_eNg1N33r1nG_cH4LL4ng3}" | ./e4sy_RE
...
[+] Excellent work, reverse engineer!
[+] Submit the flag to the CTF platform to receive your points.

6. Takeaways

Muktir Shongket
Cold Start