Украденный флаг (Stolen flag)
Украденный флаг (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 read-only • copy allowed1 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 takesHEX(...)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 to31= ASCII'1'→ theflagtable 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> currentwas 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 sequence | Resolves 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:
| Bytes | Recovered | Meaning |
|---|---|---|
| 1-6 | BugCTF | CTF flag prefix |
| 7 | { | open brace |
| 8-11 | f14g | leet "flag" (1=l, 4=a) |
| 12 | _ | separator |
| 13-17 | s7eal | leet "steal" (7=t) |
| 18-21 | 1ng_ | leet "ing_" (1=i) |
| 22-30 | inj3cti0n | leet "injection" (3=e, 0=o) |
| 31 | } | close brace |
| |
Reconstruction script
python read-only • copy allowed#!/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)