N0PSctf 2025

N0PSctf Writeup

まずはこちらをご覧ください.↓

horik.hatenablog.com

HoriK氏からの命を受けましてWriteupを書いていきたいと思います.

BabyTopia

The emperor

Ahoye! Here are the crypto newbies! Today, we are learning the basics of cryptography! Here is an encrypted message for you, try to decipher it. Learning this will help you on the day you will face CrypTopia.

Note: The flag format is B4BY{DECODEDMESSAGE}.

与えられたファイルの中を見ると変な文字列が書いていた.

$ cat challenge.txt
Ea, kag pqoapqp uf tgt?
Ftqz, tqdq ue ftq rxms:
UVGEFPQOAPQPMOMQEMDOUBTQDITUOTYMWQEYQMBDARQEEUAZMXODKBFATQDA

シーザー暗号と見立てCyberChefで変換してみる.

ROT14で見えましたね.ということで,

Flag : B4BY{IJUSTDECODEDACAESARCIPHERWHICHMAKESMEAPROFESSIONALCRYPTOHERO}

Unknown File

Hello young trainees! Today, we are studying digital forensics! This may be useful if one day you have to face PwnTopia...

Here is a file, you have to find a way to read its content. Good luck!

まず,拡張子のないファイルが渡されますので,fileコマンドでファイルの種類を確認します.

$ file challenge
challenge: PDF document, version 1.6, 1 pages (zip deflate encoded

すると,pdfファイルということが分かったのでmvコマンドで拡張子を付けpdfファイルを開きます.

$ mv challenge challenge.pdf

Flag : B4BY{h1dD3n_PDF!}

Read the Bytes!

Look who's there! New students! Fine, this time we will focus on reverse engineering. This could help you against PwnTopia one day!

I give you now a Python program and its output. Try to understand how it works!

provide code

from flag import flag

# flag = b"XXXXXXXXXX"

for char in flag:
    print(char)

# 66
# 52
# 66
# 89
# 123
# 52
# 95
# 67
# 104
# 52
# 114
# 97
# 67
# 55
# 51
# 114
# 95
# 49
# 115
# 95
# 74
# 117
# 53
# 116
# 95
# 52
# 95
# 110
# 85
# 109
# 56
# 51
# 114
# 33
# 125

125がmaxで,おそらくASCIIに変換するのだろう.
ということで,コード書きます.

# soloved.py
ascii_codes = [
    66, 52, 66, 89, 123, 52, 95, 67, 104, 52, 114, 97, 67, 55, 51, 114,
    95, 49, 115, 95, 74, 117, 53, 116, 95, 52, 95, 110, 85, 109, 56, 51,
    114, 33, 125
]

characters = []

for code in ascii_codes:
    character = chr(code)
    characters.append(character)

flag = ''.join(characters)

print(flag)
$ python3 solved.py
B4BY{4_Ch4raC73r_1s_Ju5t_4_nUm83r!}

Falg : B4BY{4_Ch4raC73r_1s_Ju5t_4_nUm83r!}

Tak Tak

Aloha querid@s! ^^

It's finally time to dive into the fascinating world of OSINT, that magical word that excites digital detectives and gives regular folks the chills. Today, we’ll be starting with the baaasic stuff: just a bit of reverse image searching to warm up!

Now, story time: Alice has stumbled into N0PStopia and found herself in a surreal place, lined entirely with chairs. 🪑

Will you be able to figure out where this tunnel is, when it opened, and, just for fun, how many chairs are in there? It seemed like it stretches into infinity.. x)

Flag format : B4BY{Place-Location_Opening-Date_chairs-number}

Example : B4BY{Lectures-Tower-N0PSTopia_June-1st-2025_505}

Note : Make sure all the parts of your flag (even country names) are in English.

Google Lensに突っ込むと,「Designmuseum Denmark」というところにヒットする.
そこからいろいろ探していくと以下のサイトからflagに必要な情報が得られる.

www.wonderfulcopenhagen.com

Flag : B4BY{Designmuseum-Danmark_June-7th-2024_125}

N0PStopia

G-Bee-S

There is a bee living in N0PStopia, named Valentine. She loves waterlilies, and lots of them are located around her beehive. However, she needs some help to find a good way to visit all of them while tralling the shortest distance, can you help her? She does not need the best path, but at least one that is good enough.

Note: Each line is the coordinates of a waterlily. The beehive is located in (0,0), and the path has to start from there and end there. Each flower has to be visited one time, but not more. When submitting, you have to send the order of flowers to be visited. For instance, if your path is to visit the 2nd flower, then the 3rd, then the 1st, you will send 0 2 3 1 0, where 0 represents the beehive. In order to be valid, your path has to have a total length of less than 1400.

Author: algorab

nc 0.cloud.chals.io 13055

provide text file

-62 -67
-8 44
44 17
-91 -74
-56 18
96 -19
45 -67
-28 62
94 69
48 52
-11 64
-95 -57
-2 79
34 40
-5 24
-35 -50
-40 72
-25 -4
-75 -98
6 98
-87 -37
-63 99
-96 86
28 65
-87 26
53 -2
-98 7
69 -71
18 41
-84 51
-80 -10
50 39
13 -89
4 35
31 95
84 -50
86 -82
32 -21
-36 -22
34 -77
-77 -78
-92 -2
72 -54
88 -29
1 -14
-82 97
-16 -70
-19 96
-41 41
-24 -87

  • 問題の概要

    • 原点 (0, 0) を含む座標リストが与えられます.
    • これらの全てを1回ずつ訪問し,出発地点に戻るという巡回セールスマン問題(TSP)を解く必要があります.
    • 経路の長さが1400未満であれば,フラグが与えられます.
  • 解法 TSPを解くにはいろいろありますが,今回はgreedyと2-optを使って解きました.
    以下,solverです.

# solved.py
import math
import pexpect

coordinates = [
    (-62, -67), (-8, 44), (44, 17), (-91, -74), (-56, 18), (96, -19), (45, -67),
    (-28, 62), (94, 69), (48, 52), (-11, 64), (-95, -57), (-2, 79), (34, 40),
    (-5, 24), (-35, -50), (-40, 72), (-25, -4), (-75, -98), (6, 98), (-87, -37),
    (-63, 99), (-96, 86), (28, 65), (-87, 26), (53, -2), (-98, 7), (69, -71),
    (18, 41), (-84, 51), (-80, -10), (50, 39), (13, -89), (4, 35), (31, 95),
    (84, -50), (86, -82), (32, -21), (-36, -22), (34, -77), (-77, -78), (-92, -2),
    (72, -54), (88, -29), (1, -14), (-82, 97), (-16, -70), (-19, 96), (-41, 41),
    (-24, -87)
]

points = [(0, 0)] + coordinates

def dist(p1, p2):
    return math.hypot(p1[0] - p2[0], p1[1] - p2[1])

def greedy_tsp(points):
    n = len(points)
    unvisited = list(range(1, n))
    path = [0]
    while unvisited:
        last = path[-1]
        next_city = min(unvisited, key=lambda i: dist(points[last], points[i]))
        path.append(next_city)
        unvisited.remove(next_city)
    path.append(0)
    return path

def total_length(path):
    return sum(dist(points[path[i]], points[path[i+1]]) for i in range(len(path)-1))

def two_opt(path, points):
    best = path[:]
    improved = True
    while improved:
        improved = False
        for i in range(1, len(path) - 2):
            for j in range(i + 1, len(path) - 1):
                if j - i == 1:
                    continue
                new_path = path[:i] + path[i:j][::-1] + path[j:]
                if total_length(new_path) < total_length(best):
                    best = new_path
                    improved = True
        path = best
    return best

path = greedy_tsp(points)
path = two_opt(path, points)
length = total_length(path)

print("Path:", path)
print("Total length:", length)

if length < 1400:
    answer = " ".join(map(str, path))
    conn = pexpect.spawn("nc 0.cloud.chals.io 13055", timeout=10)
    conn.expect("Enter the path Valentine should follow:")
    conn.sendline(answer)
    conn.interact()
else:
    print("Total length exceeds 1400")
$ python3 solved.py
Path: [0, 45, 18, 39, 16, 47, 50, 1, 41, 19, 4, 12, 21, 31, 42, 27, 25, 5, 49, 17, 8, 11, 2, 15, 34, 29, 14, 10, 32, 3, 26, 38, 33, 40, 7, 28, 37, 43, 36, 44, 6, 9, 24, 35, 20, 13, 48, 22, 46, 23, 30, 0]
Total length: 1377.6346187253687

>>> 0 45 18 39 16 47 50 1 41 19 4 12 21 31 42 27 25 5 49 17 8 11 2 15 34 29 14 10 32 3 26 38 33 40 7 28 37 43 36 44 6 9 24 35 20 13 48 22 46 23 30 0
The length of your path is 1377.6346187253687.
Well done! Thanks to you, Valentine can visit all the waterlillies <3
Here is your flag: N0PS{w4t3rl1ll13s_f0r_v4l3nt1n3}

Flag : N0PS{w4t3rl1ll13s_f0r_v4l3nt1n3}

Musical Encounter

n00psy takes you to a nature walk in N0PStopia. On your way, you meet a group of dancing skeletons! What an amazing adventure to see and hear. I wonder what they talk about...

与えられた動画には何もなかったので音声を見てみる.
そのためにはwav形式の音声ファイルにしないといけないので,ffmpegで音声を抽出する.

ffmpeg -i dancing_skeletons.mp4 -vn -acodec pcm_s16le -ar 44100 -ac 1 audio.wav

このaudio.wavwindowsのaudaciryで見てみる.

波形では何も見えないのでスペクトログラムで見てみるとFlagが見えます.

Flag : N0PS{c4rT5s0N8z}

CrypTopia

Break My stream

CrypTopia is testing their next gen encryption algorithm. We believe that the way they implemented it may have a flaw...

Author: algorab

nc 0.cloud.chals.io 31561

provide code

import os

class CrypTopiaSC:

    @staticmethod
    def KSA(key, n):
        S = list(range(n))
        j = 0
        for i in range(n):
            j = ((j + S[i] + key[i % len(key)]) >> 4 | (j - S[i] + key[i % len(key)]) << 4) & (n-1)
            S[i], S[j] = S[j], S[i]
        return S

    @staticmethod
    def PRGA(S, n):
        i = 0
        j = 0
        while True:
            i = (i+1) & (n-1)
            j = (j+S[i]) & (n-1)
            S[i], S[j] = S[j], S[i]
            yield S[((S[i] + S[j]) >> 4 | (S[i] - S[j]) << 4) & (n-1)]

    def __init__(self, key, n=256):
        self.KeyGenerator = self.PRGA(self.KSA(key, n), n)

    def encrypt(self, message):
        return bytes([char ^ next(self.KeyGenerator) for char in message])

def main():
    flag = b"XXX"
    key = os.urandom(256)
    encrypted_flag = CrypTopiaSC(key).encrypt(flag)
    print("Welcome to our first version of CrypTopia Stream Cipher!\nYou can here encrypt any message you want.")
    print(f"Oh, one last thing: {encrypted_flag.hex()}")
    while True:
        pt = input("Enter your message: ").encode()
        ct = CrypTopiaSC(key).encrypt(pt)
        print(ct.hex())

if __name__ == "__main__":
    main()

  • 問題概要

    • サーバはランダムな鍵でフラグを暗号化し,平文を入力すると暗号文を返してくれる.
    • 鍵 (key) は内部的にランダム生成され,KSA/PRGAでキーストリームが生成される.
    • 入力したメッセージに対して同じキーストリームで暗号化が行われる.
  • 脆弱性はどこか

    • この実装の最大の問題点は,一度生成されたキーストリームを繰り返し使っていることである.つまり,フラグの暗号化にも,ユーザが入力した任意の平文の暗号化にも同じキーストリームが使われているため,次の式が成り立つ
      ciphertext = plaintext ^ keystream

攻撃方法
1. サーバから encrypted_flag を受け取る.
2. 同じ長さの "A" * len(flag) を送って暗号文を取得.
3. "A" = 0x41 なので,暗号文と0x41をXORすればキーストリームが手に入る.
4. そのキーストリームを使って,encrypted_flag を復号する.

# solved.py
from pwn import remote

r = remote('0.cloud.chals.io', 31561)
r.recvuntil(b"Oh, one last thing: ")
enc_flag = bytes.fromhex(r.recvline().strip().decode())

# 入力プロンプトの待ち受け
r.recvuntil(b"Enter your message: ")
r.sendline(b"A" * len(enc_flag))

# 受信行を取得して暗号文に変換
cipher_hex = r.recvline().strip()
try:
    cipher = bytes.fromhex(cipher_hex.decode())
except ValueError:
    print(f"[!] Unexpected server response: {cipher_hex}")
    exit(1)

# keystreamを復元
keystream = bytes([c ^ 0x41 for c in cipher])

# flagを復元
flag = bytes([enc_flag[i] ^ keystream[i] for i in range(len(enc_flag))])
print(f"Flag: {flag.decode()}")
$ python3 solved.py
[+] Opening connection to 0.cloud.chals.io on port 31561: Done
Flag: N0PS{u5u4L_M1sT4k3S...}
[*] Closed connection to 0.cloud.chals.io port 31561

Flag : N0PS{u5u4L_M1sT4k3S...}

WebTopia

Press Me If U Can

-"Stop chasing me!" said the button.

-"We can't. It's our job." they answered.

Trapped in WebTopia on a page with no way out, except for the fleeing button. Can you break free?

flagをgetするにはPress Meと書いているボタンを押す必要がある.
しかし,マウスカーソルを近づけるとPress Meが離れていくので押すことができない.

そこで,このボタンを動かないようにして押せるようにする.

script.js

const btn = document.querySelector("button");
const OFFSET = 100;

const testEdge = function (property, axis) {
if (endPoint[property] <= 0) {
    endPoint[property] = axis - OFFSET;
} else if (endPoint[property] >= axis) {
    endPoint[property] = OFFSET;
}
};

let endPoint = { x: innerWidth / 2, y: innerHeight * 2 / 3 };

addEventListener("mousemove", (e) => {
const btnRect = btn.getBoundingClientRect();

const angle = Math.atan2(e.y - endPoint.y, e.x - endPoint.x);

const distance = Math.sqrt(
    Math.pow(e.x - endPoint.x, 2) + Math.pow(e.y - endPoint.y, 2)
);

if (distance <= OFFSET) {
    endPoint = {
    x: OFFSET * -Math.cos(angle) + e.x,
    y: OFFSET * -Math.sin(angle) + e.y
    };
}

btn.style.left = endPoint.x + "px";
btn.style.top = endPoint.y + "px";

btn.disabled = true;

testEdge("x", innerWidth);
testEdge("y", innerHeight);
});



// Select all pupils
const pupils = document.querySelectorAll('.pupil');

// Add an event listener for mouse movement
document.addEventListener('mousemove', (event) => {
    const { clientX: mouseX, clientY: mouseY } = event;

    // Adjust each pupil position
    pupils.forEach((pupil) => {
        const eye = pupil.parentElement;

        // Get the bounding box of the eye
        const { left, top, width, height } = eye.getBoundingClientRect();

        // Calculate the center of the eye
        const eyeCenterX = left + width / 2;
        const eyeCenterY = top + height / 2;

        // Calculate the offset for the pupil based on the eye center
        const dx = mouseX - eyeCenterX;
        const dy = mouseY - eyeCenterY;

        // Normalize the movement within a range
        const maxOffsetX = width * 0.25; // Adjust range for horizontal movement
        const maxOffsetY = height * 0.25; // Adjust range for vertical movement

        const offsetX = Math.max(-maxOffsetX, Math.min(maxOffsetX, dx * 0.1));
        const offsetY = Math.max(-maxOffsetY, Math.min(maxOffsetY, dy * 0.1));

        // Set the pupil position
        pupil.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
    });
});

ボタンが逃げる動作はbtnに対する操作なので,btnが参照しているDOMノードを置き換えることで,スクリプト側のbtnは「過去の要素」を見続ける.
つまり,ボタンを外見そのままに差し替えればスクリプトの制御から外れる.

Google Chrome のDev ToolsにあるConsoleで以下のスクリプトを書く.

let old_element = document.querySelector("button");       // 元のボタンを取得
let new_element = old_element.cloneNode(true);            // ボタンを複製
old_element.parentNode.replaceChild(new_element, old_element); // DOM上で入れ替え

こうするとボタンが止まり,押すことができる.
押したらflagが表示される.

Flag : N0PS{W3l1_i7_w4S_Ju5T_F0r_Fun}