2022 HITCON CTF WriteUp
HITCON CTF 是由台湾的 HITCON 安全社区组织并主办的国际性网络安全夺旗竞赛,与每年的 HITCON 台湾骇客年会同期举办
🏆 赛事概况
比赛时间:2022 年 11 月 25 日 21:00 至 11 月 27 日 21:00 (UTC+7)
比赛形式:线上 Jeopardy(解题) 模式
赛事规模:共吸引了来自全球 95 个国家的 1,243 支队伍参赛
题目设置:共 27 道挑战题,涵盖 Web、Pwn、Crypto、Reverse、Misc 等方向
Web
yeeclass(弱比较 + 越权 + uniqid() 可预测)

yeeclass 是一个类似学校作业提交系统的网站,功能:
学生注册
查看课程
提交 homework
查看 submission
系统存在两类作业:public homework 和 private homework
目标就是获取用户 flagholder 提交到 private homework 中的 flag

题目提供源码,submission.php 中,每个 submission 都有一个 hash:
1 | $id = uniqid($_SESSION["username"]."_"); |
当把 ?homeworkid=2 改成 ?homeworkid=1 时候页面回显权限不够
查看 private homework 源码
1 | if ($_SESSION["userclass"] < PERM_TA && !$result["public"]) { |
这里有一个经典 PHP 弱比较,如果 $_SESSION["userclass"] 没有登录则为 null
null < PERM_TA,所以可以绕过

分析 hash 生成方式
1 | $id = uniqid($username."_"); |
PHP uniqid() 最终可以近似看成将时间转换成固定格式的十六进制字符串,因此如果知道目标提交的大概时间,就可以枚举附近的微秒值
1 | import requests |

rce(JS RCE + 字符长度限制绕过)
1 | // 从签名 cookie 中读取 hex 代码,长度达到 40 后执行 |
接下来分析 Cookie,组成部分为 code=s:<value>.<signature>

关键源码中每访问一次增加一个随机 hex 字符
1 | // 生成 1 字节随机数并转为十六进制字符串(结果 2 个字符) |
现在的问题是我们需要控制这增加的随机字符从而凑够 40 个 hex 字符打 RCE
也就说我们的 Payload 不能超过 20 字符,可构造出以下 Payload
1 | // 让服务器执行 URL 参数 meow 里的任意 JavaScript 代码 |
服务器每次增加的字符只有 16 种可能,我们可以编写脚本爆破
1 | #!/usr/bin/python3 |
Misc
lemminx(XXE 无回显绕过)

题目描述:Eclipse LemMinX XML Language Server,版本 0.19.1
LemMinX 是 Eclipse 基金会开源的一个 XML 语言服务器。当你在 VSCode 中打开一个 .xml 文件时,编辑器需要知道:
这段 XML 语法对不对?
光标放在某个标签上时,应该提示什么补全?
这个标签的定义在哪里?
这些功能不是 VSCode 自己实现的,而是 VSCode 通过 LSP 协议 向 LemMinX 这个 “后端服务” 请求的。LemMinX 接收请求,分析 XML,然后把结果返回给编辑器
LemMinX 是一个网络服务,它通过标准输入/输出或 TCP 接收 LSP 消息,解析 XML 文档,并返回诊断信息。它本身不面向最终用户,而是面向编辑器(客户端)
LSP 的消息格式类似 HTTP,由 Header 和 Body 两部分组成,中间用 \r\n\r\n 分隔
Header 部分:
1 | Content-Length: 123\r\n |
Body 是一个标准的 JSON-RPC 2.0 消息:
1 | {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}} |
连接服务后,第一件事是发送 initialize 请求,告诉服务器 “我是什么编辑器,我支持什么功能”
1 | from pwn import * |
1 | { |
capabilities告诉客户端 “我能做什么”textDocumentSync: 2表示支持增量文档同步(Full 是 1,Incremental 是 2)completionProvider表示支持自动补全serverInfo.version: "0.19.1"确认了版本号
初始化完成后,客户端需要发送 initialized 通知
initialize 是握手请求,服务端回复能力列表后,客户端再发 initialized 确认 “我收到了,你可以开始工作了”
1 | initialized_msg = { |
通过 textDocument/didOpen 通知发送一个恶意的 XML 文档
1 | didopen_msg = { |
diagnostics 是空数组。没有报错,但也没有返回文件内容
1 | { |
原因一:LemMinX 在 0.19.1 版本中已经默认禁用了外部实体的解析
原因二:即使 XXE 成功读取了文件,LemMinX 也不会把解析结果返回给客户端。LemMinX 的工作方式是:解析 XML → 分析语法 → 返回诊断信息(比如 “第 3 行缺少闭合标签”)
原因三:publishDiagnostics 返回的是语法层面的诊断(如缺少标签、属性值无效等),不会包含实体展开后的内容。即使 /flag 被读取了,你也不可能从诊断信息中看到它
既然 XML 解析层面走不通,我们把注意力转向 LemMinX 的配置系统
LSP 协议规定,客户端在 initialize 请求中可以通过 initializationOptions 字段传递配置参数。很多语言服务器会读取这个字段来调整自己的行为——比如设置日志路径、配置代码格式化规则、指定 Schema 文件位置等
核心问题:LemMinX 的 initializationOptions 支持哪些配置?有没有可以写入任意文件的配置项?
搜索 LemMinX 的配置文档,在 GitHub 上找到了 docs/Configuration.md。其中明确写明了 initializationOptions 的 JSON 格式
1 | "initializationOptions": { |
文档中明确说明:
1 | file: Path to the file for log messages to be output. |
这意味着:
客户端可以通过
initializationOptions.settings.xml.logs.file指定日志文件的输出路径服务器会将日志消息写入这个文件
路径完全由客户端控制
这是一个典型的任意文件写入漏洞的入口
题目环境的启动方式通常是:容器启动时执行一个脚本(比如 /tmp/run.sh),这个脚本负责启动 LemMinX 服务
如果我们能把 /tmp/run.sh 覆盖成执行 cat /flag 的脚本,那么下一次连接时,容器会重新执行这个脚本,flag 就会被输出
分析 LemMinX 的日志输出逻辑。当客户端发送一个不存在的方法时,LSP4J 框架会输出一条错误日志:
1 | Unsupported notification method: <method_name> |
其中 <method_name> 是客户端发送的 method 字段的内容
1 | # 发送一个不存在的 method |
服务端日志中会出现:
1 | Unsupported notification method: /printflag |
所以 method 字段的内容会原样进入日志,我们想要日志中包含 cat /flag。最直接的想法:
1 | bad_msg = { |
日志会变成:
1 | Unsupported notification method: cat /flag |
但 Unsupported notification method: 和 cat /flag 在同一行,如果直接作为 shell 脚本执行,shell 会尝试执行一个叫做 Unsupported 的命令,而不是 cat
解决方案很简单:在 method 字段中注入换行符,让 cat /flag 成为一个独立的新行
1 | bad_msg = { |
攻击链路总览
1 | 第一次连接(植入阶段) |
1 | from pwn import * |