1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

writeup - LA CTF 2025 日本語版

Last updated at Posted at 2025-02-10

English version

The English version is here:

coming soon...

概要

2025/02/08 13:00-2025/02/10 07:00の3日間(時間単位で42時間)にわたって開催されたLA CTF 2025の参戦記・writeupとなります。

成績

総合

今回はUCLA部門とオープン部門のうちオープン部門に出場させていただきました。

解けた問題... 全52問(welcome問を除く)8問
オープン部門... 906人中363位
全体... 933人中375位

問題の内訳

※welcome 3問を除く

カテゴリ 問題数 解けた問題
crypto 10 1
misc 9 2
pwn 11 0
rev 9 3
web 13 2
52 8

writeup

ここからはwriteupとなります。目次を参考にして問題を開いてください。

crypto

big e

chall.pyが添付されているので、ダウンロードし中身を確認してみます。
(編集の都合上コメントアウトされている部分のうちct_1ct_2nの値を上10桁のみに省略しています)

chall.py
from Crypto.Util.number import bytes_to_long, getPrime

flag = REDACTED

pt = bytes_to_long(flag.encode())

p = getPrime(1024)
q = getPrime(1024)
n = p*q

e_1 = getPrime(16)
e_2 = getPrime(16)



ct_1 = pow(pt, e_1, n)
ct_2 = pow(pt, e_2, n)
print("ct_1 = ", ct_1)
print("ct_2 = ", ct_2)

print("e_1 = ", e_1)
print("e_2 = ", e_2)

print("n = ", n)

# ct_1 =  7003427993...
# ct_2 =  2995334251...
# e_1 =  49043
# e_2 =  60737
# n =  9162219874...

RSA暗号の鍵生成・暗号化コードのようです。
同じ平文ptnが共通かつ互いに素な2つのeで暗号化された文が確認できます。

Common Modulus Attackができそうです。
理論と実装の両方をまとめてくださっている記事(以下リンク)を見つけたので、実装を参考にコードを作っていきます。

cma.py
from Crypto.Util.number import long_to_bytes


def extgcd(a, b):
    #拡張ユークリッド互除法
    #ax + by = gcd(a, b)となる(x, y)を求めます
    if b == 0:
        return [1, 0]

    q = a//b
    r = a%b

    s, t = extgcd(b, r)
    y = s - q*t
    return [t, y]


def common_modulus_attack(c1, c2, e1, e2, n):
    #c1:暗号文1, c2:暗号文2
    a, b = extgcd(e1, e2)# a*c1 + b*c2 = 1 となる(a, b)を計算する
    m1 = pow(c1, a, n)# c1^a mod N の計算
    m2 = pow(c2, b, n)# c2^a mod N の計算
    return (m1 * m2) % n


def main():
    c1 = 7003427993343973209633604223157797389179484683813683779456722118278438552981580821629201099609635249903171901413187274301782131604125932440261436398792561279923201353644665062240232628983398769617870021735462687213315384230009597811708620803976743966567909514341685037497925118142192131350408768935124431331080433697691313467918865993755818981120044023483948250730200785386337033076398494691789842346973681951019033860698847693411061368646250415931744527789768875833220281187219666909459057523372182679170829387933194504283746668835390769531217602348382915358689492117524129757929202594190396696326156951763154356777
    c2 = 2995334251818636287120912468673386461522795145344535560487265325864722413686091982727438605788851631192187299910519824438553287094479216297828199976116043039048528458879462591368580247044838727287694258607151549844079706204392479194688578102781851646467977751150658542264776551648799517340378173131694653270749425410071080383488918100565955153958793977478719703463115004497213753735577027928062856483316183232075922059366731900291340025009516177568909257605255717594938087543899066756942042664781424833498278544829618874970165660669400140113047048269742309745649848573501494088032718459018143817236079173978684104782
    e1 = 49043
    e2 = 60737
    n =  9162219874876832806204248523866163938680921861751582550947065673035037752546476053774362284605943422397285024205866696280912237827227700515353007344062472274717294484810421409217463791112287997964358655519896402380272695026012981743782564008035342746214988154836484419372449523768063368280069515180570625408254410932129769708259508451185553774810385066789146531683973766796965747310893648672657945403825359068647151094841570404979930542270681833162424933411724266687320976217446032292107871449464575533610369244978941764470549091443086646932177141081314452355708815370388814214178980532690792441231698974328523197187

    pt = common_modulus_attack(c1, c2, e1, e2, n)

    m = long_to_bytes(pt).decode()
    print(m)


if __name__ == '__main__':
    main()
$ python3 cma.py
lactf{b1g_3_but_sm4ll_d!!!_part2_since_i_trolled}

フラグを入手できました。

フラグ

lactf{b1g_3_but_sm4ll_d!!!_part2_since_i_trolled}

misc

extended

問題文

What if I took my characters and... extended them?

解法

gen.pychall.txtが添付されているので、ダウンロードし中身を確認してみます(chall.txtはISO 8859-1でエンコードします)。

gen.py
flag = "lactf{REDACTED}"
extended_flag = ""

for c in flag:
    o = bin(ord(c))[2:].zfill(8)

    # Replace the first 0 with a 1
    for i in range(8):
        if o[i] == "0":
            o = o[:i] + "1" + o[i + 1 :]
            break

    extended_flag += chr(int(o, 2))

print(extended_flag)

with open("chall.txt", "wb") as f:
    f.write(extended_flag.encode("iso8859-1"))
chall.txt
ìáãôæûÆõîîéìùßÅîïõçèßÔèéóßÌïïëóßÄéææåòåîôßÏîßÍáãßÁîäß×éîäï÷óý

gen.pyは、「flagから1文字(1バイト)ずつ取りバイナリ上の先頭の0を1に変える」コードのようです。
chall.txtをISO 8859-1でエンコードしたextended_flagに書き換えるコードも確認できます。

元のchall.txtはフラグをgen.pyにかけてできたようなので、逆の操作をしてフラグを取り出していきます。

flag_gen.py
flag = ""
extended_flag = "ìáãôæûÆõîîéìùßÅîïõçèßÔèéóßÌïïëóßÄéææåòåîôßÏîßÍáãßÁîäß×éîäï÷óý"


for c in extended_flag:
    o = bin(ord(c))[2:].zfill(8)

    # Replace the first 1 with a 0
    for i in range(8):
        if o[i] == "1":
            o = o[:i] + "0" + o[i + 1 :]
            break

    flag += chr(int(o, 2))

print(flag)
$ python3 flag_gen.py
lactf{Funnily_Enough_This_Looks_Different_On_Mac_And_Windows}

フラグを獲得することができました。

フラグ

lactf{Funnily_Enough_This_Looks_Different_On_Mac_And_Windows}

Danger Searching

問題文

My friend told me that they hiked on a trail that had 4 warning signs at the trailhead: Hazardous cliff, falling rocks, flash flood, AND strong currents! Could you tell me where they went? They did hint that these signs were posted on a public hawaiian hiking trail.

Note: the intended location has all 4 signs in the same spot. It is 4 permanent distinct signs - not 4 warnings on one sign or on a whiteboard.

Note: Feel free to try multiple plus codes. The answer skews roughly one "plus code tile" south/west of where many people think it is.

Flag is the full 10 digit plus code containing the signs they are mentioning, (e.g. lactf{85633HC3+9X} would be the flag for Bruin Bear Statue at UCLA). The plus code is in the URL when you select a location, or click the ^ at the bottom of the screen next to the short plus code to get the full length one. If your plus code contains 3 digits after the plus sign, zoom out and try selecting again.

解法

Hawaii trail warningsでGoogle検索すると、早速4つの警告がある山道を見つけました。Pololū Trailというそうです。

これを基にGoogle mapとPlus codeで探すと、その標識は73G66738+9Cにあることがわかりました。

フラグ

lactf{73G66738+9C}

rev

javascryption

問題文

You wake up alone in a dark cabin, held captive by a bushy-haired man demanding you submit a "flag" to leave. Can you escape?

解法

web-home.png
フラグが正しければ通過できるようです。
ソースからcabin.jsを見つけました。

cabin.js
const msg = document.getElementById("msg");
const flagInp = document.getElementById("flag");
const checkBtn = document.getElementById("check");

function checkFlag(flag) {
    const step1 = btoa(flag);
    const step2 = step1.split("").reverse().join("");
    const step3 = step2.replaceAll("Z", "[OLD_DATA]");
    const step4 = encodeURIComponent(step3);
    const step5 = btoa(step4);
    return step5 === "JTNEJTNEUWZsSlglNUJPTERfREFUQSU1RG85MWNzeFdZMzlWZXNwbmVwSjMlNUJPTERfREFUQSU1RGY5bWI3JTVCT0xEX0RBVEElNURHZGpGR2I=";
}

checkBtn.addEventListener("click", () => {
    const flag = flagInp.value.toLowerCase();
    if (checkFlag(flag)) {
        flagInp.remove();
        checkBtn.remove();
        msg.innerText = flag;
        msg.classList.add("correct");
    } else {
        checkBtn.classList.remove("shake");
        checkBtn.offsetHeight;
        checkBtn.classList.add("shake");
    }
});

checkFlag関数はフラグの認証を担う関数だと思われます。どうやら入力されたフラグにある操作を施してから照合しているようです。

checkFlag
function checkFlag(flag) {
    const step1 = btoa(flag);
    const step2 = step1.split("").reverse().join("");
    const step3 = step2.replaceAll("Z", "[OLD_DATA]");
    const step4 = encodeURIComponent(step3);
    const step5 = btoa(step4);
    return step5 === "JTNEJTNEUWZsSlglNUJPTERfREFUQSU1RG85MWNzeFdZMzlWZXNwbmVwSjMlNUJPTERfREFUQSU1RGY5bWI3JTVCT0xEX0RBVEElNURHZGpGR2I=";
}

最後の文に操作後のフラグが確認できます。また中の関数を見ると、
Base64エンコード→文字列の順番を反転→文字列中のZ[OLD_DATA]に置換→URLエンコード→Base64エンコード
という操作が行われていることがわかります。

これと逆の操作をして、フラグを取り出していきます(ここではコンソールで実行しています)。

const flag_base = "JTNEJTNEUWZsSlglNUJPTERfREFUQSU1RG85MWNzeFdZMzlWZXNwbmVwSjMlNUJPTERfREFUQSU1RGY5bWI3JTVCT0xEX0RBVEElNURHZGpGR2I=";
const r_step5 = atob(flag_base);
const r_step4 = decodeURIComponent(r_step5);
const r_step3 = r_step4.replaceAll("[OLD_DATA]", "Z");
const r_step2 = r_step3.split("").reverse().join("");
const r_step1 = atob(r_step2)
> console.log(r_step1);
<- lactf{no_grizzly_walls_here}

フラグ

lactf{no_grizzly_walls_here}

patricks-paraflag

問題文

I was going to give you the flag, but I dropped it into my parabox, and when I pulled it back out, it got all scrambled up!

Can you recover the flag?

解法

ELFファイルが載っているので、ダウンロードし実行してみます。

$ ./patricks-paraflag      
What do you think the flag is? <testと入力>
Bad length >:(

Bad length >:(と出てしまいました。最初に文字列の長さを確認しているようです。
コードの可読部分を調査します。

$ strings patricks-paraflag
_ITM_registerTMCloneTable
PTE1
u+UH
What do you think the flag is? 
l_alcotsft{_tihne__ifnlfaign_igtoyt}
Bad length >:(
Paradoxified: %s
You got the flag wrong >:(
That's the flag! :D

l_alcotsft{_tihne__ifnlfaign_igtoyt}という文字列がフラグのようにみえますが、検証するとそれがフラグではないことが確認できます。

$ ./patricks-paraflag
What do you think the flag is? <l_alcotsft{_tihne__ifnlfaign_igtoyt}と入力>
Paradoxified: l__iaflncloftasifgtn{__itgithonyet_}
You got the flag wrong >:(

Ghidraを使って、Paradoxified:に続く文字列と入力した文字列との関係を調べてみます。
ghidra.png

main
do {
    local_208[uVar4 * 2] = local_108[uVar4];
    local_208[uVar4 * 2 + 1] = local_108[uVar4 + (sVar2 >> 1)];
    uVar4 = uVar4 + 1
} while (uVar4 < sVar2 >> 1);

特にこの部分が関係していそうです。
ここから、Paradoxified:に続く文字列は「入力された文字列を2分割し、前の部分の文字を奇数番目に、後ろの部分の文字を偶数番目に配置した文字列」だとわかります。
先ほどのl_alcotsft{_tihne__ifnlfaign_igtoyt}もフラグにそのような操作が施された文字列だと推測して、操作を逆に実行してみます。

sort.py
CHANGED = "l_alcotsft{_tihne__ifnlfaign_igtoyt}"


splited = list(CHANGED)

odd = []
even = []
for i in range(0, len(splited)):
    if i % 2 == 0:
        odd.append(splited[i])
    else:
        even.append(splited[i])

origin = ''.join(odd) + ''.join(even)
print(origin)
$ python3 sort.py
lactf{the_flag_got_lost_in_infinity}

フラグが得られました。

フラグ

lactf{the_flag_got_lost_in_infinity}

nine-solves

問題文

Let's make a promise that on that day, when we meet again, you'll take the time to tell me the flag.

You have no more unread messages from LA CTF.

解法

ELFファイルが載っていたので、とりあえずダウンロードし実行してみます。

$ ./nine-solves        
Welcome to the Tianhuo Research Center.
Please enter your access code: <testと入力>
ACCESS DENIED

stringsにかけてみましたが、有益な情報はなさそうです。

$ strings nine-solves 
/lib64/ld-linux-x86-64.so.2
hs@Y
mgUa
fgets
stdin
puts
fflush
fopen
stdout
strcspn
__libc_start_main
__cxa_finalize
printf
libc.so.6
GLIBC_2.2.5
GLIBC_2.34
_ITM_deregisterTMCloneTable
__gmon_start__
_ITM_registerTMCloneTable
PTE1
u+UH
Could not open flag.txt
ACCESS DENIED
Welcome to the Tianhuo Research Center.
Please enter your access code: 
;*3$"
GCC: (Debian 12.2.0-14) 12.2.0
Scrt1.o
__abi_tag
nine-solves.c
crtstuff.c
deregister_tm_clones
__do_global_dtors_aux
completed.0
__do_global_dtors_aux_fini_array_entry
frame_dummy
__frame_dummy_init_array_entry
__FRAME_END__
_DYNAMIC
__GNU_EH_FRAME_HDR
_GLOBAL_OFFSET_TABLE_
__libc_start_main@GLIBC_2.34
_ITM_deregisterTMCloneTable
stdout@GLIBC_2.2.5
puts@GLIBC_2.2.5
stdin@GLIBC_2.2.5
_edata
_fini
printf@GLIBC_2.2.5
strcspn@GLIBC_2.2.5
fgets@GLIBC_2.2.5
__data_start
__gmon_start__
__dso_handle
_IO_stdin_used
fflush@GLIBC_2.2.5
eigong
_end
shuanshuan
__bss_start
main
fopen@GLIBC_2.2.5
__TMC_END__
_ITM_registerTMCloneTable
__cxa_finalize@GLIBC_2.2.5
_init
.symtab
.strtab
.shstrtab
.interp
.note.gnu.property
.note.gnu.build-id
.note.ABI-tag
.gnu.hash
.dynsym
.dynstr
.gnu.version
.gnu.version_r
.rela.dyn
.rela.plt
.init
.plt.got
.text
.fini
.rodata
.eh_frame_hdr
.eh_frame
.init_array
.fini_array
.dynamic
.got.plt
.data
.bss
.comment

Ghidraでデコンパイルしてみると、以下のようなことがわかります。
ghidra.png

  • while (lVar4 != 6);から、アクセスコードは6文字だと推測できる
  • 入力された値がchar型の配列に格納され、1文字ずつuint型として再格納されている→ASCII?
  • 再格納された値をコラッツ予想に基づいて処理したときの回数とyiの値が合わなければならない

※コラッツ予想...すべての正の整数は「偶数なら2で割り、奇数なら3倍して1を足す」という操作を繰り返せば最終的に1になるという数論の未解決問題

gdbで探索できそうです。今回はmainの19行目に相当する+10faにブレークを張って調査します。

$ gdb nine-solves 
Reading symbols from nine-solves...
(No debugging symbols found in nine-solves)
(gdb) b *0x5555555550fa
Breakpoint 1 at 0x5555555550fa
(gdb) r
Starting program: /home/kali/CTF/LACTF-2025/rev/nine-solves/nine-solves 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Welcome to the Tianhuo Research Center.
Please enter your access code: <testと入力>

Breakpoint 1, 0x00005555555550fa in main ()
(gdb) x/d $rdi
0x555555558040 <yi>:    27

yiの値が27だということはわかりました。付近のアドレスも探索してみます。

(gdb) x/d $rdi + 1
0x555555558041 <yi+1>:  637534208
(gdb) x/d $rdi + 2
0x555555558042 <yi+2>:  2490368
(gdb) x/d $rdi + 3
0x555555558043 <yi+3>:  9728
(gdb) x/d $rdi + 4
0x555555558044 <yi+4>:  38

何やら関係性のありそうな値が出てきました。1進むごとにコラッツ予想を8回試行しているようです。
さらに調べてみます。

(gdb) x/d $rdi + 10
0x55555555804a <yi+10>: 6225920
(gdb) x/d $rdi + 20
0x555555558054 <yi+20>: 9
(gdb) x/d $rdi + 21
0x555555558055 <yi+21>: 0
(gdb) x/d $rdi - 5
0x55555555803b: 5592405
(gdb) x/d $rdi - 3
0x55555555803d: 452984917
(gdb) x/d $rdi - 2
0x55555555803e: 1769472
(gdb) x/d $rdi - 1
0x55555555803f: 6912
(gdb) x/d $rdi + 8
0x555555558048 <yi+8>:  87
(gdb) x/d $rdi + 12
0x55555555804c <yi+12>: 95
(gdb) x/d $rdi + 16
0x555555558050 <yi+16>: 118

関係性があるのは-2から+20まででした。

これまでの情報から、先ほどの値と処理回数が一致する数をASCIIに変換していけばアクセスコードがわかると仮定します。Pythonで「処理回数が一致する数」を列挙するコードを作り、出力された(ASCIIへ変換したときに印字可能な)最小の数をそれぞれASCIIに変換していきましょう。

korattz.py
def koratz(init):
    processed = 1
    while True:
        if init % 2 == 0:
            init = init / 2
        else:
            init = init * 3 + 1
        if init == 1:
            return processed
        processed = processed + 1


def main():
    PROCESSED = sys.argv[0]
    for i in range(33, 126):
        processed = koratz(i)
        if processed == PROCESSED:
            print(i)
            break


if __name__ == '__main__':
    main()

PROCESSEDに先ほどの値を入れて実行すると、以下のような結果になりました。

PROCESSEDの値 最小の数
27 65
38 105
87 103
95 121
118 97
9 12

これらをASCIIに変換します。

10進数 ASCII
65 A
105 i
103 g
121 y
97 a
80 P

アクセスコードがAigyaPだとわかったので、サーバーでフラグを入手します。

$ nc chall.lac.tf 32223
Welcome to the Tianhuo Research Center.
Please enter your access code: <AigyaPと入力>
lactf{the_only_valid_solution_is_BigyaP}

フラグ

lactf{the_only_valid_solution_is_BigyaP}

web

lucky-flag

問題文

Just click the flag :)

解法

web-home.png
ボタンの中に1つだけ当たりが隠されているようです。
ソースから、main.jsを見つけました。

main.js
const $ = q => document.querySelector(q);
const $a = q => document.querySelectorAll(q);

const boxes = $a('.box');
let flagbox = boxes[Math.floor(Math.random() * boxes.length)];

for (const box of boxes) {
  if (box === flagbox) {
    box.onclick = () => {
      let enc = `"\\u000e\\u0003\\u0001\\u0016\\u0004\\u0019\\u0015V\\u0011=\\u000bU=\\u000e\\u0017\\u0001\\t=R\\u0010=\\u0011\\t\\u000bSS\\u001f"`;
      for (let i = 0; i < enc.length; ++i) {
        try {
          enc = JSON.parse(enc);
        } catch (e) { }
      }
      let rw = [];
      for (const e of enc) {
        rw['\x70us\x68'](e['\x63har\x43ode\x41t'](0) ^ 0x62);
      }
      const x = rw['\x6dap'](x => String['\x66rom\x43har\x43ode'](x));
      alert(`Congrats ${x['\x6aoin']('')}`);
    };
    flagbox = null;
  } else {
    box.onclick = () => alert('no flag here');
  }
};

let flagbox = boxes[Math.floor(Math.random() * boxes.length)];とありますが、どうやらページを更新するたびに当たりのボタンが変わるようです。
そのあとにフラグを計算するコードが入っていますが、javascriptを読む力なんてないわざわざ解読していては時間がかかりそうなので、いっそのことindex.htmlを編集してボタンを1つだけにしてみます。

index.html
<!DOCTYPE html>
<!-- saved from url=(0032)https://lucky-flag.chall.lac.tf/ -->
<html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  
  <title>Lucky Flag</title>
  <link rel="stylesheet" href="./Lucky Flag_files/style.css">
  <script defer="" src="./Lucky Flag_files/main.js"></script>
</head>
<body>
  <h1>Lucky Flag</h1>
  The flag is in one of the below boxes.
  <div class="boxes">
    <button class="box">flag</button>
  </div></body></html>

web-edited.png
1つだけになったボタンを押してフラグを取得しましょう。

フラグ

lactf{w4s_i7_luck_0r_ski11}

I spy...

問題文

I spy with my little eye...

解法

stage1

web-stage1.png
トークンが表示されているので、入力して次に進みます。

stage2

web-stage2.png
HTMLソースを確認します。
web-stage2-source.png

stage3

web-stage3.png
DevToolsでコンソールを開きます。
web-stage3-console.png

stage4

web-stage4.png
style.cssを確認します。
web-stage4-stylesheet.png

stage5

web-stage5.png
ソースを確認すると、thingy.jsが見つかりました。
web-stage5-jscode.png

stage6

web-stage6.png
Burpでレスポンスを確認してみます。
web-stage6-header.png

stage7

web-stage7.png
Burpでcookieを調査します。
web-stage7-cookie.png

stage8

web-stage8.png
/robots.txtを開くと、トークンのURLが見つかります。
web-stage8-robots.png
/a-magical-token.txtを開きます。
web-stage8-token.png

stage9

web-stage9.png
ページ構成を記述した/sitemap.xmlというファイルがあるようです。
web-stage9-sitemap.png

stage10

web-stage10.png
Burpで捕捉したリクエストをRepeaterでDELETEリクエストに改変して送信します。
web-stage10-delete.png

stage11

web-stage11.png
ローカルでnslookupを実行しTXTレコードを調査します。

$ nslookup 
> set type=txt
> i-spy.chall.lac.tf
Server:         xx.xx.xx.xx
Address:        xx.xx.xx.xx#xx

Non-authoritative answer:
i-spy.chall.lac.tf      text = "Token: 7227E8A26FC305B891065FE0A1D4B7D4"

Authoritative answers can be found from:

フラグ

web-flag.png

全11問解くと、フラグが表示されます。

lactf{1_sp0773d_z_t0k3ns_4v3rywh3r3}

最後に

今回は1ヶ月後に控えているpicoCTFへの対策の一環として参戦してみましたが、やはり知識不足が目立っていたり、また時間配分も下手だったという印象を強く感じました。
個人的にかなり早く解けたのはmisc/Danger Searching(OSINT)だと思います。159人しか解けていない段階でかつ5分程度で解けたのが、その後の順位に大きく影響してきたのでしょう。

今回で得意分野・苦手分野などがある程度はっきりしたので、これからはpicoCTF 2025に向けて実力(特に得意分野)を伸ばしていこうと思います。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?