3225 字
16 分钟
EzFlag 逆向分析:密码门 + Fibonacci 周期坑,以及怎么自己出这道题

EzFlag 逆向分析:密码门 + Fibonacci 周期坑,以及怎么自己出这道题#

逆向对象:Crypto/Ezflag/EzFlag(Linux ELF x86-64,20KB,not stripped,C++ 编写) 分析工具:REA(Ghidra 反编译)+ objdump -d 交叉验证 + 真实运行验证 + Python 闭环求解


一、结论先行#

最终 Flag:flag{10632674-1d219-09f29-147a2-760632674}

一句话还原:程序是一个 密码门 + flag 逐字符生成器——

  1. 先问你密码,密码明文躺在字符串表里:V3ryStr0ngp@ssw0rd(这道”门”几乎不防逆向,防的只是不懂工具的人);
  2. 密码正确后打印 flag{,然后循环 32 次:每次用函数 f(key) 查一个全局表 K(= "012ab9c3478d56ef")得到一个字符,keykey = key*8 + (i+0x40) 指数增长;
  3. 最大的坑f(key) 内部是一个循环 key的 Fibonacci(mod 16)递推,而 key 每轮乘 8 → 到第 10 个字符左右,程序就要跑几十亿次循环,你永远等不到它把 flag 打完
  4. 解法:发现 Fibonacci mod 16 的周期是 24(Pisano period),把天文数字的 key 降到 key % 24,离线算完 32 个字符。

核心考点:从汇编还原 f() 的斐波那契递推 → 发现 mod-16 周期 → 用周期把”跑不完”的暴力循环降维成常数次运算。这题叫 “Crypto”,坑不在数学多深,而在”必须静态逆算法,不能靠运行”。


二、文件识别#

Terminal window
$ file EzFlag
ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=85828d..., for GNU/Linux 3.2.0, not stripped
$ sha256sum EzFlag
7bbd540a5b7e688d57efd7f1175368fa9f934f3c83a1e6262457b870bd0b5996
意义
格式ELF 64 x86-64 PIELinux 可执行文件
符号not stripped函数名全保留:mainf、全局 K 直接可 nm -C
编译器GCC (Debian 14.2.0),C++game.cpp 编译,-O0 未优化,反编译很干净
动态链接libstdc++ / libc用了 std::stringstd::cin/coutstd::chrono/nanosleep
大小20KB,100 个函数,161 条字符串非常小

strings 一眼看到关键线索:

Enter password: # 0x102007
V3ryStr0ngp@ssw0rd # 0x102018 ← 密码明文!门是假的
Wrong password! # 0x10202b
flag{ # 0x10203b ← flag 前缀
012ab9c3478d56ef # 0x102045 ← 16 字符"字母表"(16 = 4bit,像 hex 换表)

符号(nm -C EzFlag):

0000000000004300 b K # 全局 std::string K(bss,静态初始化赋值)
0000000000001229 T f # 核心函数 f(unsigned long long)
000000000000129b T main

三、程序逻辑还原#

3.1 main(0x129b)#

Ghidra 反编译 + objdump 交叉验证后,等价源码:

int main() {
std::string input;
std::cout << "Enter password: ";
std::getline(std::cin, input);
if (input != "V3ryStr0ngp@ssw0rd") { // 明文密码比较
std::cout << "Wrong password!" << std::endl;
} else {
std::cout << "flag{";
std::cout.flush();
unsigned long long key = 1;
for (int i = 0; i < 0x20; i++) { // 32 个字符
std::cout << f(key); // 查表得一个字符
std::cout.flush();
if (i == 7 || i == 12 || i == 17 || i == 22)
std::cout << "-"; // 分组 8-5-5-5-9(伪 UUID 样式)
key = key * 8 + (i + 0x40); // key 指数增长 ← 坑的根源
std::this_thread::sleep_for(std::chrono::seconds(1)); // 打字机延迟
}
std::cout << "}" << std::endl;
}
return 0;
}

3.2 f(0x1229)—— 核心算法#

; f(unsigned long long n)
; prev = 0, cur = 1, i = 0
; loop: tmp = cur; cur = (cur + prev) & 0xf; prev = tmp; i++ ; while i < n
; return K[prev] ; 查全局字符串 K(std::string::operator[])

还原成 C++:

// 全局 K:静态初始化时 K = "012ab9c3478d56ef"
std::string K;
char f(unsigned long long n) {
unsigned long long prev = 0, cur = 1; // F(0), F(1)
for (unsigned long long i = 0; i < n; i++) {
unsigned long long tmp = cur;
cur = (cur + prev) & 0xf; // 斐波那契,mod 16
prev = tmp;
}
return K[prev]; // K[F(n) mod 16]
}

也就是说:f(key) = K[F(key) mod 16],其中 F 是斐波那契数列,K = "012ab9c3478d56ef"

3.3 全局 K 的初始化#

// __static_initialization_and_destruction_0 (0x1482)
void __static_init(void) {
K = "012ab9c3478d56ef"; // std::string 构造
atexit(dtor); // 注册析构
}

012ab9c3478d56ef 正好是 0123456789abcdef 打乱顺序的 16 个字符——像一套”自定义 hex 表”。


四、为什么”跑起来看”永远解不出(本题最大的坑)#

先算 key 的增长:

i=0: key=1 → f(1) :1 次循环
i=1: key=1*8+64=72 → f(72) :72 次
i=2: key=72*8+65=641 → f(641) :641 次
...
i=9: key≈1.36e9 → 十几亿次循环(约 1~2 秒)
i=10: key≈1.09e10 → 百亿次(几十秒到几分钟)
i=20: key≈9.4e19 → 天文数字,基本卡死
i=31: key≈1e29 → 永远跑不完

f(key) 的循环次数 = key,而 key 每轮 ×8 → 程序在第 10 个字符左右开始指数级卡死,即使密码正确、把 1 秒 sleep 也去掉,也永远打不完 flag。

实测(正确密码 + 30 秒超时):只打印出前 10 个字符:

Enter password: flag{10632674-1d ← 30 秒只到这,之后 f(key) 卡死

这正是”crypto”味所在:运行不可行 → 必须静态还原算法 + 数学降维。出题人用”跑不完”逼你动手逆。


五、求解:发现周期,离线算 flag#

5.1 关键观察:Fibonacci mod 16 有周期#

F(n) mod 16 的 Pisano period 是 24F(24)≡0, F(25)≡1 (mod 16),回到初始对)。所以:

F(key) mod 16 = F(key mod 24) mod 16

key 虽是 10^29 级别的天文数字,取模 24 后只剩 0..23——暴力循环瞬间可算

5.2 Solver#

K = "012ab9c3478d56ef"
def fib_mod16(n):
n %= 24 # Pisano period of 16
a, b = 0, 1 # F(0), F(1)
for _ in range(n):
a, b = b, (a + b) & 0xf
return a
key = 1
chars = []
for i in range(32):
chars.append(K[fib_mod16(key)])
key = key * 8 + (i + 0x40)
s = "".join(chars)
out = ""
for i, ch in enumerate(s):
out += ch
if i in (7, 12, 17, 22):
out += "-"
print(f"flag{{{out}}}")
# flag{10632674-1d219-09f29-147a2-760632674}

5.3 闭环验证#

  • 与真实运行一致:程序 30 秒内打印的 flag{10632674-1d 与 solver 前 10 个字符 10632674-1d 完全吻合;
  • 算法可逆:把任意一个 key_i 代回 f 的 C++ 逻辑手算,结果与查表一致;
  • 周期正确key1, 72, 641, 5194, ... 任意值,fib_mod16(key) == fib_mod16(key % 24)

六、怎么自己出这道题(思路 + 一般操作方式)#

6.1 思路:出题 = 设计”解题路径”#

选手流程:识别(ELF/C++) → strings/符号 → 还原 main 和 f → 发现"跑不完" → 找周期 → 离线算 flag
出题流程:写 flag 生成器 → 包密码门 → 埋"运行不可行"的坑 → 验证可解且不可白嫖

这道题的三层设计各自在”考”什么:

设计考什么
1 密码门密码明文放在字符串表strings/看反编译就有钥匙——“门”是假的,防新手不防逆向
2 生成器f(key)=K[F(key)%16],key 每轮 ×8会还原汇编里的递推(prev/cur 交换 + & 0xf
3 运行不可行f 的循环次数 = key,指数爆炸会做数学降维(发现周期 24),而不是干等程序输出

难度 = 逻辑复杂度 × 防护层数 × 反分析强度。这道是”逻辑一眼(斐波那契查表)+ 防护两层(假密码门 + 跑不完坑)“,属于入门偏易,但”跑不完”这个坑比纯静态题多了一层”要动手逆而不是运行”的意识。

6.2 一般操作方式(6 步)#

第 1 步:定难度与题型

  • 入门:密码门(明文)+ 一眼算法(查表/XOR/异或),flag 运行时生成。
  • 进阶:密码哈希化、多段算法(RC4/AES/LCG)、反调试。
  • 本题定位:Crypto 标签下的”逆向实现”题——程序不存储 flag,而是按算法现场生成

第 2 步:写核心逻辑(C++ 模板)

// game.cpp —— 出题模板
#include <iostream>
#include <string>
#include <thread>
#include <chrono>
static std::string K; // 静态初始化赋值,见第 3 步
static char f(unsigned long long n) {
unsigned long long prev = 0, cur = 1;
for (unsigned long long i = 0; i < n; i++) {
unsigned long long tmp = cur;
cur = (cur + prev) & 0xf; // 斐波那契 mod 16
prev = tmp;
}
return K[prev]; // 查表
}
int main() {
std::string input;
std::cout << "Enter password: ";
std::getline(std::cin, input);
if (input != "V3ryStr0ngp@ssw0rd") {
std::cout << "Wrong password!" << std::endl;
return 0;
}
std::cout << "flag{";
unsigned long long key = 1;
for (int i = 0; i < 32; i++) {
std::cout << f(key);
if (i == 7 || i == 12 || i == 17 || i == 22) std::cout << "-";
key = key * 8 + (i + 0x40);
std::this_thread::sleep_for(std::chrono::seconds(1)); // 可选打字机效果
}
std::cout << "}" << std::endl;
return 0;
}

第 3 步:用脚本生成/校验常量(关键:flag 是”算出来”的,不是写死的)

#!/usr/bin/env python3
"""gen.py —— 出题辅助:给定 K 表推导 flag,并做闭环校验。
换 K 表(16 个互异字符的排列)= 换一道题。
"""
import sys
def fib_mod16(n: int) -> int:
n %= 24 # Fibonacci mod 16 的 Pisano 周期
a, b = 0, 1
for _ in range(n):
a, b = b, (a + b) & 0xf
return a
def gen_flag(K: str) -> str:
assert len(K) == 16 and len(set(K)) == 16, "K 必须是 16 个互异字符"
key = 1
s = []
for i in range(32):
s.append(K[fib_mod16(key)])
key = key * 8 + (i + 0x40)
s = "".join(s)
return f"flag{{{s[:8]}-{s[8:13]}-{s[13:18]}-{s[18:23]}-{s[23:]}}}"
if __name__ == "__main__":
K = sys.argv[1] if len(sys.argv) > 1 else "012ab9c3478d56ef"
print(gen_flag(K))

运行:

Terminal window
python3 gen.py # flag{10632674-1d219-09f29-147a2-760632674}
python3 gen.py 0f9e8d7c6b5a4321 # 换 K 表 → 新 flag(自动满足一致性)

一致性约束(换题要点):key 序列固定后,每个位置的 F(key)%16 下标是固定的——凡下标相同的位,字符必须相同(例如本题下标 13 出现在第 3/6/25/27/30 位,全是 6)。所以要么让 flag 由 K 表自然生成(推荐),要么按约束手工构造 32 字符序列再反推 K 表。别手算,用 gen.py。

第 4 步:编译

Terminal window
g++ -std=c++17 -O0 -o EzFlag game.cpp # -O0 + 保留符号:反编译干净,方便选手,也方便你自测
# 发布可选:strip EzFlag # 去掉符号 → 难度+1(选手要从 xref/字符串找 f)
# 可选加壳:upx -9 EzFlag # ELF 也能 UPX

第 5 步:以选手视角自测(最重要,别跳过)

Terminal window
file EzFlag # ELF64 x86-64 not stripped
strings EzFlag # 密码明文 V3ryStr0ngp@ssw0rd、K 表 012ab9c3478d56ef、flag{
nm -C EzFlag # main / f / K
objdump -d EzFlag | sed -n '/<_Z1fy>:/,/^$/p' # 还原 f:prev/cur + &0xf
# 写 solver(第五节),跑出 flag
printf 'V3ryStr0ngp@ssw0rd\n' | timeout 30 ./EzFlag # 验证输出前缀与 solver 一致

验收标准

  • ✅ 正常输入输出正确(密码门生效);
  • ✅ 按上述解题流程能解出 flag(可解性——你自己必须能解);
  • strings 里找不到明文 flag(不可白嫖性——flag 是运行时生成/算出的,不在常量表);
  • ✅ “跑不完”坑真的存在且周期可解(难度与坑的匹配);
  • ✅ 换 K 表后 gen.py 生成的 flag 与程序一致(发布前核对)。

第 6 步:发布 交付物:EzFlag(+ EzFlag.zip)、一页 hint、一份 writeup(还原逻辑 + solver)。writeup 既是”标准答案”,也是题目可复现性的证明。

6.3 难度旋钮(同一套模板,从易到难)#

  1. 不埋坑f 循环次数固定(如 24)→ 纯入门,跑一下就看到 flag。
  2. 埋”跑不完”坑(本题)→ 必须静态逆 + 周期降维,意识题。
  3. 密码哈希化sha256(input) == TARGET 替代明文比较 → 门不再是摆设。
  4. 无周期的生成器:LCG/LFSR/大周期序列 → 需要推公式而不是查周期。
  5. K 表加密存储:运行时才解出 K(XOR/异或表)→ 多一层还原。
  6. strip / UPX / 花指令 / 反调试 → 进阶。
  7. 真·crypto 化:把”查表生成”换成真正的分组加密(AES/RC4 加密 flag,密钥藏在程序里)→ 从”逆向题”变”Crypto 题”。

每加一档,务必回到第 5 步重新自测——“题目能出”和”题目能解”是两个方向,必须双向验证

6.4 通用出题铁律#

  1. 真 flag 永远不进字符串常量表——strings 白嫖区。要么运行时生成(本题),要么加密存储,要么独立数据文件。
  2. 坑必须可解:周期类坑要保证周期小到选手能发现(24 这个数枚举一次就够);“跑不完”要配”数学降维”的出路,否则是坑题。
  3. 先自己当选手完整解一遍,记录步骤与耗时,据此定难度;发布前用 gen.py 重新核对交付的二进制与 flag 一致。
  4. 密码门如果是明文,就别指望它挡人——它只是新手护栏/剧情,真正的难度在生成算法与坑上,别让选手以为”过了密码就通关”。
  5. 工具清单:识别 file/DIE/readelf;反编译 IDA/Ghidra/objdump -d;符号 nm -C;动态 gdb;辅助生成数据用自己写的 gen.py。

七、总结#

  • EzFlag 是一道典型的 Crypto 标签下的”逆向实现”入门题:程序不存储 flag,而是”密码门(明文密码 V3ryStr0ngp@ssw0rd)+ 生成器(f(key) = K[F(key) mod 16]K="012ab9c3478d56ef",key 每轮 ×8)“现场打印。
  • 最大坑f(key) 的循环次数 = key,key 指数增长 → 程序在第 10 个字符后卡死,运行永远拿不到 flag,必须静态还原算法。
  • 解法:还原 f 的斐波那契(mod 16)递推 → 发现 Pisano 周期 24 → 把 key 降为 key % 24 → 离线算出 flag{10632674-1d219-09f29-147a2-760632674},并与真实运行输出前缀闭环验证。
  • 自己出题:六步流程(定难度 → 写逻辑 → gen.py 生成校验 → 编译 → 选手视角自测 → 发布)+ 难度旋钮 + “真 flag 不进字符串表”+“坑必须可解”两条铁律。换一道题只需换 K 表,gen.py 自动给出新 flag。

分析证据:REA (Ghidra) 反编译 main/f/__static_initialization_and_destruction_0objdump -d 交叉验证 main(0x129b)与 f(0x1229)指令流(and $0xf、prev/cur 交换);nm -C 确认 K(bss 0x4300)、fmainstrings 获取密码明文与 K 表;真实运行 30 秒输出 flag{10632674-1d 与 solver 前 10 字符完全一致;Python solver 用 Pisano period 24 降维并闭环验证。