access.log 하나만 주어진 포렌식 문제. 로그는 sqlmap이 DVWA에 던진 time-based blind SQLi로 가득하다. time-based의 원래 신호인 '지연'은 nginx 기본 로그에 없지만, 주입된 IF가 WHERE의 행 유무를 바꾸는 덕에 응답 바이트(1664 = 조건 참)가 오라클이 된다. 4977개 추출 쿼리의 ORD(MID()) 비교를 되감아 dvwa.flag.value = DH{...}를 복원했다.
당신은 이번 해킹 사건의 분석을 담당하게 되었습니다. 그런데 access.log 파일 하나만이 제공 가능하다고 합니다. 과연 분석을 마치고 플래그를 찾아낼 수 있을까요?
즉 공격자가 서버에 무슨 짓을 했는지 로그만 보고 되짚어야 한다. 로그를 열어 보면 처음부터 끝까지 sqlmap의 SQL 인젝션 흔적이다. 공격자가 blind SQLi로 무언가를 한 글자씩 빼내 갔고, 그 "무언가"가 플래그다. 로그를 거꾸로 돌려 공격자가 얻어 낸 값을 그대로 재구성하는 게 목표다.
access.log 역추적 — sqlmap의 time-based blind SQLi를 되감아 flag 복원
문제 개요
항목
내용
문제명
access-log
난이도
🥉 브론즈 2
분류
Forensics
제공 파일
access.log (nginx 접근 로그 6655줄)
목표
로그 분석으로 공격자가 빼낸 플래그 복원
핵심 기법
time-based blind SQLi를 응답 크기 오라클로 역재구성
정답 플래그는 서버 어딘가의 DB에 있었고, 공격자가 그걸 blind SQLi로 이미 빼 갔다. 우리는 그 과정을 로그로만 지켜본 셈이라, 공격자가 얻은 답을 똑같이 계산해 내면 된다.
배경 — DVWA, sqlmap, 그리고 time-based blind SQLi
로그의 요청 경로는 /vulnerabilities/sqli/, User-Agent는 sqlmap/1.2.4다. 둘 다 익숙한 이름이다.
DVWA(Damn Vulnerable Web Application)는 웹 취약점 실습용 앱이다. 는 파라미터를 그대로 쿼리에 넣는 전형적인 SQL 인젝션 지점이다.
sqlmap은 SQL 인젝션 자동화 도구다. 취약점을 찾으면 DB 구조부터 테이블·컬럼·데이터까지 자동으로 빼낸다.
blind SQLi는 쿼리 결과가 화면에 직접 안 보일 때, 참/거짓만 알 수 있는 신호로 데이터를 한 비트씩 캐내는 기법이다. 그중 time-based는 "조건이 참이면 SLEEP()으로 응답을 지연시켜라"라고 주입해, 응답이 늦는지 아닌지로 참/거짓을 읽는다.
sqlmap의 time-based 페이로드는 대략 이렇게 생겼다.
id=1' AND 9329=IF( (ORD(MID((SELECT `value` FROM dvwa.flag ORDER BY id LIMIT 0,1), 1, 1)) > 64), SLEEP(1), 9329)-- -
dvwa.flag 테이블 value의 첫 글자 아스키 코드가 64보다 크면 SLEEP(1)로 1초 쉬고, 아니면 안 쉰다. 이렇게 > N의 N을 이진탐색으로 바꿔 가며 한 글자의 아스키 값을 좁혀 나간다. 공격자는 이 짓을 문자 하나하나, 컬럼 하나하나 반복해 DB를 통째로 떠 갔고, 그 요청이 전부 로그에 남았다.
access.log 개요 — 6655줄 중 6649줄이 sqlmap/1.2.4 UA, 첫 요청은 DVWA sqli 페이지 GET
6655줄 중 6649줄이 sqlmap/1.2.4다. 사람이 아니라 도구가 수천 번 요청을 때린 흔적이다. 요청 하나를 URL 디코드해서 무슨 쿼리인지 뜯어봤다.
# decode_one.py — 로그 한 줄을 URL 디코드해 blind 쿼리 구조 보기import urllib.parsefor ln in open("access.log"): q = urllib.parse.unquote(ln) if "value` AS CHAR" in q and "FROM dvwa.flag" in q and ">64)" in q: print(q[q.find("id="):q.find(" HTTP")]) break
URL 디코드한 blind 쿼리 — dvwa.flag.value 1번째 글자 ORD가 64보다 크면 SLEEP, 응답 1664바이트는 조건 참
디코드하면 앞의 배경에서 본 그 페이로드다. SELECT \value` FROM dvwa.flag의 첫 글자를 ORD(MID(...)) > 64로 떠보고 있다. 공격 대상이 dvwa.flag테이블의value` 컬럼임이 여기서 드러난다.
💣 핵심 — time-based인데 로그에 시간이 없다
문제가 하나 있다. time-based blind의 원래 오라클은 응답 지연 시간인데, 이 로그엔 시간 정보가 없다.
nginx combined 로그 포맷엔 $request_time 같은 응답시간 필드가 없어 SLEEP 지연을 직접 볼 수 없다
nginx 기본 combined 포맷은 IP - user [시각] "요청" 상태 응답바이트 "referer" "UA"다. $request_time 같은 소요 시간 필드가 없어서, SLEEP으로 실제로 얼마나 지연됐는지는 로그만 봐선 알 수 없다.
그런데 여기 사소하지만 결정적인 틈이 있다. sqlmap의 페이로드는 AND X=IF(조건, SLEEP(1), X) 꼴이다. 이걸 그대로 원래 쿼리에 붙이면 WHERE user_id='1' AND X=IF(...)가 된다.
조건이 참이면 IF가 SLEEP(1)을 실행하고 그 반환값은 0이다. 그러면 X=0이 되어 거짓, WHERE ... AND 거짓 → 행이 반환되지 않는다.
조건이 거짓이면 IF가 X(=9329)를 반환한다. X=X는 참, WHERE ... AND 참 → 원래 행이 반환된다.
즉 조건의 참/거짓이 결과 행의 유무를 바꾸고, 그건 곧 응답 본문 크기의 차이로 로그의 응답 바이트 필드에 남는다. 지연을 못 봐도 응답 크기로 참/거짓을 읽을 수 있는 것이다. 실제로 추출 쿼리들의 응답 바이트를 세어 봤다.
# oracle.py — 추출 쿼리 응답 바이트 분포import collections, urllib.parsebc = collections.Counter()for ln in open("access.log"): if "SLEEP" in ln and "ORD(MID" in urllib.parse.unquote(ln): bc[int(ln.split()[9])] += 1print(bc.most_common(6))
응답 바이트 분포 — 1664바이트가 2136회로 압도적, 나머지는 1808~1860, 1664가 조건 참(SLEEP·row 없음) 신호
1664 바이트가 압도적으로 많고(2136회), 나머지는 1808~1860에 흩어져 있다. 1664가 바로 "행이 사라진" 짧은 응답, 즉 조건이 참일 때의 신호다. 이게 우리의 blind 오라클이다.
▶ 삽질: 응답시간을 찾아 헤매고, flag 테이블 이름에 한 번 속다
처음엔 time-based라는 말에 갇혀 "로그에서 응답 시간을 어떻게든 뽑아야 한다"고 생각했다. 로그 포맷을 몇 번이나 다시 보며 $request_time이 숨어 있나 뒤졌지만 없었다. 한참 뒤에야 "지연이 아니라 주입이 결과 집합을 바꾸니 응답 크기를 보면 된다"는 데 생각이 미쳤다. time-based 페이로드지만 사실상 boolean 오라클을 공짜로 얻는 셈이었다.
두 번째 삽질은 테이블 이름이었다. 처음 파싱했을 때 FROM dvwa.flag만 눈에 들어와서 "flag 테이블에서 id밖에 안 빼갔네?"라고 결론지을 뻔했다. 그런데 컬럼 열거 쿼리의 WHERE table_name=0x666c616773을 디코드하니 0x666c616773 = "flags"였다. flag와 flags, 두 테이블이 따로 있었던 것.
flags 테이블은 컬럼 수를 세면 0이 나오는 빈 껍데기(디코이)였고, 진짜 데이터는 flag 테이블의 value 컬럼에 있었다. 재구성 키를 FROM ... 절 전체(WHERE 포함)로 잡아 두 테이블을 구분하고 나서야 값이 제대로 떨어졌다.
🚀 이진탐색 되감기 — 공격자가 얻은 값을 그대로 계산
오라클을 정했으니 재구성은 기계적이다. 각 추출 쿼리에서 (추출 대상, LIMIT 행, 문자 위치)와 ORD(MID(...)) 연산자 값, 그리고 응답 바이트를 뽑아, 응답이 1664면 조건 참으로 보고 >·<·=·!=에 맞춰 그 문자의 하한·상한을 좁힌다. 위치별로 값이 하나로 확정되면 문자가 나온다.
import urllib.parse, re, collectionslines = open("extracted/access.log").read().splitlines()pat = re.compile( r"ORD\(MID\(\(SELECT IFNULL\(CAST\((?P<expr>.+?) AS CHAR\),0x20\) FROM (?P<src>.+?)\)," r"(?P<pos>\d+),1\)\)(?P<op>!=|>=|<=|=|>|<)(?P<val>\d+)")bytecnt = collections.Counter(); recs = []for ln in lines: if "SLEEP" not in ln: continue q = urllib.parse.unquote(ln) m = pat.search(q) if not m: continue by = int(ln.split()[9]); bytecnt[by] += 1 src = m.group("src"); rr = re.search(r"LIMIT (\d+),1", src) r = int(rr.group(1)) if rr else 0 recs.append((m.group("expr"), re.sub(r" ORDER BY.*$", "", src), r, int(m.group("pos")), m.group("op"), int(m.group("val")), by))TRUE_BYTES = bytecnt.most_common(1)[0][0] # 가장 흔한 응답 = 조건 참(SLEEP)bounds = collections.defaultdict(lambda: [0, 255]); eqs = {}; neq = collections.defaultdict(set)for expr, src, r, pos, op, val, by in recs: cond = (by == TRUE_BYTES); k = (expr, src, r, pos); lo, hi = bounds[k] if op == ">": lo, hi = (max(lo, val+1), hi) if cond else (lo, min(hi, val)) elif op == "<": lo, hi = (lo, min(hi, val-1)) if cond else (max(lo, val), hi) elif op == "=": eqs[k] = val if cond else eqs.get(k) elif op == "!=": (neq[k].add(val) if cond else eqs.__setitem__(k, val)) bounds[k] = [lo, hi]def ch(k): if k in eqs and eqs[k] is not None: c = eqs[k] else: lo, hi = bounds[k]; cand = [x for x in range(max(lo,1), hi+1) if x not in neq[k]] c = lo if lo == hi else (cand[0] if len(cand)==1 else None) return chr(c) if c and 32 <= c < 127 else "?"groups = collections.defaultdict(dict)for k in bounds: expr, src, r, pos = k; groups[(expr, src, r)][pos] = ch(k)for k, pm in groups.items(): if "dvwa.flag" in k[1] and "value" in k[0]: flag = "".join(pm[p] for p in sorted(pm)) print("[+] dvwa.flag.value =", flag[:flag.index("}") + 1])
이 재구성기를 WHERE 절까지 포함해 키로 잡고 돌리면 공격자가 훑은 DB 구조 전체가 되살아난다. 스키마와 flag 테이블을 보면 정체가 분명하다.
재구성된 dvwa 스키마 — 테이블 flag·users·guestbook, flag 테이블 컬럼 id·value, dvwa.flag.value 에서 플래그 복원
dvwa 스키마엔 flag, users, guestbook 세 테이블이 있고, flag 테이블은 id·value 두 컬럼을 가진다. 그 value가 DH{anA1yz1nGVe3yB19L0g} — 플래그다. leet를 풀면 "analyzing very big log", 딱 이 문제 그 자체다.
재구성이 제대로 됐는지 확인 삼아 users 테이블도 떠 봤다. 공격자는 flag만이 아니라 사용자 테이블을 통째로 빼 갔다.
DVWA 기본 계정들(admin, gordonb, …)의 MD5 해시까지 정확히 복원된다. 재구성 로직이 올바르다는 방증이다.
Full Exploit
위 스크립트를 그대로 실행하면 플래그가 나온다.
solve.py 실행 — dvwa.flag.value 재구성 결과로 플래그 출력
[+] dvwa.flag.value = DH{anA1yz1nGVe3yB19L0g}
📝 결론
time-based blind라도 응답 크기가 오라클이 될 수 있다.
이 문제의 핵심은 "지연을 못 보는데 어떻게 참/거짓을 읽지?"였다. 답은 페이로드의 부작용에 있었다. AND X=IF(조건, SLEEP, X)는 조건에 따라 WHERE의 결과 행 유무를 바꾸고, 그게 응답 크기 차이로 로그에 남는다. blind SQLi를 로그로 역추적할 땐 시간뿐 아니라 응답 바이트·상태 코드·응답 길이의 미세한 차이가 전부 오라클 후보다.
공격 자동화 도구는 로그에 자기 행적을 촘촘히 남긴다.
sqlmap은 데이터를 빼내려고 수천 번의 요청을 순차적으로 던졌고, 그 이진탐색 과정이 고스란히 로그에 적혔다. 방어자 입장에선 이게 양날의 검이다. blind SQLi는 한 번의 공격으로 끝나지 않고 대량의 규칙적인 요청을 남기므로, 같은 엔드포인트에 짧은 시간 수천 건, 동일 UA, SLEEP·ORD·information_schema 같은 키워드가 보이면 즉시 탐지·차단해야 한다. 반대로 사고 분석 단계에선 그 규칙성 덕에 공격자가 무엇을 얼마나 빼 갔는지까지 정확히 복원할 수 있다. 로그를 combined 포맷 그대로 두지 말고 $request_time·요청 바디까지 남기면, 이런 역추적은 더 쉬워진다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…