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_1
、ct_2
、n
の値を上10桁のみに省略しています)
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暗号の鍵生成・暗号化コードのようです。
同じ平文pt
をn
が共通かつ互いに素な2つのe
で暗号化された文が確認できます。
Common Modulus Attackができそうです。
理論と実装の両方をまとめてくださっている記事(以下リンク)を見つけたので、実装を参考にコードを作っていきます。
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.py
とchall.txt
が添付されているので、ダウンロードし中身を確認してみます(chall.txtはISO 8859-1でエンコードします)。
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"))
ìáãôæûÆõîîéìùßÅîïõçèßÔèéóßÌïïëóßÄéææåòåîôßÏîßÍáãßÁîäß×éîäï÷óý
gen.py
は、「flag
から1文字(1バイト)ずつ取りバイナリ上の先頭の0を1に変える」コードのようです。
chall.txt
をISO 8859-1でエンコードしたextended_flag
に書き換えるコードも確認できます。
元のchall.txt
はフラグを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?
解法
フラグが正しければ通過できるようです。
ソースから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
関数はフラグの認証を担う関数だと思われます。どうやら入力されたフラグにある操作を施してから照合しているようです。
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:
に続く文字列と入力した文字列との関係を調べてみます。
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}
もフラグにそのような操作が施された文字列だと推測して、操作を逆に実行してみます。
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でデコンパイルしてみると、以下のようなことがわかります。
-
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に変換していきましょう。
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 :)
解法
ボタンの中に1つだけ当たりが隠されているようです。
ソースから、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つだけにしてみます。
<!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>
フラグ
lactf{w4s_i7_luck_0r_ski11}
I spy...
問題文
I spy with my little eye...
解法
stage1
stage2
stage3
stage4
stage5
stage6
stage7
stage8
/robots.txt
を開くと、トークンのURLが見つかります。
/a-magical-token.txt
を開きます。
stage9
ページ構成を記述した/sitemap.xml
というファイルがあるようです。
stage10
Burpで捕捉したリクエストをRepeaterでDELETEリクエストに改変して送信します。
stage11
ローカルで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:
フラグ
全11問解くと、フラグが表示されます。
lactf{1_sp0773d_z_t0k3ns_4v3rywh3r3}
最後に
今回は1ヶ月後に控えているpicoCTFへの対策の一環として参戦してみましたが、やはり知識不足が目立っていたり、また時間配分も下手だったという印象を強く感じました。
個人的にかなり早く解けたのはmisc/Danger Searching
(OSINT)だと思います。159人しか解けていない段階でかつ5分程度で解けたのが、その後の順位に大きく影響してきたのでしょう。
今回で得意分野・苦手分野などがある程度はっきりしたので、これからはpicoCTF 2025に向けて実力(特に得意分野)を伸ばしていこうと思います。