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 逐字符生成器——
- 先问你密码,密码明文躺在字符串表里:
V3ryStr0ngp@ssw0rd(这道”门”几乎不防逆向,防的只是不懂工具的人); - 密码正确后打印
flag{,然后循环 32 次:每次用函数f(key)查一个全局表K(="012ab9c3478d56ef")得到一个字符,key按key = key*8 + (i+0x40)指数增长; - 最大的坑:
f(key)内部是一个循环key次的 Fibonacci(mod 16)递推,而key每轮乘 8 → 到第 10 个字符左右,程序就要跑几十亿次循环,你永远等不到它把 flag 打完; - 解法:发现
Fibonacci mod 16的周期是 24(Pisano period),把天文数字的key降到key % 24,离线算完 32 个字符。
核心考点:从汇编还原 f() 的斐波那契递推 → 发现 mod-16 周期 → 用周期把”跑不完”的暴力循环降维成常数次运算。这题叫 “Crypto”,坑不在数学多深,而在”必须静态逆算法,不能靠运行”。
二、文件识别
$ file EzFlagELF 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 EzFlag7bbd540a5b7e688d57efd7f1175368fa9f934f3c83a1e6262457b870bd0b5996| 项 | 值 | 意义 |
|---|---|---|
| 格式 | ELF 64 x86-64 PIE | Linux 可执行文件 |
| 符号 | not stripped | 函数名全保留:main、f、全局 K 直接可 nm -C |
| 编译器 | GCC (Debian 14.2.0),C++ | game.cpp 编译,-O0 未优化,反编译很干净 |
| 动态链接 | libstdc++ / libc | 用了 std::string、std::cin/cout、std::chrono/nanosleep |
| 大小 | 20KB,100 个函数,161 条字符串 | 非常小 |
strings 一眼看到关键线索:
Enter password: # 0x102007V3ryStr0ngp@ssw0rd # 0x102018 ← 密码明文!门是假的Wrong password! # 0x10202bflag{ # 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 是 24(F(24)≡0, F(25)≡1 (mod 16),回到初始对)。所以:
F(key) mod 16 = F(key mod 24) mod 16key 虽是 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 = 1chars = []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++ 逻辑手算,结果与查表一致; - ✅ 周期正确:
key取1, 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))运行:
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 步:编译
g++ -std=c++17 -O0 -o EzFlag game.cpp # -O0 + 保留符号:反编译干净,方便选手,也方便你自测# 发布可选:strip EzFlag # 去掉符号 → 难度+1(选手要从 xref/字符串找 f)# 可选加壳:upx -9 EzFlag # ELF 也能 UPX第 5 步:以选手视角自测(最重要,别跳过)
file EzFlag # ELF64 x86-64 not strippedstrings EzFlag # 密码明文 V3ryStr0ngp@ssw0rd、K 表 012ab9c3478d56ef、flag{nm -C EzFlag # main / f / Kobjdump -d EzFlag | sed -n '/<_Z1fy>:/,/^$/p' # 还原 f:prev/cur + &0xf# 写 solver(第五节),跑出 flagprintf '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 难度旋钮(同一套模板,从易到难)
- 不埋坑:
f循环次数固定(如 24)→ 纯入门,跑一下就看到 flag。 - 埋”跑不完”坑(本题)→ 必须静态逆 + 周期降维,意识题。
- 密码哈希化:
sha256(input) == TARGET替代明文比较 → 门不再是摆设。 - 无周期的生成器:LCG/LFSR/大周期序列 → 需要推公式而不是查周期。
- K 表加密存储:运行时才解出 K(XOR/异或表)→ 多一层还原。
- strip / UPX / 花指令 / 反调试 → 进阶。
- 真·crypto 化:把”查表生成”换成真正的分组加密(AES/RC4 加密 flag,密钥藏在程序里)→ 从”逆向题”变”Crypto 题”。
每加一档,务必回到第 5 步重新自测——“题目能出”和”题目能解”是两个方向,必须双向验证。
6.4 通用出题铁律
- 真 flag 永远不进字符串常量表——
strings白嫖区。要么运行时生成(本题),要么加密存储,要么独立数据文件。 - 坑必须可解:周期类坑要保证周期小到选手能发现(24 这个数枚举一次就够);“跑不完”要配”数学降维”的出路,否则是坑题。
- 先自己当选手完整解一遍,记录步骤与耗时,据此定难度;发布前用 gen.py 重新核对交付的二进制与 flag 一致。
- 密码门如果是明文,就别指望它挡人——它只是新手护栏/剧情,真正的难度在生成算法与坑上,别让选手以为”过了密码就通关”。
- 工具清单:识别
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_0;objdump -d 交叉验证 main(0x129b)与 f(0x1229)指令流(and $0xf、prev/cur 交换);nm -C 确认 K(bss 0x4300)、f、main;strings 获取密码明文与 K 表;真实运行 30 秒输出 flag{10632674-1d 与 solver 前 10 字符完全一致;Python solver 用 Pisano period 24 降维并闭环验证。