Challenge: Bandaids Help me Heal · CTF: DalCTF 2026 · Category: Pwning · Difficulty: Easy

TL;DR

The “pwn” challenge has no input, no overflow, and nothing to exploit at runtime. It’s a static reversing puzzle wearing a pwn costume. The binary deliberately stalls you inside a usleep loop that, if you actually let it run, would take multiple days to finish. The “security system” is a fake integrity check whose outcome is a foregone conclusion. Once you read the constants out of the disassembly, the flag falls out of a single-byte XOR:

1
dalctf{binary_patched}

The challenge name is the hint: you’re meant to patch the bandaid off (the sleep loop) or just lift the flag statically — never wait.


1. First contact

We’re handed a single ELF named chall. Standard triage first — what is it, and how is it protected?

1
2
3
$ file chall
chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=5ada10c..., not stripped

Not stripped — that’s a gift. We get real symbol names. Now checksec:

1
2
3
4
5
6
7
$ checksec --file=./chall
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No

On paper this reads like a textbook buffer-overflow setup: no stack canary, no PIE (fixed addresses, gadgets at known locations). My pwn instincts immediately said “find the overflow, build a ROP chain.” That instinct turned out to be a trap — the challenge is leading you to over-think it.

Because the binary isn’t stripped, listing the symbols tells us almost the whole story before we read a single instruction:

1
2
3
$ nm chall | grep ' T '
0000000000401156 T decrypt_and_print_flag
00000000004011df T main

A function literally named decrypt_and_print_flag. The whole game is just figuring out how to make main call it — and what argument it passes.

The strings give us the script of the play:

1
2
3
4
5
6
$ strings chall
...
Flag: %s
Initializing secure wait system...
Integrity check failed.
Access granted.

So the flow is: print “Initializing secure wait system…”, do something, then either “Integrity check failed.” (and exit) or “Access granted.” followed by the flag. The “security system” the prompt mentions is this integrity check.


2. Reading main

Let’s disassemble. I used objdump -d -M intel chall.

 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
28
29
30
31
32
33
34
35
36
37
38
39
40
00000000004011df <main>:
  4011df:  push   rbp
  4011e0:  mov    rbp,rsp
  4011e3:  sub    rsp,0x10
  4011e7:  mov    BYTE PTR [rbp-0x1],0xff      ; counter = 0xff
  4011eb:  mov    BYTE PTR [rbp-0x2],0x00      ; acc = 0x00
  4011ef:  lea    rax,[rip+0xe22]              ; "Initializing secure wait system..."
  4011f6:  mov    rdi,rax
  4011f9:  call   puts@plt
  4011fe:  jmp    40121b <main+0x3c>           ; jump to loop condition

  ; ---- loop body ----
  401200:  movzx  eax,BYTE PTR [rbp-0x1]       ; al = counter
  401204:  xor    BYTE PTR [rbp-0x2],al        ; acc ^= counter
  401207:  mov    edi,0x3b9aca00               ; edi = 1,000,000,000
  40120c:  call   usleep@plt                   ; <-- the "secure wait"
  401211:  movzx  eax,BYTE PTR [rbp-0x1]
  401215:  sub    eax,0x1
  401218:  mov    BYTE PTR [rbp-0x1],al        ; counter -= 1

  ; ---- loop condition ----
  40121b:  cmp    BYTE PTR [rbp-0x1],0x0
  40121f:  jne    401200 <main+0x21>           ; loop while counter != 0

  401221:  xor    BYTE PTR [rbp-0x2],0x5a      ; acc ^= 0x5a
  401225:  cmp    BYTE PTR [rbp-0x2],0x5a      ; integrity check: acc == 0x5a ?
  401229:  je     401244 <main+0x65>
  40122b:  lea    rax,[rip+0xe09]              ; "Integrity check failed."
  401232:  mov    rdi,rax
  401235:  call   puts@plt
  40123a:  mov    edi,0x1
  40123f:  call   exit@plt                     ; bail out

  401244:  lea    rax,[rip+0xe08]              ; "Access granted."
  40124b:  mov    rdi,rax
  40124e:  call   puts@plt
  401253:  movzx  eax,BYTE PTR [rbp-0x2]       ; edi = acc
  401257:  mov    edi,eax
  401259:  call   decrypt_and_print_flag       ; key = acc
  40125e:  ...

Let’s translate this into C in our heads:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
unsigned char counter = 0xff;
unsigned char acc     = 0x00;

puts("Initializing secure wait system...");
while (counter != 0) {
    acc ^= counter;
    usleep(1000000000);   // 0x3b9aca00
    counter -= 1;
}

acc ^= 0x5a;
if (acc != 0x5a) {
    puts("Integrity check failed.");
    exit(1);
}
puts("Access granted.");
decrypt_and_print_flag(acc);   // acc passed as the key

Two things jump out.

The “secure wait” is the whole joke

usleep takes microseconds. The argument is 0x3b9aca00 = 1,000,000,000 µs = 1,000 seconds per iteration. The loop runs 255 times.

1
255 iterations × 1000 s ≈ 255,000 seconds ≈ 70.8 hours ≈ ~3 days

(usleep with an argument ≥ 1,000,000 is technically out-of-spec on some libcs, but glibc happily sleeps the full duration.) If you naively ./chall and wait, you’ll be staring at “Initializing secure wait system…” for the better part of a long weekend. That is the “bandaid” — an artificial delay slapped over the program. The challenge wants you to rip it off.

The integrity check can never fail

Look closely at what acc actually becomes. The loop XORs acc with every value of counter from 0xff down to 1:

1
acc = 255 ^ 254 ^ 253 ^ ... ^ 2 ^ 1

There’s a well-known identity for the running XOR of 1..n:

n mod 4XOR(1..n)
0n
11
2n + 1
30

Here n = 255, and 255 mod 4 == 3, so XOR(1..255) = 0. Then:

1
2
3
acc = 0
acc ^= 0x5a   ->   acc = 0x5a
cmp acc, 0x5a ->   equal  ->  "Access granted."

The integrity check is always satisfied, and the key handed to decrypt_and_print_flag is always 0x5a. The XOR-loop is pure theater: it exists only to (a) burn three days of wall-clock time and (b) look like it might branch to “Integrity check failed.” It never does.

This is the elegant part of the design — the program genuinely computes the key, it just computes it the slow way on purpose. There’s no patching required to make it “work”; you only patch to avoid the wait, or skip execution entirely.


3. Reading decrypt_and_print_flag

Now the payload function:

 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
28
29
30
31
32
33
0000000000401156 <decrypt_and_print_flag>:
  401156:  push   rbp
  401157:  mov    rbp,rsp
  40115a:  sub    rsp,0x30
  40115e:  mov    eax,edi
  401160:  mov    BYTE PTR [rbp-0x24],al       ; save key (0x5a) at [rbp-0x24]

  ; ---- load 22 obfuscated bytes onto the stack ----
  401163:  movabs rax,0x38213c2e39363b3e
  40116d:  movabs rdx,0x3b2a0523283b3433
  401177:  mov    QWORD PTR [rbp-0x20],rax      ; bytes  0..7
  40117b:  mov    QWORD PTR [rbp-0x18],rdx      ; bytes  8..15
  40117f:  movabs rax,0x273e3f32392e3b2a
  401189:  mov    QWORD PTR [rbp-0x12],rax      ; bytes 14..21 (overlaps previous!)

  40118d:  mov    DWORD PTR [rbp-0x8],0x16      ; length = 22
  401194:  mov    DWORD PTR [rbp-0x4],0x0       ; i = 0
  40119b:  jmp    4011b9

  ; ---- decrypt loop: buf[i] ^= key ----
  40119d:  mov    eax,DWORD PTR [rbp-0x4]
  4011a2:  movzx  eax,BYTE PTR [rbp+rax*1-0x20]
  4011a7:  xor    al,BYTE PTR [rbp-0x24]        ; ^ key
  4011b1:  mov    BYTE PTR [rbp+rax*1-0x20],dl
  4011b5:  add    DWORD PTR [rbp-0x4],0x1
  4011b9:  cmp    i, 22 ... jl loop

  ; ---- print ----
  4011c1:  lea    rax,[rbp-0x20]
  4011c5:  mov    rsi,rax
  4011c8:  lea    rax,[rip+0xe39]              ; "Flag: %s"
  4011cf:  mov    rdi,rax
  4011d7:  call   printf@plt

In C:

1
2
3
4
5
6
7
void decrypt_and_print_flag(unsigned char key) {  // key = 0x5a
    unsigned char buf[22];
    // initialized from three 64-bit immediates (note the overlap at offset 14)
    for (int i = 0; i < 22; i++)
        buf[i] ^= key;
    printf("Flag: %s", buf);
}

The 22 ciphertext bytes are laid down by three movabs writes. The catch worth noticing: the third write goes to [rbp-0x12], which is only 6 bytes after [rbp-0x18], not 8 — so it overlaps and overwrites the last two bytes of the middle qword. You must replicate that overlap exactly or you’ll get garbage at offsets 14–15.

Reconstructing the buffer (little-endian, with the overlap applied):

OffsetSource qwordBytes (LE)
0–70x38213c2e39363b3e3e 3b 36 39 2e 3c 21 38
8–150x3b2a0523283b343333 34 3b 28 23 05 2a 3b
14–210x273e3f32392e3b2a2a 3b 2e 39 32 3f 3e 27 (overwrites offsets 14–15)

4. Recovering the flag (without the 3-day nap)

There are three reasonable ways to skip the wait. Pick your poison.

Option A — Compute it by hand (fastest, no execution)

We know the key is 0x5a and we have the bytes. XOR them:

1
2
3
4
5
6
7
8
import struct

buf = bytearray(22)
buf[0:8]   = struct.pack('<Q', 0x38213c2e39363b3e)
buf[8:16]  = struct.pack('<Q', 0x3b2a0523283b3433)
buf[14:22] = struct.pack('<Q', 0x273e3f32392e3b2a)   # note: overlapping write

print(bytes(b ^ 0x5a for b in buf).decode())
1
2
$ python3 solve.py
dalctf{binary_patched}

Done. No binary ever executed.

Option B — Patch the bandaid off (true to the challenge name)

If you want to run it, just neuter the sleep. The usleep argument lives in the instruction at 0x40120c (call usleep@plt). The cleanest patch is to NOP out the call, or zero the argument register. With your editor of choice (radare2 shown):

1
2
3
4
5
6
7
8
9
$ r2 -w chall
[0x00401156]> s 0x401207
# zero out edi before usleep: 'mov edi, 0' = BF 00 00 00 00 (5 bytes, same length)
[0x00401207]> wa mov edi, 0
[0x00401207]> q
$ ./chall
Initializing secure wait system...
Access granted.
Flag: dalctf{binary_patched}

The loop still runs 255 times, but usleep(0) returns instantly, so the whole thing finishes in microseconds. This is literally “patching the binary” — which, satisfyingly, is exactly what the flag says.

Option C — Just skip the loop in a debugger

Set a breakpoint after the loop and jump the program counter past it:

1
2
3
4
5
6
7
$ gdb ./chall
(gdb) break *0x401221      # right after the loop, before the integrity check
(gdb) run
(gdb) set $pc = 0x401221   # if you broke earlier; here we just continue
(gdb) continue
Access granted.
Flag: dalctf{binary_patched}

Or simply set var on the counter to 1 once you’ve broken inside the loop, so it exits after a single iteration.


5. The flag

1
dalctf{binary_patched}

The flag is self-documenting: the intended solution is to patch the binary (Option B) — peel the bandaid off the artificial delay. Options A and C are equally valid shortcuts that arrive at the same place.


6. Lessons & takeaways

  • checksec is a starting hint, not a verdict. “No canary + No PIE” screamed overflow at me, but there’s no read/gets/scanf anywhere in the binary — no attacker-controlled input at all. Always confirm an actual input surface exists before committing to a memory-corruption plan.
  • Not-stripped binaries hand you the plot. A symbol named decrypt_and_print_flag plus four telltale strings told me the entire control flow before I read one opcode. Run nm/strings first.
  • “Slow” is a defense mechanism. A usleep measured in billions of microseconds is a deliberate time-wall, not real logic. When a program seems to “hang,” check whether it’s supposed to — and patch the delay rather than wait it out.
  • Watch overlapping stack writes. The three movabs constants overlapped by two bytes. Copy the layout literally; don’t assume each movabs contributes a clean 8 bytes.
  • The XOR(1..n) identity is worth memorizing. n mod 4 instantly tells you the result (0,1,n+1,n for residues 1,2,3,0). It turned a 255-iteration loop into a one-line constant and proved the integrity check was unconditional.

A clean, beginner-friendly reversing challenge dressed up as pwn — the “security system” was never a lock, just a very long, very fake loading bar.