2026. 4. 29. 19:19ㆍ카테고리 없음

들어가면서
휴우... 문제를 푸는 과정은 꽤나 어렵다면 어렵고 쉽다면 쉬웠던것 같다. AI의 도움을 받는것을 더 이상 주저하지는 않지만, 그럼에도 내가 이해를 명확하게 하고 넘어가야한다는 점은 변하지 않지 않은가? 질문에 질문을 물고 더 많은 질문들이 있었기 때문에 어찌 보면 더 성장가능할지도 모르겠다. 물론 AI가 내뱉은 정보를 내것으로만 만들 수 있다면 참 좋을텐데 그게 100퍼센트 될 리가 없지.. 조금이라도 뇌에 들어오길 바라면서 리뷰를 작성하겠다.
XSS-appspot level 1
뭐 당연하게도 첫번째 문제부터 재미나이와 함께하거나 그러지는 않았다.
이 수준은 사용자 입력이 적절한 이스케이프 처리 없이 페이지에 직접 포함되는
일반적인 크로스 사이트 스크립팅(XSS) 공격의 원인을 보여줍니다.
아래의 취약한 애플리케이션 창과 상호 작용하여 원하는 JavaScript 코드를 실행할 수 있는 방법을 찾아보세요.
취약한 창 내부에서 작업을 수행하거나 URL 표시줄을 직접 편집할 수 있습니다.

에이 그래도 웹해킹 지난번에 들은게 있는데 이정도는 간단하게 하지. 목적 자체는 창에서 alert()를 실행시키는 것이다.
아. 근데 이게 왜인지는 모르겠는데, 가상으로 만들어진 페이지 내부의 url 입력창도 원래는 입력이 가능해야하는데, 자꾸 [object Object]라고 쓰여있고, 새로운게 타이핑도 제대로 안되는 버그가 있어서 시크릿 탭으로 문제를 풀었다는 점 정도가 있다.
다시 문제로 돌아와서, 코드를 우선 한번 살펴보자.
page_header = """
<!doctype html>
<html>
<head>
<!-- Internal game scripts/styles, mostly boring stuff -->
<script src="/static/game-frame.js"></script>
<link rel="stylesheet" href="/static/game-frame-styles.css" />
</head>
<body id="level1">
<img src="/static/logos/level1.png">
<div>
"""
page_footer = """
</div>
</body>
</html>
"""
main_page_markup = """
<form action="" method="GET">
<input id="query" name="query" value="Enter query here..."
onfocus="this.value=''">
<input id="button" type="submit" value="Search">
</form>
"""
class MainPage(webapp.RequestHandler):
def render_string(self, s):
self.response.out.write(s)
def get(self):
# Disable the reflected XSS filter for demonstration purposes
self.response.headers.add_header("X-XSS-Protection", "0")
if not self.request.get('query'):
# Show main search page
self.render_string(page_header + main_page_markup + page_footer)
else:
query = self.request.get('query', '[empty]')
# Our search engine broke, we found no results :-(
message = "Sorry, no results were found for <b>" + query + "</b>."
message += " <a href='?'>Try again</a>."
# Display the results page
self.render_string(page_header + message + page_footer)
return
application = webapp.WSGIApplication([ ('.*', MainPage), ], debug=False)
길긴 한데 뭐 사실 위에부분은 그닥 필요없고, render_string도 그냥 렌더링 함수이기 때문에 깊게 주목할 점은 없다.
핵심은 get 함수이다.
query = self.request.get('query', '[empty]')
이부분이 특히 관건이다.
request에서 get 방식으로 요청이 들어왓을 때, 'query' 라는 값 안에 들어있는걸 의심도 없이 고대로 받아서 덥석 저장해버리는 실수를 하고 있다.
이후에 message라는 변수에 "Sorry, no results were found for <b> "+ query + "</b>."라면서 이 내용을 브라우저에게 던진다. 바로 이 포인트가 핵심이다.
저 쿼리 값이 따로 이스케이프 처리와 같은 필터링이 없기 때문에 저 자리에 alert를 띄울 수 있는 html 태그만 집어넣어도 바로 가능하다. 가장 대표적으로 우린 이 태그를 알지.
<img src =" " onerror='alert()'>
뭐 대강 해석해보자면 이미지를 보여주는 태그인데, 이미지의 로딩시에 무언가 문제가 생길 경우 자바스크립트를 실행한다. 그리고 우리가 쓴 코드에는 이미지의 주소가 비어있기 때문에 확정적으로 자바스크립트가 실행되지.

만족스럽구만. 다음으로 넘어가자.
Level2
웹 애플리케이션은 종종 사용자 데이터를 서버 측 데이터베이스에 저장하고,
점점 더 클라이언트 측 데이터베이스에도 저장하는 추세입니다.
이러한 사용자 제어 데이터는 출처와 관계없이 신중하게 다뤄야 합니다.
이 수준은 복잡한 애플리케이션에 XSS 버그가 얼마나 쉽게 발생할 수 있는지 보여줍니다.

게시판 형태인걸 보아하니 딱 봐도 stored xss를 의도한 것으로 보인다. 문제 설명에서도 데이터베이스를 언급하기도 하고 말이다. 으흐흐 나도 어딘가에다가 stored xss 한번 성공해봤으면 좋겠다.
이번엔 특별히 코드가 3개나 된다. index.html , level.py , post-store.js 이렇게 3개인데, 이제부터 코드는 딱 의미있는 부분만 적어서 가져오겠다. .

level.py인데, 그냥 메인페이지 처리를 담당하고 있다. 그렇게까지 의미있는 내용은 없다고 판단했다.
<script>
var defaultMessage = "Welcome!<br><br>This is your <i>personal</i>"
+ " stream. You can post anything you want here, especially "
+ "<span style='color: #f00ba7'>madness</span>.";
var DB = new PostDB(defaultMessage);
function displayPosts() {
var containerEl = document.getElementById("post-container");
containerEl.innerHTML = "";
var posts = DB.getPosts();
for (var i=0; i<posts.length; i++) {
var html = '<table class="message"> <tr> <td valign=top> '
+ '<img src="/static/level2_icon.png"> </td> <td valign=top '
+ ' class="message-container"> <div class="shim"></div>';
html += '<b>You</b>';
html += '<span class="date">' + new Date(posts[i].date) + '</span>';
html += "<blockquote>" + posts[i].message + "</blockquote";
html += "</td></tr></table>"
containerEl.innerHTML += html;
}
}
window.onload = function() {
document.getElementById('clear-form').onsubmit = function() {
DB.clear(function() { displayPosts() });
return false;
}
document.getElementById('post-form').onsubmit = function() {
var message = document.getElementById('post-content').value;
DB.save(message, function() { displayPosts() } );
document.getElementById('post-content').value = "";
return false;
}
displayPosts();
}
</script>
index.html의 <script> 부분이다. 흐음... 이제보니 "왜 이미 풀었는데 이 코드가 처음 보는것 같지?" 라는 생각을 했었는데, 처음 문제를 풀때 보자마자 이건 stored xss겠지~ 하고서 바로 답부터 집어넣어버려서 풀렸었다. 뭐 다시 볼때는 이유를 찾는것도 나쁘진 않지. 개인적으로는 이 부분이 문제가 아닌가 싶다. html += "<blockquote>" + posts[i].message + "</blockquote";
서버에서 날라온 내용을 그대로 html 구현체 안에 담아버린다. 그럼 적어도 서버에서 필터링이 존재했어야 하는데, 서버의 코드를 보면?
this.save = function(message, callback) {
var newPost = new Post(message);
var allPosts = this.getPosts();
allPosts.push(newPost);
window.localStorage["postDB"] = JSON.stringify({
"posts" : allPosts
});
callback();
return false;
}
음... 필터링 없이 여기도 넙죽 받아먹는 모습이다. 이전과 마찬가지로 코드를 입력해주면?

이전과 같은 코드를 입력했음에도 문제없이 다음 스테이지로 넘어갈 수 있다.
Level3
이전 레벨에서 보셨듯이, 일부 일반적인 JavaScript 함수는 실행 싱크(execution sink) 입니다.
즉, 이러한 함수는 입력에 포함된 모든 스크립트를 브라우저에서 실행하게 합니다.
때로는 상위 레벨 API가 내부적으로 이러한 함수 중 하나를 사용함으로써 이러한 사실이 숨겨지기도 합니다.
이번 레벨의 애플리케이션은 바로 이러한 숨겨진 싱크를 사용하고 있습니다.

문제에서 설명이 나온대로, 뭔가 입력창이 없다. 이건 살짝 잔머리를 굴린 감이 있지만, 아마 이전에 두개가 각각 Refelected xss, Stored Xss였으니까 이건 높은 확률로 DOM based xss이겠다고 생각하고 문제를 풀었다.
사실 이 문제를 풀다가 조금 막혔었던 부분이, 이 문제는 저 url 창을 입력하지 못하면 풀 수가 없는 문제이다. 그런데 난 버그가 터진 상태로 계속 문제를 풀고 있었으니... 막히는건 어찌 보면 당연한 일이었다. 그래서 다음 문제라도 풀어보기 위해서 이런 저런 시도를 했었다.

링크를 보면 뭔가 떠오르는게 있다. 아니, 3레벨의 문제를 푸는데 그 주소가 /level3이다?? 이거는 그냥 바로 숫자만 바꿔보고 싶어지는 것이었다.
물론 시도해본 결과는 막혔다. 왜 막혔는가 보니

각 단계를 클리어할 때마다 이렇게 쿠키에 새로운 값이 저장이 된다. 지금은 다 클리어했기 때문에 대응되는 값이 있었지만 그땐 2레벨까지밖에 없었다. 처음에는 value값이 MD5랑 자리수도 딱 똑같고 그러길래 해시 크래커에다가 돌려보았지만, 유효한 값이 아니었다. 나중에 재미나이에게 물어보니 아마 랜덤한 값일거라서 맞추는건 어려울 거라고 했다.
버그가 없는 모습은 이렇게 생겼다.

이때 각각 이미지를 누르면,

url이 이런식으로 바뀌는걸 볼 수가 있다. 지난주의 내용을 잘 봤다면 기억이 날것이다. 그렇다! 저건 분명 fragment였다. 가장 이해하기 쉽게 설명하자면 나무위키에서 특정 글 누르면 해당 글의 내용에 해당하는 위치로 넘어가는... 특징으로는 서버에 따로 기록이 남지 않는 부분이었다는 점이 있었던 바로 그 부분이다. 바로 이곳이 DOM 기반 xss가 노리는 부분이다.
이를 방증하듯이, 코드에서 파이썬, 즉 서버쪽 코드는 아주 간단하게만 표시되어있다.

메인은 이제 index.html이다. 문제들을 보아하니 <script> 쪽 코드가 메인인듯 하다.
<script>
function chooseTab(num) {
// Dynamically load the appropriate image.
var html = "Image " + parseInt(num) + "<br>";
html += "<img src='/static/level3/cloud" + num + ".jpg' />";
$('#tabContent').html(html);
window.location.hash = num;
// Select the current tab
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].id == "tab" + parseInt(num)) {
tabs[i].className = "tab active";
} else {
tabs[i].className = "tab";
}
}
// Tell parent we've changed the tab
top.postMessage(self.location.toString(), "*");
}
window.onload = function() {
chooseTab(unescape(self.location.hash.substr(1)) || "1");
}
// Extra code so that we can communicate with the parent page
window.addEventListener("message", function(event){
if (event.source == parent) {
chooseTab(unescape(self.location.hash.substr(1)));
}
}, false);
</script>
뭐 나름 길긴 한데, 나름대로 핵심이라고 여긴 부분은 이 부분이었다.
chooseTab이라는 함수 안에 있던 부분인데, 해당 함수의 호출부를 보면
choooseTab(unescape(self.location.hash.substr(1)) || "1"); 이라고 되어있는것을 볼 수 있다.
이 부분은 사실 처음에 제미나이의 도움이 있기 전까지는 명확히 몰랐던 부분이다. location.hash라는 말은 fragment 부분을 추출하는 뜻이라고 한다. 이걸 알아내면서 부가적으로 알아낸 잡다한 지식이 있는데, unescape라는 함수는 url인코딩을 디코딩하는 함수, 그리고 substr은 문자열을 특정 부분부터 특정 부분까지 잘라내는 함수라는 것이다. 원래는 .substr(a,b) 의 형태로 쓰여서 a 부터 b 전까지로 쓰이지만, 파라미터를 하나만 전달할 경우 그 인덱스부터 끝까지 쭉이라는 의미라고 한다. 여기서는 프래그먼트가 #1, #2, #3과 같은 형태라서 #을 빼기 위해서 1번 인덱스부터 시작한다고 설명되었다.
무튼, 잡다한 코드 설명은 넘기고, 우리가 여기서 시도할 것도 똑같다. 결국 그렇게 해서 집어넣은 값을 가지고서 html이라는 문자열에다가 값을 더한다. 즉 # 뒤의 값이 그대로 html 상으로 넘어간다고 보면 되는것이다. 지금 대략 아마 이런 맥락 속에서 들어갈것이다
"Image [프래그먼트부분]<br><jimg src='static/level3/cloud[프래그먼트부분].jpg
그렇다면? 바로 그냥 앞부분에서 alert() 터지게 한번 만들어보자.
<img src=" " onerror='alert()'>

흐음...안되는 모습이다.