Swizzer's Sound

Back

2025 H^3CTF 出题人 WriteupBlur image

很早就幻想过一校三区搞个CTF联赛,没想到在本部Lilac的号召下,H^3CTF真的办了起来。

作为出题人给第一届H3CTF贡献了6个题目,包括3个Crypto、2个Misc和1个比较Misc的Reverse。

剽窃了一些国际赛的题目,Swizzer在此表示忏悔

Crypto#

isoDream#

from sage.all import GF, EllipticCurve, polygen, PolynomialRing, Zmod
from Crypto.Util.number import getPrime, isPrime, bytes_to_long
import random


def getSafePrime(bits: int) -> int:
  while True:
    p = getPrime(bits - 1)
    q = 2 * p + 1
    if isPrime(q):
      return q


def encrypt(m: int, p: int, exponents: list[int]):
  modulus = p**3
  y = PolynomialRing(Zmod(modulus), "x").quotient("x**3 + x + 1", "y").gen()
  g = 13 * y + 37
  powers = [pow(m, e, modulus) for e in exponents]
  return [g**e for e in powers]


def transform(secret: int):
  while True:
    try:
      p = getSafePrime(384)
      x = polygen(GF(p))
      F = GF(p**2, "i", modulus=x**2 + 1)
      j = F(secret)
      E = EllipticCurve(j=j)
      kerl = E(0).division_points(5)[1:]
      E2 = E.isogeny(random.choice(kerl)).codomain()
      return p, E2.j_invariant()
    except Exception as _:
      continue


if __name__ == "__main__":
  m = bytes_to_long(open("flag.txt", "rb").read().strip())
  p_m, (re, im) = transform(m)
  p = getSafePrime(256)
  pt = int(re + im)

  # fmt:off
  exps = [(getPrime(384) - 1) * p for _ in range(8)] + [getPrime(384) * (p - 1) for _ in range(8)]
  # fmt: on
  ct = encrypt(pt, p, exps)
  print(p_m)
  print(exps)
  print(ct)
python

836e197497a9e1c0bed7155bb427043b.png 题目首先在扩域上生成一条j不变量为flag的曲线,然后把这个曲线的一个5-isogeny的邻居的j不变量的实部和虚部的和作为plaintext,最后生成一组指数,在 Zmod(p3)Zmod(p^3)上对plaintext做 形如gpteg^{{pt}^{e}}的加密。

这里多项式商环上的DLP在计算时可以通过对多项式取companion matrix再取行列式转化到环Zmod(p3)Zmod(p^3)上的DLP,也可以直接取范数转化为环Zmod(p3)Zmod(p^3)上的DLP。 虽然解决Zmod(p3)Zmod(p^3)上的DLP并不容易,但是经过p-adic处理后只需求模逆即可得到Zmod(p2)Zmod(p^2)上的DLP,所以我们可以拿到一组pte(modp2)pt^{e} \pmod{p^2}。因为这组e的最大公因子是2,所以由bezout定理可以知道存在一组系数aia_{i}使得 aiei=2\sum{a_i}{e_i}=2。可以用LLL或者exgcd找到这样的一组系数,之后就能得到 pt2(modp2)pt^2 \pmod{p^2} 接下来使用hensel lifting或是其他手段求解出ptpt,即可得到j不变量的实部和虚部的和。

注意到题目中的起始曲线经过一次5-isogeny后得到的曲线的j不变量极大概率还是实数,所以我们可以猜测我们得到的和就是j不变量的实部,而虚部则为0。这一点也可以通过本地测试验证。

两条曲线的同源可以由modular polynomial联系起来,也就是说,如果知道了一个j不变量,那么可以将其代入对应度的modular polynomial去求根,得到的所有根就是所有作为他的邻居的j不变量。本题中是5-isogeny,可以去 https://math.mit.edu/~drew/modpolys/jfiles/phi_j_5.txt 找到对应的modular polynomial,随后对该多项式求根即可得到起始曲线的j不变量,也就是flag。

from sage.all import *
from Crypto.Util.number import GCD, isPrime, long_to_bytes
import ast


# https://pypi.org/project/sageball/
def hensel_solve(f, p, r):
  """
  Solves polynomial roots in the ring Zmod(p**r) using Hensel's lifting method.

  Parameters:
  f (polynomial): The polynomial equation.
  p (int): A prime number.
  r (int): The exponent.

  Raises:
  ValueError: If p is not a prime number or if f has no roots.
  """
  if not is_prime(p):
    raise ValueError("p must be a prime")
  f = f.change_ring(Zp(p))
  F = f.change_ring(Zmod(pow(p, r)))
  P = Zp(p, max(30, r))
  Fd = derivative(F)
  origin_roots = f.roots()
  if not len(origin_roots):
    raise ValueError("f has no roots")
  ans = set()
  for x in origin_roots:
    x_k = ZZ(x[0])
    flag = 0
    for k in range(1, r):
      if Fd(x_k) == P(0):
        if Zmod(pow(p, r))(f(x_k)) == 0:
          continue
        else:
          flag = 1
          break
      else:
        x_k = Zmod(pow(p, r))(P(x_k) - P(F(x_k)) / P(Fd(x_k)))
    if not flag:
      ans.update({x_k})
  return list(ans)


def p_adic_dlp(g, y, p, e):
  R = Zp(p, prec=e)
  x = (R(y).log() / R(g).log()).lift()
  return x


def do_dlog(g, y, p, e, C_f):
  g_ = g.lift().substitute(x=C_f).det()
  y_ = y.lift().substitute(x=C_f).det()
  return p_adic_dlp(g_, y_, p, e)


p_m = eval(open("output.txt").readlines()[0])
exps = eval(open("output.txt").readlines()[1])

p = GCD(*exps[:8]) // 2
assert isPrime(p)
modulus = p**3
PR = PolynomialRing(Zmod(p**3), "x")
x = PR.gen()
f = x**3 + x + 1
y = PR.quotient(f, "y").gen()
ct = eval(open("output.txt").readlines()[-1])
g = 13 * y + 37
C_f = companion_matrix(f)
# replace "^" with "**" before solving
res = []
for y in ct:
  res.append(do_dlog(g, y, p, 3, C_f))
# print(res)
M = identity_matrix(len(res)).augment(vector(exps))
K = 2**128
M[:, -1:] *= K
L = M.LLL()
coeffs = []
for row in L:
  if abs(row[-1] // K) == 2:
    coeffs = row[:-1]
    # print(row)
    break
m = 1
for i in range(len(coeffs)):
  m *= pow(res[i], coeffs[i], p**2)
  m %= p**2
# print(m)

PR2 = PolynomialRing(Zmod(p**2), "z")
z = PR2.gen()
h = z**2 - m
ans = hensel_solve(h, p, 2)
print(ans)
mp5_def_str = """[0,0] 141359947154721358697753474691071362751004672000
[1,0] 53274330803424425450420160273356509151232000
[1,1] -264073457076620596259715790247978782949376
[2,0] 6692500042627997708487149415015068467200
[2,1] 36554736583949629295706472332656640000
[2,2] 5110941777552418083110765199360000
[3,0] 280244777828439527804321565297868800
[3,1] -192457934618928299655108231168000
[3,2] 26898488858380731577417728000
[3,3] -441206965512914835246100
[4,0] 1284733132841424456253440
[4,1] 128541798906828816384000
[4,2] 383083609779811215375
[4,3] 107878928185336800
[4,4] 1665999364600
[5,0] 1963211489280
[5,1] -246683410950
[5,2] 2028551200
[5,3] -4550940
[5,4] 3720
[5,5] -1
[6,0] 1"""
mp5_def = [
  [ast.literal_eval(x) for x in line.split(" ")] for line in mp5_def_str.split("\n")
]
mp_term = (
  lambda e, coef: lambda x, y: coef * x ** e[0] * y ** e[1]
  + coef * x ** e[1] * y ** e[0]
  if e[0] != e[1]
  else coef * x ** e[0] * y ** e[1]
)
mp = lambda mp_def: lambda x, y: sum([mp_term(*term)(x, y) for term in mp_def])
mp5 = mp(mp5_def)
x = var("x")
Fpm = GF(p_m)
Fpm2 = GF(p_m**2, "i", modulus=x**2 + 1)
i = Fpm2.gen()
aa, cc = Fpm2["aa, cc"].gens()
PR_Fpm = Fpm["aa, cc"]
f = mp5(aa + 0 * i, Fpm(int(ans[1])) + 0 * i)
f_real = PR_Fpm(f.map_coefficients(lambda c: c.polynomial()[0])).univariate_polynomial()
for res, _ in f_real.roots():
  if long_to_bytes(int(res)).startswith(b"H3CTF"):
    print(long_to_bytes(int(res)))
    break
python

这题的裁剪版扔给了QnQSec CTF用,一题两吃了属于是 所以我的劳务费在哪里

SSS???#

包含3个关卡的题目,都是给出多项式的根然后要求恢复一些系数,类似Shamir Secret Sharing,所以叫SSS???

打算以后复用类似的想法所以就不放出题目代码了,私密马赛喵🙌🏻

Lost Linear Logic#

from sage.all import random_matrix, vector, GF, ZZ
from Crypto.Util.number import getPrime, bytes_to_long


flag = bytes_to_long(open("flag.txt", "rb").read().strip())
n = 35
m = flag.bit_length()
p = getPrime(256)
F = GF(p)

A = random_matrix(GF(3), m, n).change_ring(F)
A[:, 0] = vector(ZZ, list(map(int, bin(flag)[2:])))
x = random_matrix(F, n, 1)
res = A * x

print(p)
print(list(res.transpose()[0]))
python

抄袭的zer0pts CTF 2022-Karen,只改了改矩阵转置和随机矩阵的大小。 完全是原版HSSP,直接打正交格就行。

具体来说,我们知道Ax(modp)A*x\pmod{p},所以如果我们找到MM满足MAx=xTATMT0(modp)MA\vec{x}=\vec{x}^TA^TM^T\equiv{0}\pmod{p},那么ATA^T就在MTM^T的左核空间中; 而ATA^T的向量又都很短,所以对MTM^T的左核空间作格规约就可以期望找到这些短向量,从而恢复出flag。

而在modp\bmod{p}下找到这样的MM的方法也是格,构造方案请参考solve.py

from sage.all import *
from Crypto.Util.number import long_to_bytes
# fmt: off
p = 
res = 
# fmt: on
m = len(res)
n = 35

B = identity_matrix(m).augment(matrix(ZZ, res).transpose()).stack(vector([0] * m + [p]))
ortho = B.LLL()
res = ortho[: m - n, :m]
L = res.transpose().left_kernel_matrix()
print(L.nrows(), L.ncols())
ans = L.LLL()
flag_bits = "".join(str(abs(x)) for x in ans[0])
flag = long_to_bytes(int(flag_bits, 2))
print(flag)
python

Misc#

铠冢霙检测器#

from __future__ import annotations
import os
import secrets
from flask import Flask, request, render_template_string
from werkzeug.utils import secure_filename
from PIL import Image
import torch
import torchvision.transforms as T
from predict import predict_image_with_saved_model

ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg"}
UPLOAD_FOLDER = "./uploads"
FIXED_IMAGE_PATH = "./test_dir/test.png"
FLAG = os.environ.get("FLAG", "H3CTF{test_flag}")
L2_MAX = float("0.5")
MAX_CONTENT_LENGTH = 2 * 1024 * 1024
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)
app = Flask(__name__)
app.config.update(
  UPLOAD_FOLDER=UPLOAD_FOLDER,
  MAX_CONTENT_LENGTH=MAX_CONTENT_LENGTH,
  SECRET_KEY=os.environ.get("SECRET_KEY", secrets.token_hex(16)),
)

os.makedirs(UPLOAD_FOLDER, exist_ok=True)


def allowed_file(filename: str) -> bool:
  return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS


def safe_open_image(path: str) -> Image.Image:
  """Strict image open: verify then reopen in RGB to thwart trivial polyglots."""
  with Image.open(path) as im:
    im.verify()
  im = Image.open(path).convert("RGB")
  return im


def l2_between_images(
  img1: Image.Image, img2: Image.Image, img_size: int = 288
) -> float:
  tfm = T.Compose(
    [
      T.Resize((img_size, img_size)),
      T.ToTensor(),
    ]
  )
  x1 = tfm(img1).view(-1)
  x2 = tfm(img2).view(-1)
  return torch.norm(x1 - x2, p=2).item()


TEMPLATE = """
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Anime Check · CTF</title>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>
  <body class="min-h-screen bg-gray-50 text-gray-800">
    <div class="mx-auto max-w-xl px-4 py-12">
      <div class="text-center mb-8">
        <h1 class="text-3xl font-semibold tracking-tight">Anime Check</h1>
        <p class="text-sm text-gray-500 mt-2">Is this Mizore?</p>
      </div>

      <div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
        <form class="space-y-4" action="/" method="post" enctype="multipart/form-data">
          <div>
            <label for="file" class="block text-sm font-medium text-gray-700">选择图片(.png /.jpg)</label>
            <input id="file" name="file" type="file" accept=".png,.jpg,.jpeg,image/png,image/jpeg"
                   class="mt-2 block w-full rounded-xl border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white" required />
          </div>
          <button type="submit" class="w-full rounded-xl bg-black text-white py-2.5 text-sm font-medium hover:opacity-90">上传鉴定</button>
        </form>
      </div>

      {% if error %}
        <div class="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-800">{{ error }}</div>
      {% endif %}

      {% if checked %}
        {% if success %}
          <div class="mt-6 rounded-xl bg-emerald-50 border border-emerald-200 p-4">
            <div class="text-sm text-emerald-800">She is surely NOT Mizore!</div>
            <div class="mt-1 font-mono text-emerald-900 text-base select-all">{{ flag }}</div>
          </div>
        {% else %}
          <div class="mt-6 rounded-xl bg-amber-50 border border-amber-200 p-4 text-sm text-amber-800">
            Not so good...
            <div class="mt-1 font-mono text-emerald-900 text-base select-all">L2 norm = {{ l2 }}</div>
          </div>
        {% endif %}
      {% endif %}

      <footer class="mt-10 text-center text-xs text-gray-400">© Kitauji, FIGHT!</footer>
    </div>
  </body>
</html>
"""


@app.route("/", methods=["GET", "POST"])
def index():
  if not os.path.exists(FIXED_IMAGE_PATH):
    return render_template_string(
      TEMPLATE,
      error="服务器配置错误:后端参考图不存在。",
      checked=False,
    ), 503

  if request.method == "GET":
    return render_template_string(TEMPLATE, checked=False)

  file = request.files.get("file")
  if file is None or file.filename == "":
    return render_template_string(TEMPLATE, error="未选择文件。", checked=False), 400

  if not allowed_file(file.filename):
    return render_template_string(
      TEMPLATE, error="仅支持 .png / .jpg。", checked=False
    ), 400

  ext = file.filename.rsplit(".", 1)[1].lower()
  fname = f"{secrets.token_hex(8)}.{ext}"
  save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(fname))

  try:
    file.save(save_path)
  except Exception:
    return render_template_string(TEMPLATE, error="保存上传失败。", checked=False), 500

  try:
    user_img = safe_open_image(save_path)
  except Exception:
    try:
      os.remove(save_path)
    except Exception:
      pass
    return render_template_string(
      TEMPLATE, error="文件不是有效图片。", checked=False
    ), 400

  try:
    fixed_img = safe_open_image(FIXED_IMAGE_PATH)
  except Exception:
    try:
      os.remove(save_path)
    except Exception:
      pass
    return render_template_string(
      TEMPLATE, error="服务器图像读取失败。", checked=False
    ), 503
  try:
    l2 = l2_between_images(user_img, fixed_img, img_size=288)
  except Exception:
    try:
      os.remove(save_path)
    except Exception:
      pass
    return render_template_string(TEMPLATE, error="图像对比失败。", checked=False), 500
  try:
    is_target = predict_image_with_saved_model(save_path)[0]
  except Exception:
    is_target = False

  success = ((l2 <= L2_MAX) and (not is_target))
  try:
    os.remove(save_path)
  except Exception:
    pass

  if success:
    return render_template_string(
      TEMPLATE,
      checked=True,
      success=True,
      flag=FLAG,
    )
  else:
    return render_template_string(
      TEMPLATE,
      checked=True,
      success=False,
      l2=f"{l2:.6f}",
    )


if __name__ == "__main__":
  app.run(host="0.0.0.0", port=1337, debug=False)
python

c97b13e365d02959d782346e99ebb85c.png

这题的思想来自smileyCTF 2025-Project Holoswap,也就是在l2 norm上执行PGD Attack。 不过比起Project Holoswap需要让模型误分类到指定类别,本题只需要让模型判定为非目标类别即可,所以难度会稍微低一点。 在PGD Attack面前似乎也没低多少

为了避免出现去年Lilac 405杯那种“手动修改几个像素就能通过模型验证”的情况,这次基于EfficientNetV2架构训练了一个稍大点的模型(这也导致这题因为太吃资源而无法部署到平台,不得不在@Cherrling和我的机子上各部署了一份以减轻后端压力), 并且把l2 norm的阈值调得比较严格。不过只要肯稍微调调步长+写个早停策略,还是能比较容易地打通的。

训练用数据集来自CyberHarem/yoroizuka_mizore_soundeuphonium,并人工去除了一些看不到霙的正脸的图片。感谢ta的用心制作。

import os
import torch
import torch.nn as nn
import torch.nn.functional as F
from PIL import Image
from torchvision import transforms
from torchvision.transforms import InterpolationMode
from torchvision.models import efficientnet_v2_m
from predict import predict_image_with_saved_model
from tqdm import trange

MODEL_PATH = "../src/models/anime_classifier_efficientnetv2m_best.pt"
FIXED_IMAGE_PATH = "../src/test_dir/test.png"
OUT_PATH = "./adv_submit.png"

IMG_SIZE = 288
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)
EPS_L2 = 0.5
STEPS = 500
STEP_SIZE = 0.0000035
THRESH = 0.5

if torch.backends.mps.is_available():
  DEVICE = torch.device("mps")
elif torch.cuda.is_available():
  DEVICE = torch.device("cuda")
else:
  DEVICE = torch.device("cpu")

_server_tfm = transforms.Compose(
  [
    transforms.Resize((IMG_SIZE, IMG_SIZE), interpolation=InterpolationMode.BILINEAR),
    transforms.ToTensor(),
  ]
)


def server_feat_from_pil(pil_img: Image.Image) -> torch.Tensor:
  return _server_tfm(pil_img.convert("RGB")).reshape(-1)


def server_l2_from_tensors(y_pixel_01: torch.Tensor, x0_pil: Image.Image) -> float:
  y_pil = transforms.ToPILImage()(y_pixel_01.clamp(0, 1).detach().cpu())
  fy = server_feat_from_pil(y_pil)
  return torch.norm(fy - server_feat_from_pil(x0_pil), p=2).item()


def load_model_and_meta(model_path: str):
  assert os.path.exists(model_path), f"ckpt 不存在:{model_path}"
  raw = torch.load(model_path, map_location="cpu")

  if (
    isinstance(raw, dict)
    and "state_dict" in raw
    and isinstance(raw["state_dict"], dict)
  ):
    state_dict = raw["state_dict"]
    meta_ckpt = raw
  elif isinstance(raw, dict):
    state_dict = raw
    meta_ckpt = {}
  else:
    raise RuntimeError("无法识别的 checkpoint 格式。")

  PREFIXES = ("module.", "model.", "_orig_mod.", "_forward_module.")

  def strip_prefix(k):
    for p in PREFIXES:
      if k.startswith(p):
        return strip_prefix(k[len(p) :])
    return k

  state_dict = {strip_prefix(k): v for k, v in state_dict.items()}

  keys = list(state_dict.keys())
  if "classifier.1.weight" in state_dict:
    fc_w_name = "classifier.1.weight"
  elif "classifier.0.weight" in state_dict:
    fc_w_name = "classifier.0.weight"
  else:
    cand = [k for k in keys if k.endswith(".weight")]
    cand.sort()
    fc_w_name = cand[-1]
  num_classes = state_dict[fc_w_name].shape[0]

  model = efficientnet_v2_m(weights=None)
  in_features = model.classifier[-1].in_features
  model.classifier[-1] = nn.Linear(in_features, num_classes)
  model.load_state_dict(state_dict, strict=False)
  model.eval().to(DEVICE)

  classes = (
    meta_ckpt.get("classes")
    or meta_ckpt.get("meta", {}).get("classes")
    or (
      ["positve", "negative"][:num_classes]
      if num_classes <= 2
      else [f"class_{i}" for i in range(num_classes)]
    )
  )
  pos_name = (
    meta_ckpt.get("positive_class_name")
    or meta_ckpt.get("meta", {}).get("positive_class_name")
    or next((c for c in classes if c.lower() != "negative"), classes[0])
  )
  if pos_name not in classes:
    classes = [pos_name] + [c for c in classes if c != pos_name]
  pos_idx = classes.index(pos_name)

  return model, pos_idx


def preprocess_tensor_pixel01(x01: torch.Tensor) -> torch.Tensor:
  x = x01.unsqueeze(0)
  x = F.interpolate(
    x,
    size=(IMG_SIZE, IMG_SIZE),
    mode="bilinear",
    align_corners=False,
    antialias=True,
  )
  mean = torch.tensor(IMAGENET_MEAN, device=x.device)[None, :, None, None]
  std = torch.tensor(IMAGENET_STD, device=x.device)[None, :, None, None]
  x = (x - mean) / std
  return x


def main():
  assert os.path.exists(FIXED_IMAGE_PATH), f"原图不存在:{FIXED_IMAGE_PATH}"
  x0_pil = Image.open(FIXED_IMAGE_PATH).convert("RGB")
  x0_pixel = transforms.ToTensor()(x0_pil)
  y = x0_pixel.to(DEVICE).clone().detach().requires_grad_(True)
  model, pos_idx = load_model_and_meta(MODEL_PATH)

  fx0 = server_feat_from_pil(x0_pil)

  for _ in trange(STEPS):
    logits = model(preprocess_tensor_pixel01(y))
    loss = logits[0, pos_idx]
    g = torch.autograd.grad(loss, y, retain_graph=False, create_graph=False)[0]

    with torch.no_grad():
      g_norm = g.reshape(-1).norm(p=2).clamp(min=1e-12)
      y.add_(-STEP_SIZE * g / g_norm)
      y.clamp_(0.0, 1.0)

      y_pil = transforms.ToPILImage()(y.detach().cpu())
      fy = server_feat_from_pil(y_pil)
      l2_now = torch.norm(fy - fx0, p=2).item()
      if l2_now > EPS_L2:
        alpha = EPS_L2 / l2_now
        y = (x0_pixel.to(DEVICE) + (y - x0_pixel.to(DEVICE)) * alpha).clamp(0, 1)
        y.requires_grad_(True)

  adv_pil = transforms.ToPILImage()(y.clamp(0, 1).detach().cpu())
  adv_pil.save(OUT_PATH, format="PNG")
  adv_disk = Image.open(OUT_PATH).convert("RGB")
  l2_check = torch.norm(server_feat_from_pil(adv_disk) - fx0, p=2).item()

  is_target, pos_prob = predict_image_with_saved_model(
    OUT_PATH, model_path=MODEL_PATH, threshold=THRESH
  )

  print(f"[RESULT] Saved: {OUT_PATH}  (size={adv_disk.size}, orig={x0_pil.size})")
  print(
    f"[CHECK] L2 (server-style) = {l2_check:.6f}  (<= {EPS_L2}? {'YES' if l2_check <= EPS_L2 else 'NO'})"
  )
  print(f"[PRED ] pos_prob = {pos_prob:.6f}  -> is_target = {is_target}  (need False)")


if __name__ == "__main__":
  main()
python

快要坏掉的二维码#

import numpy as np
from scipy.fftpack import dct
import qrcode
from functools import reduce


def gen_qr(data):
    qr = qrcode.QRCode(
        version=1,
        error_correction=qrcode.constants.ERROR_CORRECT_L,
        box_size=10,
        border=4,
    )
    qr.add_data(data)
    qr.make(fit=True)

    qr_image = qr.make_image(fill='black', back_color='white')
    return np.array(qr_image).astype(float)


def rs(image, size):
    H, W = image.shape
    Ha = H // size * size
    Wa = W // size * size
    print(Ha, Wa)
    return image[:Ha, :Wa]


def bs(image, size):
    return [image[i:i+size, j:j+size].flatten()
            for i in range(0, image.shape[0], size)
            for j in range(0, image.shape[1], size)]


def trans(blocks, size, len):
    mat = np.random.randn(size, len)
    return mat, [mat.dot(dct(block, norm='ortho')) for block in blocks]


def compose(*funcs):
    def compose_two(f, g):
        return lambda x: f(g(x))
    return reduce(compose_two, funcs)


def processor(block_size, rs_size):
    def rsp(img): return rs(img, block_size)
    def bsp(img): return bs(img, block_size)
    def transp(blocks): return trans(blocks, rs_size, block_size**2)

    return compose(transp, bsp, rsp, gen_qr)


flag = open("flag.txt", "r").read().strip()
BS = 8
RS = 20
A, out = processor(BS, RS)(flag)
np.save('A.npy', A)
np.save('output.npy', out)
python

这题考的是压缩感知,之前出给了imaginaryCTF daily,这次原封不动地搬了过来。

压缩感知是说这么一件事:如果一个信号在某个变换域(比如 DCT、FFT、小波)是稀疏的,那么我们可以用远少于奈奎斯特采样定理要求的测量数恢复原始信号。

题目的流程是

flag -> 生成二维码 -> 裁剪成8x8块 -> DCT变换 -> 乘随机矩阵 -> 得到低维投影
txt

二维码是黑白两色,在 DCT 域下比较稀疏,而这里的乘随机矩阵可以看作是采样过程,所以利用压缩感知的原理可以逆向重建原始信号。

当然这题中重建效果并不是很好,不过也足够扫出flag了。

import numpy as np
from scipy.fftpack import idct
from sklearn.linear_model import OrthogonalMatchingPursuit
import matplotlib.pyplot as plt

A = np.load('A.npy')
out = np.load('output.npy')
num_blocks, _ = out.shape
block_size = 8

h_blocks = int(np.sqrt(num_blocks))
w_blocks = h_blocks if h_blocks**2 == num_blocks else num_blocks // h_blocks
Ha, Wa = h_blocks * block_size, w_blocks * block_size
image = np.zeros((Ha, Wa))

for k in range(num_blocks):
    y = out[k]
    omp = OrthogonalMatchingPursuit(n_nonzero_coefs=10)
    omp.fit(A, y)
    x_hat = omp.coef_
    block = idct(x_hat, norm='ortho').reshape((block_size, block_size))
    i, j = divmod(k, w_blocks)
    image[i*block_size:(i+1)*block_size, j*block_size:(j+1)*block_size] = block

plt.imshow(image, cmap='gray')
plt.axis('off')
plt.savefig('recovered_qr.png')
plt.show()
python

Reverse#

リバース問題が多すぎる!#

主要考时序侧信道的一题,就不放附件/源码了。level1是简单的流密码性质,level2考察时序侧信道。当然gdb下断点比较内存然后逐字节爆破也是可以的。

2025 H^3CTF 出题人 Writeup
https://blog.swizzer.cc/blog/2025-h3ctf-%E5%87%BA%E9%A2%98%E4%BA%BA-writeup
Author Swizzer
Published at October 15, 2025