실제 서비스는 대개 리버스 프록시(nginx, haproxy 등) 뒤에 앱 서버를 둔다. 이때 앱 입장에서 TCP 연결의 상대는 프록시이므로,
remote_addr을 그대로 쓰면 모든 요청이 프록시 IP로 보인다. 그래서 프록시는 원래 클라이언트 IP를 X-Forwarded-For(XFF) 헤더에 실어 전달한다.
이 문제의 구성도 정확히 그렇다. docker-compose.yml을 보면 haproxy(:8000) → Flask app(:3000) 2단 구성이고,
haproxy.cfg에 option forwardfor가 켜져 있다. 이 옵션이 요청마다 XFF 헤더를 붙여 준다.
문제는 XFF가 클라이언트가 마음대로 조작할 수 있는 헤더라는 점이다. 프록시는 기존 XFF를 지우지 않고 뒤에 자기 관찰값을 덧붙일 뿐이라,
공격자가 보낸 XFF 값이 목록의 맨 앞(최좌측)에 그대로 남는다. 여기에 앱이 그 값을 신뢰하면 문제가 시작된다.
app.py·haproxy.cfg·Dockerfile 정찰: app.py 가 request.access_route[0] 을 bash -c "echo user_ip" 에 그대로 삽입, haproxy 는 option forwardfor 로 XFF 주입, /flag 는 chmod 744 root:chall 로 chall 그룹 읽기 가능
핵심은 app.py의 이 부분이다.
@app.route('/')def flag(): user_ip = request.access_route[0] if request.access_route else request.remote_addr result = run( ["/bin/bash", "-c", f"echo {user_ip}"], # ← user_ip 가 셸 명령에 그대로! capture_output=True, text=True, timeout=3, ) return render_template("ip.html", result=result.stdout)
두 가지가 겹쳐 치명적이다.
request.access_route[0] — Werkzeug가 X-Forwarded-For를 파싱한 목록의 첫 원소. 앞서 봤듯 이건 공격자가 통제한다.
f"echo {user_ip}"를 /bin/bash -c 로 실행 — 사용자 입력을 셸 문자열에 그대로 이어붙였다. 전형적인 OS 커맨드 인젝션.
그리고 Dockerfile을 보면 플래그는 /flag에 있고 chmod 744 root:chall, 앱은 USER chall로 돈다.
즉 chall 그룹 권한으로 /flag를 읽을 수 있다. 명령만 실행할 수 있으면 플래그는 바로 손에 들어온다.
3. 취약점 원리 — 헤더 한 줄이 셸까지 가는 길
전체 흐름을 그림으로 정리하면 이렇다. 공격자의 XFF 문자열이 haproxy를 지나 Flask의 access_route[0]가 되고,
그대로 bash -c "echo ..."에 삽입되어 임의 명령으로 실행된다.
echo {user_ip}에서 user_ip가 ;cat /flag가 되면 실제 실행되는 셸 명령은 이렇게 쪼개진다.
bash -c "echo ;cat /flag"# └ echo(빈 줄) └ 세미콜론으로 분리된 두 번째 명령이 실행됨
;|&&$(...)`...` 어느 것으로든 명령을 이어붙일 수 있다. 셸에 사용자 입력을 그대로 넘긴 순간 게임 끝이다.
4. 익스플로잇 — 단계별로 확인하며 플래그까지
curl로 X-Forwarded-For 헤더만 바꿔가며 한 단계씩 확인했다.
익스플로잇 4단계: [1] 헤더 없이 요청하면 접속 IP 가 echo 됨, [2] XFF=1.3.3.7 로 스푸핑하면 그 값이 echo 됨(주입 지점 확인), [3] XFF=;whoami/;id/;ls -l /flag 로 임의 RCE(chall 사용자, /flag 는 root:chall 744), [4] XFF=;cat /flag 로 플래그 획득
정상 요청: 헤더 없이 보내면 haproxy가 붙인 내 실제 IP가 그대로 echo되어 화면에 나온다. (echo {ip}가 도는 증거)
IP 스푸핑: X-Forwarded-For: 1.3.3.7 을 보내면 출력이 1.3.3.7로 바뀐다. 내가 보낸 헤더값이 셸의 echo 인자임이 확정된다.
임의 RCE: ;whoami → chall, ;id → uid=1000(chall), ;ls -l /flag → -rwxr--r-- root chall. 명령이 실제로 실행되고, /flag가 chall 그룹으로 읽힌다는 것까지 확인.
플래그: X-Forwarded-For: ;cat /flag → 플래그가 응답 본문에 그대로 렌더된다.
응답에서 결과만 뽑으려고 <div class="ip-address"> 다음 줄부터 </div> 전까지를 awk로 긁었는데,
;whoami 같은 주입은 잘 나오는데 정상 IP나 1.3.3.7은 빈 값으로 보였다.
원인은 echo의 출력 위치였다. echo 1.3.3.7 은 결과가 여는 태그와 같은 줄에 붙는다.
<div class="ip-address">1.3.3.7</div>
반면 echo ;whoami 는 echo가 먼저 빈 줄을 찍어서 결과가 다음 줄로 내려간다.
<div class="ip-address">chall</div>
여는 태그 줄을 next로 건너뛰는 awk가 같은 줄 결과를 놓쳤던 것. perl -0777 슬럽 모드로 태그 사이를 통째로 뽑도록 바꿔 해결했다.
익스 자체는 처음부터 성공하고 있었는데 출력 파싱 때문에 잠깐 헷갈린 케이스.
▶🐛 삽질 2 — 페이로드에 콤마(,)를 쓰면 안 되는 이유
access_route는 X-Forwarded-For를 콤마로 분리해 만든 목록이고, 우리가 쓰는 건 그 첫 원소다.
그래서 페이로드에 콤마가 들어가면 거기서 잘려 의도한 명령이 깨진다. 다행히 cat /flag, id, whoami엔 콤마가 없어 그대로 통했지만,
복잡한 명령을 쓸 땐 콤마를 피하거나 ${IFS}·base64 인코딩 같은 우회를 염두에 둬야 한다. (공백은 헤더값에 그대로 넣어도 문제없었다.)
6. 방어 관점 — 무엇이 잘못됐나
한 줄짜리 취약점이지만 방어 교훈은 여러 겹이다.
사용자 입력을 셸에 넘기지 말 것. 애초에 IP를 화면에 보여주는 데 bash -c "echo ..."가 필요 없다. 그냥 문자열을 템플릿에 렌더하면 된다.
꼭 외부 명령을 써야 한다면 shell=True/bash -c 대신 인자 배열로 직접 전달(run(["echo", user_ip]))해 셸 해석을 차단한다.
X-Forwarded-For를 신뢰하지 말 것. XFF는 클라이언트가 위조할 수 있다. 신뢰 경계는 내가 제어하는 맨 바깥 프록시뿐이며,
그 프록시가 XFF를 덮어쓰도록(append가 아니라 set) 설정하고, 앱은 신뢰된 프록시가 준 값만 사용해야 한다.
입력 검증. IP를 원했다면 정규식/ipaddress 파싱으로 IP 형식이 아닌 값은 거부했어야 한다.
최소 권한. 이 문제는 /flag가 앱 사용자 그룹으로 읽혀 바로 털렸다. RCE가 나더라도 민감 파일에 손이 닿지 않도록 권한을 최소화해야 피해를 줄인다.
정리하면 "신뢰할 수 없는 입력(XFF) + 셸 실행(bash -c) + 과한 파일 권한" 세 가지가 겹쳐 한 줄이 전체 서버 RCE로 번졌다.
웹앱에서 외부 입력이 인터프리터(셸·SQL·템플릿)로 흘러 들어가는 경로는 언제나 최우선 점검 대상이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…