Mobile wallpaper 1
1535 字
8 分钟
题解·The Emergence of Rikka
2025-11-21

概述#

这是一个包含 模型逆向 (ML Reverse Engineering)API 信息泄露 (Web Exploitation) 的多阶段挑战。目标是首先通过恢复模型结构和 Prompt 来获取一个密钥(ID),然后利用该密钥与服务器 API 交互,最终获取 Rikka 留下的完整信息(Final Flag)。

原始题目#

一天,Muika 在自己的电脑上找到了一份奇怪的模型参数文件:

Muika.pth

Muika 推断,这是一个极小的 Transformer,被训练得 过度拟合 在某个任务上。

他知道这是由 Rikka 留下来的:

“只要你复现正确的模型结构,并加载正确的参数, 输入与训练任务相近的 prompt,模型就会返回 FLAG。”

然而,模型结构信息不完整,部分参数(如注意力头数、层数)已被删除或混淆。 你必须推断它们,正确还原模型,然后从模型中推理出最终 Flag。

你能破解这台“过度学习的小脑袋”吗?


目录结构

你有 3 个文件:

ctf_tiny_transformer/
├── muika.pth # 模型权重(核心)
├── partial_config.json
├── hint.txt # 部分 prompt

其中,muika.pth 是模型的权重,你需要正确推导出模型结构并正确加载它,之后你就应该知道怎么做了。


你的任务

  1. 根据权重形状、矩阵维度推断 Transformer 的真实结构;
  2. 正确构建模型并成功加载权重;
  3. 输入 hint.txt 中的 prompt;
  4. 从模型的输出中恢复 FLAG。
  5. 提交到 http://8.134.xxx.xxx:8080/api/ctf/flag{...} (GET)。

如果 Flag 正确,你会得到 200 状态码

祝你好运。


来自 Muika 的提示

我知道 transformer 对于你有点超纲,所以我找到了模型结构:

TinyTransformer(
(tok_emb): Embedding(257, 128)
(pos_emb): Embedding(64, 128)
(encoder): TransformerEncoder(
(layers): ModuleList(
(0-1): 2 x TransformerEncoderLayer(
(self_attn): MultiheadAttention(
(out_proj): NonDynamicallyQuantizableLinear(in_features=128, out_features=128, bias=True)
)
(linear1): Linear(in_features=128, out_features=512, bias=True)
(dropout): Dropout(p=0.0, inplace=False)
(linear2): Linear(in_features=512, out_features=128, bias=True)
(norm1): LayerNorm((128,), eps=1e-05, elementwise_affine=True)
(norm2): LayerNorm((128,), eps=1e-05, elementwise_affine=True)
(dropout1): Dropout(p=0.0, inplace=False)
(dropout2): Dropout(p=0.0, inplace=False)
)
)
)
(head): Linear(in_features=128, out_features=257, bias=True)
)

这下应该对你来说不难了

但是即使你应用了正确的 Prompt ,由于模型架构限制,输出结果的前 2~3 位被截断了,你需要自行补全它


From Rikka

我先说说你会在这个题目最后会得到什么(我说的是你提交答案之后),答案是一串数字,你看到这一串数字之后你就应该怎么做了

至于我为什么不直接给你,理由很简单,我不想再看到别人比我做得更好了,我不能阻止你进步,但是我可以闭上我的双眼,让这些事情让我从我的视线中消失

因此,请善用这一串数字。

但是,这一串数字不是终点,你还有一串文本没有解出来,而模型文件里没有。它不在这张纸上,但是你刚刚得到了这串钥匙。

祝你好运

第一阶段:模型逆向与密钥获取#

题目分为两个阶段,第一个阶段是从过拟合的模型中获取 FLAG 并完成提交

1. 模型结构重建#

我们拿到了 muika.pth 权重文件和 partial_config.json

由于原始模型结构已给出,我们可以立即重建模型

VOCAB_SIZE = 257 # Embedding(257, 128)
EMBED = 128 # Embedding(257, 128)
N_LAYERS = 2 # (0-1): 2 x TransformerEncoderLayer
N_HEAD = 4 # d_model 必须能被 nhead 整除, 而 vocab_size, d_model = state_dict['tok_emb.weight'].shape = 257, 128
# 当 d_model = 128 时,n_head 通常为 4,或者使用下面的代码检查
# sd = torch.load("muika.pth", map_location="cpu")
# for k,v in sd.items():
# print(k, v.shape)
# 观察与 self_attn 相关的 weight,例如 'encoder.layers.0.self_attn.in_proj_weight'
class TinyTransformer(nn.Module):
def __init__(self, vocab_size, embed_dim, n_head, n_layers):
super().__init__()
self.tok_emb = nn.Embedding(vocab_size, embed_dim)
self.pos_emb = nn.Embedding(MAX_DECODE, embed_dim)
encoder_layer = nn.TransformerEncoderLayer(
d_model=embed_dim, nhead=n_head,
dim_feedforward=embed_dim*4,
dropout=DROPOUT, batch_first=True)
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=n_layers)
self.head = nn.Linear(embed_dim, vocab_size)
def forward(self, x):
B,T = x.shape
pos = torch.arange(T, device=x.device).unsqueeze(0).expand(B,T)
h = self.tok_emb(x) + self.pos_emb(pos)
h = self.encoder(h)
logits = self.head(h)
return logits
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = TinyTransformer(VOCAB_SIZE, EMBED, N_HEAD, N_LAYERS).to(device)
model.load_state_dict(torch.load("Rikka/muika.pth", map_location=device))
model.eval()

2. Prompt 谜题破解#

hint.txt 内容只有一行:

<?????>:

这表示输入 prompt 的格式是 <xxxxx>:,其中中间必须填入 5 个字符。

结合 partial_config.json 中 “find me” 的提示,以及整个题目背景都围绕 Rikka 展开,可以推断:

“me” 指的正是 Rikka;

“Rikka” 恰好是 5 个字符;

因此 prompt 应该为:

<rikka>:

这也是模型最可能被训练过的输入格式。

3. 模型推理与输出修正#

将 Prompt 编码输入模型进行自回归生成 (Autoregressive Generation)。

EOS = 256 # VOCAB_SIZE - 1
prompt = "rikka:"
def encode(s):
return list(s.encode("utf-8")) + [EOS]
prompt_tokens = encode(prompt)
cur = torch.tensor(prompt_tokens, device=device).unsqueeze(0)
result = []
for _ in range(100):
with torch.no_grad():
logits = model(cur)[:, -1, :]
next_id = int(logits.argmax())
if next_id == EOS:
break
result.append(next_id)
cur = torch.cat([cur, torch.tensor([[next_id]], device=device)], dim=1)
flag = bytes(result).decode("utf-8")
print(flag)
eEmergenceOfRikka}

题目提示输出结果前 2 位被截断。根据语义,e 前面最合理的 2~3 个字符是 {Th,构成 {TheEmergenceOfRikka}

第一次提交 (Key Request):

提交内容提交 URL
flag{TheEmergenceOfRikka}http://8.134.xxx.xxx:8080/api/ctf/flag{TheEmergenceOfRikka}

API 响应: {"message": "52441778734xxxx", "code": 200}

获取密钥 (Key ID): 52441778734xxxx

第一部分结束


第二阶段:API 交互与最终 Flag 获取#

1. 密钥用途分析#

Rikka 的提示:

“这一串数字不是终点… 你刚刚得到了这串钥匙。文本不在模型文件里。”

这表明 52441778734xxx 是一个密钥,用于访问服务器上的隐藏文本。

2. 信息泄露与路由发现#

我们不能直接将这个密钥再次作为一个 FLAG 提交,没人会这么做。

但是这个密钥本身是有意义的,他指向 Muika 的舞萌DX账号,密钥本身没有什么破解的必要。

所以,服务器上是不是还有其他隐藏的端口没被我们发现?如果我们有 API 文档 就好了。

如果服务器使用 FastAPI 实现,访问 http://8.134.xxx.xxx:8080/docshttp://8.134.xxx.xxx:8080/openapi.json 就可以找到 FastAPI 自带的 Swagger UI 文档 ,这往往是 CTF 中最容易被忽视的信息泄露点。

在文档中发现了可以查询 Friend Code 的接口:/friend_code/{friend_code}

FastAPI 自带的 API 文档

注:http://8.134.xxx.xxx:8080/api/ctf/flag{TheEmergenceOfRikka} 其实中的 /api/ 其实是一个误导项,API 文档在全局路由中挂载而不是 /api/docs

3. 最终 API 请求#

使用获取到的密钥 ID 访问该接口,以获取 Rikka 留下的最终文本。

  • Final Request: GET http://8.134.xxx.xxx:8080/friend_code/52441778734xxxx

  • API 返回 (Final Text):

friend_code

至此,全部题目已解密完成。

题解·The Emergence of Rikka
https://blog.snowy.moe/posts/49641/
作者
Muika
发布于
2025-11-21
许可协议
CC BY-NC-SA 4.0