1. 从一道CTF题说起:当RSA公钥被用来“加签”
最近在复盘一些CTF(Capture The Flag)比赛的题目,特别是密码学方向的,发现一个挺有意思的现象:很多刚入门的朋友,一看到“RSA”和“公钥”这两个词绑在一起,脑子里第一反应就是“加密”。这没错,RSA公钥加密、私钥解密,这是教科书里的经典场景。但如果你在BUUCTF这类平台上,看到一道题目标题或描述里带着“RSA公钥加签”这几个字,还按加密的思路去硬套,那大概率会卡住,甚至钻进死胡同。
这道题(或这类题型)的核心陷阱和教学意义就在于此:它故意使用了“加签”这个说法,而不是更常见的“签名”。对于熟悉PKI(公钥基础设施)的朋友来说,“签名”是私钥干的事,验证才用公钥。那“公钥加签”是什么鬼?是不是出题人写错了?其实不然,这正是CTF题目的魅力所在——它往往在玩文字游戏,或者是在考察你对密码学原语(Cryptographic Primitive)本质的理解是否僵化。
简单来说,在这类题目里,“公钥加签”很可能不是一个标准的密码学术语,而是一个描述题面行为的“黑话”。它的真实含义可能是:“题目给出了一段数据,以及一个RSA公钥,这段数据看起来像是用某种方式‘处理’过的,你需要利用这个公钥来解读出原始信息,而这个‘处理’过程,逆向来看,模拟了‘签名’的某些步骤,但用的是公钥。” 这听起来有点绕,我们拆开看。
首先,为什么公钥不能用来“加签”?在标准的RSA数字签名方案中(如RSASSA-PKCS1-v1_5或RSASSA-PSS):
- 签名(生成):对消息的哈希值(比如SHA256)进行填充,然后用私钥进行RSA解密运算(是的,从运算角度看是“解密”)。
- 验签(验证):对收到的签名值,用公钥进行RSA加密运算(运算角度看是“加密”),得到结果后,去掉填充,与消息的哈希值对比。
所以,公钥在签名体系里的角色是“验证”,它做的是加密运算。那么,如果一道题说“用公钥加签”,一种可能是它偷换了概念,把公钥参与的“加密运算”这个过程,类比成了“对数据进行某种锁定”,并称之为“加签”。实际上,它可能描述的是这样一个非标准流程:对某个数据(可能是flag,也可能是中间值)直接使用公钥进行RSA加密(即教科书式的公钥加密操作),然后将这个加密结果作为“签名值”给出。在这种情况下,解题者需要做的,恰恰是拿到对应的私钥去“解密”这个“签名”,才能得到原始数据。但题目只给了公钥,私钥呢?这就需要结合其他信息了。
另一种可能是,题目涉及了RSA的数学性质。RSA算法中,加密和解密、签名和验证,在数学上都是模幂运算,密钥对(e, d, n)满足m^(e*d) ≡ m (mod n)。在某些简化或错误的实现中,如果混淆了(e, n)和(d, n)的角色,就可能出现“用公钥指数e去进行签名生成运算”的情况。这时,如果你有私钥d,自然可以反向操作。但题目只给公钥,就可能需要利用RSA的其他漏洞,比如模数n分解、共模攻击、小指数攻击等,来破解出私钥信息,从而完成“验签”(实为解密)。
所以,面对“BUUCTF RSA公钥加签”,我们首先要做的是心态转换:别被字面意思带偏。它不是让你学习一个标准的签名流程,而是给你一个场景,其中“公钥”和“加签”这两个元素的组合是题目的突破口。你的任务不是实现标准签名,而是逆向这个非标准过程。接下来,我们就深入CTF实战场景,拆解这类题目的常见套路和解题工具箱。
2. 解题第一步:解剖题面与文件,识别真实操作
拿到一道CTF密码学题,尤其是RSA相关,第一步永远不是急着写脚本,而是仔细阅读题目的每一个字,并检查所有附件。对于“公钥加签”这类描述模糊的题,这一步更是至关重要。
通常,题目会提供一个压缩包或直接给出几个文件。常见的文件包括:
- 一个文本文件(
pubkey.txt或public.key):里面是RSA公钥。可能是PEM格式(-----BEGIN PUBLIC KEY-----),也可能是直接给出了(n, e)两个数字。 - 一个密文/签名文件(
flag.enc,signature.bin, 或直接写在描述里的一段十六进制/Base64字符串):这就是所谓的被“加签”后的数据。 - 可能有一个Python脚本(
task.py,challenge.py):展示了加密/签名过程。这是最重要的线索!一定要仔细分析。
假设我们有一个最典型的场景,题目描述为:“我们使用RSA公钥对flag进行了加签,你能找到flag吗?”,并附带了pubkey.pem和signature.bin。
首先,用openssl或Python的Crypto/cryptography库查看公钥详情:
openssl rsa -pubin -in pubkey.pem -text -modulus这会输出模数n(一个大整数)和公钥指数e(通常是65537)。记下这两个值。
然后,查看signature.bin文件。用十六进制查看器或Python读取:
with open('signature.bin', 'rb') as f: sig = f.read() print(sig.hex()) # 查看十六进制 print(len(sig)) # 查看字节长度关键比对:比较signature.bin的字节长度和模数n的字节长度(n.bit_length() // 8 + 1)。如果它们长度相近,那么signature.bin极有可能就是一个经过RSA模幂运算后的结果(即一个大整数),它要么是m^e mod n(加密),要么是hash(m)^d mod n(标准签名)。由于题目说是“公钥加签”,我们更倾向于猜测它是m^e mod n,也就是用公钥加密了消息m。
但这里有个死结:如果真是标准RSA加密,没有私钥d,我们无法解密。这就是CTF题目的设计点——它绝不会让你陷入真正的密码学困境。所以,我们需要寻找n或e的弱点。这就是下一步。
3. 核心攻击面:当RSA参数不再安全
在CTF的RSA题目中,安全的、大整数分解不可行的n是不会出现的(否则题目无解)。出题人一定会留下漏洞。针对“公钥加签”这种可能实质是“公钥加密”的题目,我们有几条经典的攻击路径。
3.1 模数分解:获取私钥的直球对决
这是最根本的方法。如果模数n可以被分解为两个大素数p和q,那么私钥d(满足e*d ≡ 1 mod φ(n),其中φ(n) = (p-1)*(q-1))就可以直接计算出来。
如何分解?
- 小素数:如果
n比较小(比如小于512比特),可以用本地工具如yafu、factordb.com网站或sage直接分解。 - 共用模数:如果题目给了多个公钥,它们可能有相同的
n。这非常危险,因为知道同一n对应的不同密钥对,可以通过计算最大公约数(GCD)来分解n。 - 素数生成不当:
- p和q过于接近:可以使用费马分解法。
- p或q太小:可以尝试用
pollard-rho算法爆破。 - 使用已知的素数:有时
n来自某些CTF常用素数库,可以尝试匹配。
实操步骤(以分解成功为例):假设我们用factordb.com查到了n = p * q。
from Crypto.Util.number import long_to_bytes, inverse import gmpy2 n = 123456789... # 你的模数 e = 65537 c = int.from_bytes(signature, 'big') # 假设signature是密文整数 p = 123... # 分解得到的p q = 123... # 分解得到的q phi = (p-1)*(q-1) d = inverse(e, phi) # 计算私钥指数d m = pow(c, d, n) # RSA解密:c^d mod n flag = long_to_bytes(m) print(flag)如果m解密出来是一段可读文本,可能就是flag。如果不是,可能需要继续处理(见下文)。
3.2 小公钥指数攻击:当e非常小时
在RSA中,公钥指数e通常取65537,这是一个在安全性和计算效率间平衡的值。但如果出题人将e设置得非常小(比如3,甚至2),而m也比较小,使得m^e < n,那么加密(或“加签”)运算c = m^e mod n实际上就等于m^e(因为没超过模数n)。这时,直接对c开e次方根,就能得到m。
如何判断?计算c = int(signature)。如果c^(1/e)是一个整数(或者非常接近整数),那么攻击就成功了。用gmpy2的iroot函数可以高效计算整数根。
import gmpy2 c = int.from_bytes(signature, 'big') e = 3 # 假设e=3 m, is_exact = gmpy2.iroot(c, e) if is_exact: flag = long_to_bytes(int(m)) print(flag)为什么“公钥加签”场景下可能出现小e?因为出题人可能为了简化计算,或者故意留下这个漏洞,让你忽略私钥,直接通过公钥参数和密文恢复消息。这完美契合了“只用公钥就能破解”的诡异感。
3.3 其他数学攻击与脚本识别
除了上述两种,还有共模攻击(多个密文同一n不同e)、低加密指数广播攻击(同一消息用不同n但相同小e加密)、维纳攻击(d太小)等。但这些更常见于标准的加密/解密题目。对于“加签”题,我们更需要关注题目附带的Python脚本。
仔细阅读脚本:脚本里可能隐藏了真正的“加签”逻辑。例如:
# 错误示例,但CTF中可能出现 def fake_sign(message, pub_key): n, e = pub_key m = bytes_to_long(message) # 这里用了公钥指数e进行运算,但称之为sign! s = pow(m, e, n) return long_to_bytes(s)看到这样的代码,你就立刻明白:所谓的“签名”s,其实就是m^e mod n,即公钥加密。你需要做的就是解密它。如果脚本里还显示了n是由两个特定的素数生成的,或者e是自定义的,那更是直接给出了攻击路径。
有时,脚本里会进行多次“加签”或奇怪的填充。例如,先对flag用公钥加密一次,再对结果用公钥加密一次(即c = (m^e)^e mod n = m^(e^2) mod n)。这本质上还是加密,只是指数变了。你需要解密的次数相应增加。
4. 数据预处理与后处理:Flag的“包装”与“拆包”
在CTF中,flag很少会被直接当作m进行RSA运算。通常会有各种预处理(编码、填充、转换)和后处理(输出格式)。在“公钥加签”题中,这些处理可能正是混淆的一部分。
常见预处理:
- 字符串转整数:
flag字符串先转换成bytes,再用bytes_to_long变成大整数m。这是标准操作。 - 拼接或填充:在
flag前后加上固定字符串(如'flag{' + real_flag + '}'),或者进行PKCS#1 v1.5之类的填充。填充会增加m的随机性和长度。 - 哈希:如果是标准签名,会对消息先哈希。但“公钥加签”可能省略这一步,直接对原始消息或简单处理后的消息运算。
常见后处理:
- 整数转字节:运算结果(大整数)会转换成字节,可能作为二进制文件
signature.bin给出。 - Base64/Hex编码:为了方便在题目描述中展示,这个字节串可能被进一步编码为Base64或十六进制字符串。你需要先解码还原成原始字节。
一个完整的处理链可能是:flag字符串->bytes->bytes_to_long->RSA运算(pow(m, e, n))->long_to_bytes->Base64编码-> 呈现在题面。
因此,你的解题脚本也需要逆向这个过程:
import base64 from Crypto.Util.number import long_to_bytes, bytes_to_long # 1. 从题面获取Base64密文 b64_cipher = "ABCDEFG...==" # 2. Base64解码得到字节串 cipher_bytes = base64.b64decode(b64_cipher) # 3. 字节串转整数(大端序) c = bytes_to_long(cipher_bytes) # 4. RSA解密(假设已通过分解n得到d) m = pow(c, d, n) # 5. 整数转字节串 flag_bytes = long_to_bytes(m) # 6. 尝试解码为字符串 try: flag = flag_bytes.decode('utf-8') print(flag) except UnicodeDecodeError: # 可能不是直接可读字符串,需要进一步分析 print(f"Raw bytes: {flag_bytes.hex()}")如果第6步解码失败,flag_bytes可能包含非ASCII字符,或者flag被藏在字节流的特定位置。你需要观察其十六进制形式,寻找像666c6167(‘flag’的hex)或7d(‘}’的hex)这样的模式,手动提取。
5. 实战演练:模拟一道“公钥加签”题
让我们虚构一道符合“BUUCTF RSA公钥加签”风格的题目,并一步步解构它。
题目描述:
我们开发了一个新的签名系统,为了提高效率,我们尝试使用公钥进行加签!这是公钥和签名结果,你能验证出消息吗? 附件:
pubkey.pem,signature.bin
步骤1:信息收集
$ openssl rsa -pubin -in pubkey.pem -text -modulus Public-Key: (256 bit) Modulus: 00:d0:8b:... (很长一串十六进制) Exponent: 3 (0x3) Modulus=D08B...发现关键信息:模数n只有256比特(非常小!),公钥指数e=3。这是一个强烈的信号:可能采用小公钥指数攻击。
步骤2:读取签名文件
with open('signature.bin', 'rb') as f: sig = f.read() print(f"Signature length: {len(sig)} bytes") # 输出可能是32字节(256位) print(f"Signature hex: {sig.hex()}") c = int.from_bytes(sig, 'big') print(f"Ciphertext as integer: {c}")步骤3:尝试小公钥指数攻击因为e=3,且n只有256位,c = m^3 mod n。我们首先尝试直接开立方根,看是否m^3 < n。
import gmpy2 m_candidate, is_exact = gmpy2.iroot(c, 3) if is_exact: print(f"Found exact root! m = {m_candidate}") flag = long_to_bytes(int(m_candidate)) print(f"Potential flag: {flag}") else: print("Not an exact cube root. Need to consider mod n.")如果is_exact为True,恭喜,直接得到m。但更可能的情况是m^3超过了n,所以c是m^3被n取模后的结果,直接开方无效。
步骤4:分解模数n256比特的n在CTF中几乎肯定是可以分解的。使用在线工具factordb.com或sage。 假设我们分解得到:
p = 123456791 q = 987654323 n = p * q = 121932631112359253验证一下n是否与公钥中的一致。
步骤5:计算私钥并解密
from Crypto.Util.number import inverse n = 121932631112359253 e = 3 c = ... # 从signature.bin读取的整数 p = 123456791 q = 987654323 phi = (p-1)*(q-1) # 计算私钥指数d,需要满足 e*d ≡ 1 mod phi # 注意:因为e=3,需要检查gcd(e, phi)是否为1。如果不是,则d不存在,RSA无效。 if gmpy2.gcd(e, phi) != 1: print("e and phi are not coprime, RSA invalid in this setting.") else: d = inverse(e, phi) m = pow(c, d, n) flag = long_to_bytes(m) print(f"Decrypted message: {flag}")如果一切顺利,flag就会以flag{...}的格式打印出来。
步骤6:处理意外情况如果解密出来的m转换成的字节不是可见字符串,可能是以下原因:
- Flag被反转了:尝试
flag_bytes[::-1]。 - Flag是hex编码:尝试
bytes.fromhex(flag_bytes.decode('ascii'))。 - 需要从长字节流中截取:搜索
b'flag{'或b'}'的索引。 - 解密结果还需要进一步运算:可能题目中的“加签”不是简单的
m^e mod n,而是(m + padding)^e mod n,你需要猜测或爆破padding。
6. 工具链与调试技巧:提升解题效率
工欲善其事,必先利其器。处理RSA题目,一个顺手的工具链能节省大量时间。
1. Python库:
PyCryptodome/Crypto:经典库,包含Crypto.Util.number模块,提供long_to_bytes,bytes_to_long,inverse,GCD等关键函数。gmpy2:处理大整数运算的利器,开方、模逆、素数检测速度极快。sympy:符号计算,有时用于解方程或分解中等大小的整数。requests:如果需要交互式攻击远程服务器。
2. 在线工具与网站:
factordb.com:分解模数n的首选。把n的十进制或十六进制值贴进去,经常有惊喜。RsaCtfTool:一个强大的RSA攻击集成工具(GitHub可搜)。它集成了数十种攻击方式(分解、维纳、共模、广播等),对于已知格式的公钥/密文,可以一键尝试所有攻击。CyberChef:瑞士军刀式的编解码网站。可以方便地在Hex、Base64、Raw bytes、整数之间转换,进行XOR、移位等操作。
3. 调试技巧:
- 打印中间变量:在解题脚本中,在每个关键步骤后打印出数据的长度、类型、前几个字节的hex值。这能帮你快速定位问题出在编码转换还是数学计算上。
- 假设验证:如果解密出一堆乱码,先别放弃。计算一下这个乱码字节串的整数形式,看看它是不是特别小(比如小于256),这可能意味着
m本身就是一个字节的值,或者flag是单字符。 - 边界检查:对于
pow(c, d, n)计算出的m,检查它是否小于n,以及转换成的字节长度是否合理(比如,如果n是1024位,解密出的m字节长度不应超过128字节)。
一个实用的解题脚本框架:
import base64 from Crypto.Util.number import long_to_bytes, bytes_to_long, inverse import gmpy2 # ---------- 1. 加载数据 ---------- # 从文件或题目描述中加载公钥(n, e)和密文c n = 0x1234... e = 65537 cipher_b64 = "..." # 解码密文 cipher_bytes = base64.b64decode(cipher_b64) c = bytes_to_long(cipher_bytes) # ---------- 2. 尝试攻击 ---------- # 攻击1: 检查n是否很小,尝试分解 # 手动去 factordb.com 查询 n # 攻击2: 如果e很小,尝试直接开方 if e == 3 or e == 2: m_root, exact = gmpy2.iroot(c, e) if exact: print(f"[!] Low exponent attack success! m = {long_to_bytes(int(m_root))}") exit() # 攻击3: 如果分解成功,常规解密 p = ... q = ... if p and q: phi = (p-1)*(q-1) if gmpy2.gcd(e, phi) == 1: d = inverse(e, phi) m = pow(c, d, n) flag_candidate = long_to_bytes(m) print(f"[*] Decrypted candidate: {flag_candidate}") # 尝试多种解码方式 try: print(f"[+] Flag (UTF-8): {flag_candidate.decode('utf-8')}") except: print(f"[+] Flag hex: {flag_candidate.hex()}") # 可能需要在hex中搜索flag格式 hex_str = flag_candidate.hex() if '666c6167' in hex_str: # 'flag' start = hex_str.find('666c6167') # 尝试提取... else: print(f"[!] e and phi not coprime. e={e}, gcd={gmpy2.gcd(e, phi)}")7. 从解题到理解:RSA签名与加密的本质再辨析
通过解这道“公钥加签”题,我们实际上被迫深刻理解了RSA中加密和签名的对称性。从数学上看,RSA公钥操作(n, e)和私钥操作(n, d)都是模幂运算,它们互为逆运算。
- 加密/解密视角:为了保密。发送者用接收者的公钥(e)加密:
c = m^e mod n。接收者用自己的私钥(d)解密:m = c^d mod n。 - 签名/验证视角:为了认证和完整性。签名者用自己的私钥(d)对消息哈希值
h进行“签名”运算:s = h^d mod n。验证者用签名者的公钥(e)进行“验证”运算:h' = s^e mod n,并对比h'和计算出的h。
注意这两个等式的形式:c = m^e mod n与h' = s^e mod n都使用了公钥指数e。m = c^d mod n与s = h^d mod n都使用了私钥指数d。
所以,从纯数学计算的角度看:
- 用公钥(e)运算,可能是加密(对消息m),也可能是验证签名(对签名值s)。
- 用私钥(d)运算,可能是解密(对密文c),也可能是生成签名(对哈希值h)。
“公钥加签”这个说法,在数学上等价于“用公钥指数e对某个数据做模幂运算”。如果这个数据是原始消息m,那就是加密。如果这个数据是消息的哈希值h,但用公钥运算,那在标准体系里是验证,但验证不会叫“加签”。因此,题目语境下的“加签”,几乎可以确定是指非标准的、概念混淆的“用公钥进行了一次类似加密的操作”。
理解这一点,就能跳出术语的桎梏,直指问题的核心:无论它叫什么,你拿到的是一个公钥(n, e)和一个经过data ^ e mod n计算后的结果。你的目标是从这个结果还原出data。还原的方法,要么是找到d(通过分解n等),要么是利用e或n的弱点(如小e、可分解n)。
8. 举一反三:其他可能变体与防御性思考
CTF题目不会一成不变。围绕“公钥”和“加签”,还有一些常见的变体:
多次“加签”:
c = pow(m, e**k, n)。即用公钥指数e连续加密k次。解密就需要连续解密k次,前提还是你需要私钥d。如果k不大,且你有d,那么m = pow(c, d**k, n)。但更可能的是,出题人希望你注意到e**k可能很大,导致新的指数与phi(n)不互质,从而无法解密,引导你寻找其他路径(比如直接分解n)。“加签”前混淆:不是直接对
m运算,而是对m进行某种可逆变换(如与固定值XOR,或加上一个常数)后再运算。解题时需要先解密,再逆向这个变换。给出多个“签名”:对同一个消息m,用同一个公钥但不同的padding(或随机数)进行多次“加签”,产生多个
c_i。这可能指向相关消息攻击或Franklin-Reiter相关消息攻击。隐藏公钥参数:公钥文件可能被损坏,或者e、n被以特殊格式隐藏(如图片隐写、内存dump)。需要先进行隐写分析或数据提取,才能获得攻击所需的参数。
从防御视角看,这道题给我们敲响了警钟:
- 切勿混淆加密和签名:在设计和实现密码系统时,必须严格区分加密和签目的,使用标准的、经过验证的算法和填充方案(如OAEP for加密,PSS for签名)。
- 参数必须安全:RSA的模数n必须足够大(目前建议至少2048位),并且由安全的随机素数生成。公钥指数e应使用65537,避免使用小值。
- 不要自己发明密码学:正如这道题中“公钥加签”这种非标准操作是危险的,在实际开发中,绝对不要尝试修改或创造新的密码学原语,应使用权威库(如
cryptography)提供的高级API。
最后,解CTF题的过程,是一个将理论知识与实战技巧结合,并不断进行逻辑推理和试错的过程。“BUUCTF RSA公钥加签”这类题目,与其说在考一个具体的算法,不如说在考一种思维灵活性——不被表面描述迷惑,直击底层数学原理和实现细节的能力。下次再看到令人困惑的术语,不妨先把它翻译成:“这里有一个用RSA公钥参数进行的模幂运算,以及运算结果,请找出输入。” 然后,你的武器库(分解、小指数、共模、脚本分析等)就可以有条不紊地派上用场了。