Back to KPMG CTF 2026 Campus Edition
Logic Bypass - (Reverse Engineering ) - Writeup
KPMG CTF 2026 Campus EditionReverse Engineering10 ptsMedium

Logic Bypass - (Reverse Engineering ) - Writeup

Oct 12, 2026 4 min readWriteup 19 of 22
On this page
  1. 1.Challenge Overview
  2. 2.Step 1: Basic Triage
  3. 3.Step 2: Mapping the Functions
  4. 4.Step 3: Reading decodeflag
  5. 5.Step 4: Extracting the Arrays and XORing
  6. 6.Summary
  7. 7.Key Takeaways

Challenge Overview

EventKPMG CTF 2026 Campus Edition
ChallengeLogic Bypass
CategoryReverse Engineering
Points10
Filechallenge (ELF 64-bit)

"Analyze the mountain elf's instructions carefully to reach the hilltop and secure the flag."

Logic Bypass challenge card

The "mountain elf" is a pun, we're analyzing an ELF binary (the Linux executable format) and reading its instructions, the assembly. The deployed target serves a directory with a single file, challenge, which we download.

Directory listing with the challenge binary

The name "Logic Bypass" and the in-binary hint ("the decision is made here, not by a memory corruption bug") steer us toward static analysis: understand the program's logic rather than crashing it with an overflow.


Step 1: Basic Triage

First, what are we dealing with?

file challenge
challenge: ELF 64-bit LSB executable, x86-64, ... dynamically linked,
... not stripped

Not stripped is the gift here: symbol names are intact, so every function keeps its real name. That makes the whole thing far easier to read.

Next, pull the human-readable strings:

strings challenge

strings output showing the auth prompts

        LOGIC BYPASS // AUTH
A secret is protected by a local check.
Your goal is to make the binary approve access.
Enter access key:
[ACCESS DENIED]
Hint: the decision is made here, not by a memory corruption bug.
[ACCESS GRANTED]
Flag: %s

So the intended-looking path is: enter a correct "access key" → get [ACCESS GRANTED] → the program prints the flag.


Step 2: Mapping the Functions

With symbols intact, nm gives us the full function list:

nm challenge

A few names jump out:

00000000004015da t decode_flag
0000000000401514 t validate_password
00000000004012bf t transform_password
0000000000401208 t weird_mix
00000000004011c6 t fnv1a32
00000000004014cf t secondary_decoy
00000000004013ca t looks_reasonable
00000000004036a0 d analysis_noise
0000000000402020 r FLAG_KEY
0000000000402040 r ENC_FLAG

This is where the challenge tips its hand. There's a cluster of functions that look like a serious password routine, transform_password, validate_password, weird_mix, fnv1a32 (a hash), plus things literally named secondary_decoy, looks_reasonable, and analysis_noise. Those names tell you the authors built an elaborate maze of decoys to burn your time.

But two symbols cut through all of it: FLAG_KEY and ENC_FLAG, sitting in read-only data, and a function called decode_flag. The flag is clearly produced by decode_flag from those two arrays. If we can read what decode_flag does, we may not need the password at all.


Step 3: Reading decode_flag

objdump -d -M intel challenge | less

Jumping to decode_flag at 0x4015da:

decode_flag disassembly

The Signature

4015de: mov [rbp-0x18], rdi    ; arg1 = output buffer pointer
4015e2: mov [rbp-0x20], rsi    ; arg2 = length n

On x86-64 the first two arguments arrive in rdi and rsi, so this is decode_flag(char *out, size_t n).

The Size Checks (not important for solving)

4015e6: cmp [rbp-0x20], 0x36   ; n > 54 ?
4015eb: ja  4015fd             ;   yes -> do the real decode
4015ed: cmp [rbp-0x20], 0x0    ; n == 0 ?
4015f2: je  401657             ;   yes -> bail
4015fd: mov [rbp-0x8], 0x0     ; i = 0   (loop starts)

main calls this with n = 0x37 (55), so it takes the ja branch and starts looping at i = 0.

The Decode Loop (this is the whole challenge)

401607: lea rdx, [rip+0xa32]      # 402040 <ENC_FLAG>   ; rdx = &ENC_FLAG
40160e: mov rax, [rbp-0x8]        ; rax = i
401612: add rax, rdx              ; &ENC_FLAG[i]
401615: movzx ecx, BYTE [rax]     ; ecx = ENC_FLAG[i]        (ciphertext byte)
401618: mov rax, [rbp-0x8]        ; rax = i
40161c: and eax, 0x7             ; eax = i & 7  (= i % 8)
401622: lea rax, [rip+0x9f7]      # 402020 <FLAG_KEY>   ; rax = &FLAG_KEY
401629: movzx eax, BYTE [rdx+rax] ; eax = FLAG_KEY[i % 8]    (key byte)
40162d: xor ecx, eax             ; ENC_FLAG[i] XOR FLAG_KEY[i % 8]   <-- the decrypt
40163c: mov BYTE [rax], dl        ; out[i] = result
401640: add [rbp-0x8], 0x1        ; i++
401643: cmp [rbp-0x8], 0x35       ; i <= 53 ?
401648: jbe 401607                ;   yes -> loop

Translated to C, the entire function is just:

for (i = 0; i <= 0x35; i++)
    out[i] = ENC_FLAG[i] ^ FLAG_KEY[i % 8];
out[0x36] = 0;   // null terminator

Three facts are all that matter:

  1. ENC_FLAG is at 0x402040 (objdump even annotates # 402040).
  2. FLAG_KEY is at 0x402020, indexed by i & 7, so it's an 8-byte key that repeats.
  3. The operation is a single XOR. No password feeds into this. It reads two fixed arrays and XORs them, nothing else.

The "Logic Bypass"

This is the point of the challenge name. All those validate_password / weird_mix / *_decoy functions gate the [ACCESS GRANTED] message, but the flag bytes don't depend on passing that check, they're static data decoded by a keyed XOR. So we "bypass the logic" entirely: skip the auth maze and just reproduce the XOR ourselves. We never need the access key.


Step 4: Extracting the Arrays and XORing

We can lift both arrays straight out of the file. The .rodata section loads at virtual address 0x402000 from file offset 0x2000, so for these addresses file offset = VMA − 0x400000:

  • FLAG_KEY → file offset 0x2020 (8 bytes)
  • ENC_FLAG → file offset 0x2040 (0x36 = 54 bytes)

A one-liner reproduces the loop:

python3 -c "
d=open('challenge','rb').read()
key = d[0x2020:0x2028]        # 8 bytes, FLAG_KEY
enc = d[0x2040:0x2040+0x36]   # 54 bytes, ENC_FLAG
print(bytes(enc[i] ^ key[i%8] for i in range(0x36)).decode())
"

Python one-liner recovering the flag

KPMG_CTF{dEUfD1VeUZg34-PBC4GNjnqrfVkBmZhIwaTnT3RCBmfp}

No debugging, no patching, no guessing the password, just re-implementing the one function that actually builds the flag.


Summary

StepActionResult
1file + stringsNot-stripped ELF, auth-style prompts
2nmFound decode_flag, ENC_FLAG, FLAG_KEY amid decoys
3objdump on decode_flagIt's out[i] = ENC_FLAG[i] ^ FLAG_KEY[i%8]
4Read arrays from file, XOR in PythonFlag recovered

Key Takeaways

  • Not-stripped binaries hand you the map. Symbol names like decode_flag, ENC_FLAG and FLAG_KEY pointed straight at the solution and let us ignore everything else.
  • Follow the data, not the drama. The binary is full of decoy functions (secondary_decoy, analysis_noise, looks_reasonable) designed to waste your time. The question "where does the flag actually come from?" routes around all of them.
  • You rarely need to beat the check. The flag was static, decoded by a keyed XOR that doesn't depend on the password at all, so passing the auth was never required. Always ask whether the secret is gated by the check or merely printed after it.
  • VMA → file offset conversion is a core RE skill. Knowing .rodata maps 0x402000↔0x2000 (subtract 0x400000) let us pull the arrays directly out of the file instead of running the binary.

Flag: KPMG_CTF{dEUfD1VeUZg34-PBC4GNjnqrfVkBmZhIwaTnT3RCBmfp}