← Back to Writeups
CUSTOMMediumForensics

Украденный флаг (Stolen flag)

XESXOR9/2/20265 min read
#forensics#custom#medium#constraint_satisfaction_flag_r#hex_nibble_decoding#probe_direction_recovery#randomblob_delay_analysis#sqlmap_binary_search_reconstru#timing_side_channel_from_logs

Украденный флаг (Stolen flag)

Platform: bug-makers | Category: Forensics | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2025-10-04 | Status: Solved Techniques: constraint_satisfaction_flag_recovery, hex_nibble_decoding, probe_direction_recovery, randomblob_delay_analysis, sqlmap_binary_search_reconstruction, timing_side_channel_from_logs

Summary

Task: only an nginx access log is given; attackers used sqlmap SQLite time-based blind SQLi (RANDOMBLOB delay) to exfiltrate a flag, then deleted it. Solution: reconstruct each hex nibble of HEX(flag) not from noisy 1s-resolution timing, but from the deterministic DIRECTION of sqlmap's binary-search probe values, then resolve the remaining 1-bit-per-nibble ambiguity with printability and flag-format/leetspeak constraints.

Украденный флаг (Stolen flag) — bug-makers

Description

Злоумышленники нашли уязвимость в поисковой строке сайта и с помощью слепой sql-инъекции украли флаг из БД, а потом стерли его. Из логов есть только access-лог nginx. Попробуйте понять какой флаг был украден.

English summary: Attackers found a vulnerability in the site's search bar and used blind SQL injection to steal the flag from the database, then erased it. The only available artifact is the nginx access log (bug-makers.ru-access.log, 3043 lines, ~979 KB). Recover the stolen flag. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Analysis

The log is dominated by requests from a single attacker:

  • Attacker IP: 37.113.192.127
  • User-Agent: sqlmap/1.9.6#stable
  • All requests hit /search?q=<payload>

URL-decoding the payloads reveals a classic sqlmap workflow: a fingerprinting/detection phase using SLEEP-style time probes, followed by a data-extraction phase. The extraction queries target a table flag and look like:

sql
1 AND 7447=(CASE WHEN
  (SUBSTR((SELECT HEX(COALESCE(CAST(flag AS TEXT),CHAR(32))) FROM flag LIMIT 0,1),POS,1) > CHAR(N))
  THEN (LIKE('ABCDEFG', UPPER(HEX(RANDOMBLOB(200000000/2)))))
  ELSE 7447 END)

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Key observations:

  • This is SQLite time-based blind SQL injection.
  • The TRUE branch evaluates RANDOMBLOB(200000000/2) then takes HEX(...) of a ~100 MB blob — an expensive operation that slows the response by ~2-3 s. The FALSE branch returns instantly.
  • So: condition TRUE → slow response; FALSE → fast response.
  • The condition is SUBSTR(HEX(flag), POS, 1) > CHAR(N) — a binary search over each hex nibble of the flag.
  • A preceding extraction of HEX(COUNT(*)) decodes to 31 = ASCII '1' → the flag table has 1 row.
  • The flag is extracted as HEX(flag): 31 bytes → 62 hex nibbles. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Solution

The forensic obstacle: coarse timing

nginx access logs only have 1-second timestamp resolution. The RANDOMBLOB delay is ~2-3 s, so in principle a TRUE response shows a >=2s gap to the next request while FALSE shows <=1s. In practice this is noisy — server jitter sometimes produces a 2-3 s gap even on FALSE responses. Relying on timing alone gives an unreliable bit-stream.

The lossless insight: probe DIRECTION

sqlmap's binary search is deterministic. For each character it issues a fixed sequence of CHAR(N) probes. The direction between consecutive probes reveals the comparison result of the previous probe with zero noise: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  • if next_probe > current_probe → the comparison > current was TRUE (char > current)
  • if next_probe < current_probe → it was FALSE (char <= current)

This recovers every comparison bit except the last probe of each nibble (there is no following probe to reveal its direction). After folding in the directions, each nibble narrows to a width-2 interval [last, last+1] — i.e. a 1-bit ambiguity per nibble, and therefore up to 4 candidate ASCII values per byte (high nibble at odd position 2b-1, low nibble at even position 2b). ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Distinct probe sequences observed

All flag-column sequences start with first probe 66='B':

Probe sequenceResolves to
(66,52,48,50,51)'3' / '4'
(66,52,48,50,49)'1' / '2'
(66,52,56,54,53)'5' / '6'
(66,52,56,54,55)'7' / '8'
(66,52,56,64,57)'9'
(66,52,56,64,65)'A' / 'B'
(66,97,70,68,67)'C' / 'D'
(66,97,70,68,69)'E' / 'F'
(66,52,48,1,...)terminator (CHAR(32) padding / end)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Constraint satisfaction

Build a per-byte candidate ASCII set from the nibble ambiguity, then prune with:

  • Printability — every byte must be valid printable ASCII.
  • Flag-format anchors — byte 7 = {, byte 31 = }.
  • Leetspeak tail — bytes 22-31 confirmed as inj3cti0n} (i, n, j, 3, c, t, i, 0, n, }).
  • Theme — the challenge is about stealing the flag via blind SQL injection.

Each of the 31 bytes is then uniquely fixed within its candidate set:

BytesRecoveredMeaning
1-6BugCTFCTF flag prefix
7{open brace
8-11f14gleet "flag" (1=l, 4=a)
12_separator
13-17s7ealleet "steal" (7=t)
18-211ng_leet "ing_" (1=i)
22-30inj3cti0nleet "injection" (3=e, 0=o)
31}close brace
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Reconstruction script

python
#!/usr/bin/env python3
# Reconstruct HEX(flag) from sqlmap binary-search probe directions in nginx log.
import re, urllib.parse
from collections import defaultdict

ATTACKER = "37.113.192.127"
PROBE_RE = re.compile(r"SUBSTR\(\(SELECT HEX\(.*?FROM flag.*?\),(\d+),1\)>CHAR\((\d+)\)")

# pos -> ordered list of CHAR(N) probe values, in request order
probes = defaultdict(list)
with open("bug-makers.ru-access.log") as f:
    for line in f:
        if ATTACKER not in line or "sqlmap" not in line:
            continue
        m = re.search(r'"GET (/search\?q=[^ ]+) HTTP', line)
        if not m:
            continue
        q = urllib.parse.unquote(urllib.parse.unquote(m.group(1)))
        pm = PROBE_RE.search(q)
        if pm:
            pos, n = int(pm.group(1)), int(pm.group(2))
            probes[pos].append(n)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# For each hex position, fold probe directions into a [lo, hi] interval.
# next > cur  => char > cur (TRUE)  => lo = cur+1
# next < cur  => char <= cur (FALSE)=> hi = cur
def resolve_nibble(seq):
    lo, hi = 0, 255
    for cur, nxt in zip(seq, seq[1:]):
        if nxt > cur:
            lo = max(lo, cur + 1)
        elif nxt < cur:
            hi = min(hi, cur)
    # last probe direction unknown -> width-2 ambiguity [lo, lo+1] when lo==hi candidates
    return lo, hi  # candidate hex chars: chr(lo) and possibly chr(lo+? )
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# Each pair of nibbles -> a byte; apply printability + flag-format + leet constraints.
# (Manual CSP step; final verified result below.)
FLAG = "BugCTF{REDACTED}"
assert FLAG[0] == "B" and FLAG[6] == "{" and FLAG[-1] == "}"
print("FLAG:", FLAG)

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍