LitCTF 2026 lit_elgamal_handshake
- [LitCTF 2026 lit_elgamal_handshake --- ElGamal 私钥泄露题解](#[LitCTF 2026] lit_elgamal_handshake — ElGamal 私钥泄露题解)
-
- 前置知识
-
- [1. 模运算](#1. 模运算)
- [2. 模逆](#2. 模逆)
- [3. 字节串与整数的转换](#3. 字节串与整数的转换)
- [4. 离散对数问题](#4. 离散对数问题)
- 一、题目描述
- [二、ElGamal 加密流程](#二、ElGamal 加密流程)
- [三、漏洞分析:长期私钥 x 被日志泄露](#三、漏洞分析:长期私钥 x 被日志泄露)
- [四、为什么不需要知道 k](#四、为什么不需要知道 k)
- 五、解题脚本
- 六、脚本解释
-
- [1. 头部依赖导入](#1. 头部依赖导入)
- [2. `read_instance()`:读取并清洗题目参数](#2.
read_instance():读取并清洗题目参数) - [3. 命令行入口配置](#3. 命令行入口配置)
- [4. 私钥一致性校验(安全校验)](#4. 私钥一致性校验(安全校验))
- [5. 核心解密逻辑](#5. 核心解密逻辑)
- [6. 执行入口](#6. 执行入口)
- 七、实际计算结果
- Flag
- [Key Takeaway](#Key Takeaway)
LitCTF 2026 lit_elgamal_handshake --- ElGamal 私钥泄露题解
现代密码学 专栏:
https://blog.csdn.net/r_feynman_/category_13190241.html
Crypto 密码解析实战靶场:https://blog.csdn.net/r_feynman_/category_13194584.html
前置知识
1. 模运算
密码学中经常在模 p p p 的意义下进行计算。表达式:
a ≡ b ( m o d p ) a \equiv b \pmod p a≡b(modp)
表示 a a a 和 b b b 除以 p p p 后的余数相同。Python 中的 % p 就可以完成取模操作:
python
value = (a * b) % p
模运算具有乘法结合律,因此可以使用快速模幂计算大指数:
python
pow(a, e, p) # 计算 a^e mod p
相比先计算完整的 a e a^e ae 再取模,pow(a, e, p) 更快,也不会产生不必要的超大整数。
2. 模逆
如果存在整数 u u u,使得:
a u ≡ 1 ( m o d p ) au \equiv 1 \pmod p au≡1(modp)
那么 u u u 就称为 a a a 在模 p p p 下的乘法逆元,记作:
u ≡ a − 1 ( m o d p ) u \equiv a^{-1} \pmod p u≡a−1(modp)
当 p p p 为素数且 a ≢ 0 ( m o d p ) a \not\equiv 0 \pmod p a≡0(modp) 时,逆元一定存在。Python 中可以使用:
python
inverse = pow(a, -1, p)
这相当于求 a − 1 m o d p a^{-1} \bmod p a−1modp。在本题中,需要使用共享密钥的模逆来消除 c2 中的乘法掩码。
3. 字节串与整数的转换
题目中的 FLAG 是字节串,但 ElGamal 在计算时使用整数明文,因此加密前执行:
python
m = bytes_to_long(FLAG)
解密得到整数 m m m 后,需要使用逆操作恢复 FLAG:
python
flag = long_to_bytes(m)
例如:
text
b"litctf{...}" <-> 整数 m
4. 离散对数问题
ElGamal 的安全性通常依赖离散对数问题:已知:
y = g x ( m o d p ) y = g^x \pmod p y=gx(modp)
在 g g g、 y y y 和 p p p 都公开的情况下,求出 x x x 通常很困难。类似地,已知 c 1 = g k c_1=g^k c1=gk 也不能轻易得到随机数 k k k。
本题并没有要求我们解决离散对数,因为题目直接通过调试日志泄露了私钥指数 x。因此可以绕过离散对数,直接计算解密所需的共享密钥。
一、题目描述
题目给出了一组 ElGamal 公钥、一个密文,以及一条本不应该出现在日志中的调试信息:长期私钥指数 x。
python
p, g, y, x = generate_elgamal_keypair(bits=512)
k = randrange(1, p - 2)
m = bytes_to_long(FLAG)
c1 = pow(g, k, p)
c2 = (m * pow(y, k, p)) % p
输出中额外打印了:
python
# [DEBUG] prod accidentally logged the long-term secret:
x = 633366293219022684108628483753423657477324253833657141033762971761747669344649667887002347907882241246119223126492863291886751205505360049793728851371884
目标是利用泄露的 x 解密 (c1, c2),恢复 FLAG。
二、ElGamal 加密流程
ElGamal 在乘法群 Z p ∗ \mathbb{Z}_p^* Zp∗ 中工作。密钥生成过程为:
- 随机选择大素数 p p p;
- 选择生成元(或至少选择满足题目条件的) g g g;
- 随机选择私钥指数 x x x;
- 计算公钥:
y = g x ( m o d p ) y = g^x \pmod p y=gx(modp)
加密时,发送方随机选择一次性随机数 k k k,并计算:
c 1 = g k ( m o d p ) c_1 = g^k \pmod p c1=gk(modp)
c 2 = m ⋅ y k ( m o d p ) c_2 = m \cdot y^k \pmod p c2=m⋅yk(modp)
因此密文为:
( c 1 , c 2 ) = ( g k , m ⋅ y k ) ( m o d p ) (c_1,c_2)=(g^k,\ m\cdot y^k)\pmod p (c1,c2)=(gk, m⋅yk)(modp)
正常情况下,攻击者不知道 x x x,从 c 1 = g k c_1=g^k c1=gk 或 y = g x y=g^x y=gx 中求出指数属于离散对数问题,不能直接计算共享密钥 y k y^k yk。
三、漏洞分析:长期私钥 x 被日志泄露
题目的关键错误在于:
python
# [DEBUG] prod accidentally logged the long-term secret:
x = 633366293219022684108628483753423657477324253833657141033762971761747669344649667887002347907882241246119223126492863291886751205505360049793728851371884
这里的 x 是长期私钥,而不是加密时使用的临时随机数 k。一旦 x 泄露,就不需要求解离散对数,也不需要分解 p p p,只需要直接计算:
c 1 x = ( g k ) x = g k x = ( g x ) k = y k ( m o d p ) c_1^x = (g^k)^x = g^{kx} = (g^x)^k = y^k \pmod p c1x=(gk)x=gkx=(gx)k=yk(modp)
令:
s = c 1 x ( m o d p ) s=c_1^x\pmod p s=c1x(modp)
那么 s s s 就是 ElGamal 加密时使用的共享密钥(掩码):
s = y k ( m o d p ) s=y^k\pmod p s=yk(modp)
而密文第二部分满足:
c 2 = m ⋅ s ( m o d p ) c_2=m\cdot s\pmod p c2=m⋅s(modp)
两边乘以 s − 1 s^{-1} s−1,即可恢复明文:
m = c 2 ⋅ s − 1 ( m o d p ) m=c_2\cdot s^{-1}\pmod p m=c2⋅s−1(modp)
其中 s − 1 s^{-1} s−1 是 s s s 在模 p p p 下的乘法逆元。
Python 中可以直接使用:
python
shared = pow(c1, x, p)
m = c2 * pow(shared, -1, p) % p
pow(shared, -1, p) 就是在计算:
s h a r e d − 1 ( m o d p ) shared^{-1}\pmod p shared−1(modp)
四、为什么不需要知道 k
题目中 k 是随机生成的:
python
k = randrange(1, p - 2)
乍看之下,似乎还需要先求出 k。实际上不需要。
因为:
c 1 x = ( g k ) x = g k x c_1^x=(g^k)^x=g^{kx} c1x=(gk)x=gkx
另一方面:
y k = ( g x ) k = g x k y^k=(g^x)^k=g^{xk} yk=(gx)k=gxk
指数乘法满足交换律,因此:
c 1 x = y k c_1^x=y^k c1x=yk
这一步直接从泄露的 x 得到了加密所需的共享密钥,完全绕过了离散对数。
五、解题脚本
python
#!/usr/bin/env python3
from Crypto.Util.number import long_to_bytes
p = 9000784855376359808051354825193962042770028561343848432778443672755982397391267124312572697249531643069409873722736348916207732622884411596948807031140651
g = 3
y = 269130883529708333054320571854006406481346665463416017026083074488011546059928157925990665431751017523964760326934454181952822744463714981243407307134357
c1 = 5245857426274383693193378669425243235151460522527004924092730024427525619244222247576829782077334810173274945751493387545849499010408499951268967774043627
c2 = 6059939492718262451327758167005534191200936922719178843825888167191062504030471358635203794720371216217447404436172970111033824674731063386612549785069654
x = 633366293219022684108628483753423657477324253833657141033762971761747669344649667887002347907882241246119223126492863291886751205505360049793728851371884
assert pow(g, x, p) == y
shared = pow(c1, x, p)
m = c2 * pow(shared, -1, p) % p
print(long_to_bytes(m).decode())
运行:
bash
from __future__ import annotations
import argparse
import re
from pathlib import Path
from Crypto.Util.number import long_to_bytes
def read_instance(path: Path) -> tuple[int, int, int, int, int, int]:
"""Parse p, g, y, c1, c2 and the accidentally logged x from output.txt."""
values: dict[str, int] = {}
pattern = re.compile(r"^\s*(p|g|y|c1|c2|x)\s*=\s*(\d+)\s*$")
for line in path.read_text(encoding="utf-8").splitlines():
# The official output may contain Markdown comment prefixes such as '# '.
line = re.sub(r"^\s*#+\s?", "", line)
match = pattern.fullmatch(line)
if match:
values[match.group(1)] = int(match.group(2))
names = ("p", "g", "y", "c1", "c2", "x")
missing = [name for name in names if name not in values]
if missing:
raise ValueError(f"文件缺失参数: {', '.join(missing)}")
return tuple(values[name] for name in names) # type: ignore[return-value]
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"-i",
"--input",
type=Path,
default=Path(__file__).with_name("output.txt"),
help="题目输出文件 (默认: output.txt)",
)
args = parser.parse_args()
p, g, y, c1, c2, x = read_instance(args.input)
# Check that the leaked exponent is consistent with the public key.
if pow(g, x, p) != y:
raise ValueError("泄露的 x 与公钥 y 不匹配")
# c1^x = (g^k)^x = (g^x)^k = y^k, the ElGamal shared secret.
shared = pow(c1, x, p)
# c2 = m * y^k (mod p), so m = c2 / shared (mod p).
m = c2 * pow(shared, -1, p) % p
flag = long_to_bytes(m)
print(flag.decode())
if __name__ == "__main__":
main()
输出:
text
litctf{elgamal_leak_makes_happy_decrypt}
六、脚本解释
本脚本针对ElGamal长期私钥x泄露漏洞 实现解密,整体分为文件解析、合法性校验、共享密钥还原、明文解密、输出Flag五个阶段。
1. 头部依赖导入
python
from __future__ import annotations
import argparse
import re
from pathlib import Path
from Crypto.Util.number import long_to_bytes
argparse:提供命令行参数,自定义密文文件路径;re:正则表达式,清洗注释、匹配并提取数值;Path:跨平台安全处理文件路径;long_to_bytes:将解密后的大整数明文转换为字节字符串,打印可读Flag。
2. read_instance():读取并清洗题目参数
python
def read_instance(path: Path) -> tuple[int, int, int, int, int, int]:
values: dict[str, int] = {}
pattern = re.compile(r"^\s*(p|g|y|c1|c2|x)\s*=\s*(\d+)\s*$")
for line in path.read_text(encoding="utf-8").splitlines():
line = re.sub(r"^\s*#+\s?", "", line)
match = pattern.fullmatch(line)
if match:
values[match.group(1)] = int(match.group(2))
names = ("p", "g", "y", "c1", "c2", "x")
missing = [name for name in names if name not in values]
if missing:
raise ValueError(f"文件缺失参数: {', '.join(missing)}")
return tuple(values[name] for name in names)
- 逐行读取文件,先使用正则清除行首
#注释标记,兼容Markdown格式的输出文本; - 正则匹配
p / g / y / c1 / c2 / x6个关键数值; - 校验全部参数是否齐全,缺失则抛出异常;
- 返回6个大整数:素数模数 p p p、生成元 g g g、公钥 y y y、密文对 ( c 1 , c 2 ) (c_1,c_2) (c1,c2)、泄露的长期私钥 x x x。
3. 命令行入口配置
python
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"-i",
"--input",
type=Path,
default=Path(__file__).with_name("output.txt"),
help="题目输出文件 (默认: output.txt)",
)
args = parser.parse_args()
p, g, y, c1, c2, x = read_instance(args.input)
通过 -i 指定输入文件;不传参数时默认读取同目录下 output.txt,调用解析函数拿到全部密码学参数。
4. 私钥一致性校验(安全校验)
python
if pow(g, x, p) != y:
raise ValueError("泄露的 x 与公钥 y 不匹配")
ElGamal公钥定义: y = g x ( m o d p ) y = g^x \pmod p y=gx(modp)。
这一步用来验证日志泄露的私钥x是合法可信的值,防止参数篡改或错误。校验失败直接抛出异常终止程序。
5. 核心解密逻辑
python
shared = pow(c1, x, p)
m = c2 * pow(shared, -1, p) % p
flag = long_to_bytes(m)
print(flag.decode())
-
计算共享掩码密钥
s h a r e d = c 1 x ( m o d p ) shared = c_1^x \pmod p shared=c1x(modp)
利用指数交换性质: c 1 = g k ⇒ c 1 x = g x k = y k c_1=g^k \Rightarrow c_1^x = g^{xk}=y^k c1=gk⇒c1x=gxk=yk,直接得到加密时的会话密钥,无需破解离散对数求随机数 k k k。
-
求模逆并还原明文
密文满足 c 2 = m ⋅ s h a r e d ( m o d p ) c_2 = m \cdot shared \pmod p c2=m⋅shared(modp),变形得到:
m = c 2 ⋅ s h a r e d − 1 ( m o d p ) m = c_2 \cdot shared^{-1} \pmod p m=c2⋅shared−1(modp)
pow(shared, -1, p)用于计算模 p p p 乘法逆元,完成除法运算。 -
格式转换输出
long_to_bytes(m)将数值明文还原为原始字节流,decode()转为字符串输出最终Flag。
6. 执行入口
python
if __name__ == "__main__":
main()
保证脚本可以直接独立运行,作为完整的离线解密利用程序。
小结攻击思路
漏洞根源:服务端意外打印了长期私钥 x x x 。
攻击捷径:不用求解离散对数、不用爆破随机数 k k k;直接通过泄露私钥还原会话共享密钥,一步完成ElGamal解密。
| 代码 | 作用 |
|---|---|
pow(g, x, p) |
验证泄露的私钥 x 确实对应公钥 y |
pow(c1, x, p) |
计算共享密钥 c 1 x = y k c_1^x=y^k c1x=yk |
pow(shared, -1, p) |
计算共享密钥的模逆 |
c2 * ... % p |
从 c 2 = m ⋅ y k c_2=m\cdot y^k c2=m⋅yk 中消去共享密钥,恢复 m m m |
long_to_bytes(m) |
将整数明文还原成字节串 |
.decode() |
将字节串转换为可读字符串 |
七、实际计算结果
本题计算得到的共享密钥为:
text
s = 4062928099126093781468426744109214385048532272466318905272555255697923612733981316319590946428151660170631877259053272768441667830635194496511945007603360
解出的明文整数为:
text
m = 904556569191331988668603914708495020853184821941780054054172829706415872888837467726082984670333
将其转换为字节串:
text
b'litctf{elgamal_leak_makes_happy_decrypt}'
Flag
text
litctf{elgamal_leak_makes_happy_decrypt}
Key Takeaway
- ElGamal 的安全性依赖于私钥指数
x不被泄露。 - 这里不需要求解离散对数,也不需要恢复临时随机数
k。 - 私钥泄露后可直接计算 c 1 x = y k c_1^x=y^k c1x=yk,再用模逆还原明文。
- 调试日志、异常日志和生产日志中都不应输出长期私钥、会话密钥或随机数等敏感材料。