
1. 从一道CTF题看RSA的“简单”陷阱最近在复盘一些经典的CTF密码学题目翻到了BJDCTF2020里的这道easyrsa。题目名字叫“easy”但做过的朋友都知道CTF里的“easy”往往意味着“坑点很基础但你没看出来就等着哭吧”。这道题就是一个典型的例子它没有复杂的数学攻击而是巧妙地利用了RSA算法实现中的一个常见但容易被忽略的细节。今天我就结合这道题把RSA从密钥生成到加解密再到CTF中常见的那些“非对称”考点系统地拆解一遍。无论你是刚接触Crypto的新手还是想巩固基础的老手相信这篇详细的Writeup和原理剖析都能让你有所收获。这道题的核心表面上是考察RSA解密但实际上它真正的考点藏在pow(c, d, n)这个最简单的解密公式背后。很多人在学习RSA时只记住了m c^d mod n这个公式却忽略了公式成立的前提条件以及d这个私钥指数到底是怎么来的。easyrsa正是从这个盲区入手给你一个看似正常的n,e,c但如果你不假思索地直接去计算私钥d就会立刻掉进坑里。接下来我们就一步步还原解题过程并深入聊聊背后的密码学原理和那些你必须知道的“避坑指南”。2. 题目回顾与初步分析首先我们明确一下CTF题目的常见形式。通常你会拿到一个文本文件或一段描述里面包含类似这样的数据n 123456789... (一个非常大的整数) e 65537 c 987654321... (密文也是一个非常大的整数)你的任务就是利用这些信息解出明文m。标准的RSA解密流程是对n进行因数分解得到两个大素数p和q。计算欧拉函数 φ(n) (p-1)*(q-1)。计算私钥指数d它是e关于模φ(n)的模逆元即满足e*d ≡ 1 (mod φ(n))。计算明文m c^d mod n。在easyrsa这道题里你拿到手的就是n,e,c。一个天真的做法是立刻尝试去分解n。如果n不大或者有弱点比如是两个很接近的素数那么用yafu或者factordb这样的工具或许能快速分解。但这道题的第一个“简单”之处就在于它的n很可能是无法直接分解的否则就真成了送分题。这迫使我们去思考是不是所有的RSA题目第一步都必须是分解n答案是否定的。这里就引出了RSA算法的一个关键点私钥d并不是只能通过p和q计算出来。实际上d的定义是e在模φ(n)下的逆元。如果我们能以某种方式直接得到φ(n)那么即使不知道p和q也能算出d。这就是本题的突破口。题目可能隐含了φ(n)的值或者n与φ(n)存在某种特殊关系使得我们可以绕过分解直接求解。另一种常见情况是公钥指数e非常小比如e3或者非常大接近n这可能对应着低加密指数攻击或维纳攻击等。但本题的e是常见的65537排除了这种特殊攻击。因此我们的注意力应该回到n和φ(n)的关系上。3. 深入原理RSA密钥生成与欧拉函数要理解可能的陷阱我们必须重新审视RSA密钥的生成过程。以下是标准的步骤选择两个大素数随机选择两个足够大、长度相近的素数p和q。这是安全的基础。计算模数n p * q。这是公钥和私钥共有的部分。计算欧拉函数φ(n) (p-1) * (q-1)。这里的φ(n)是欧拉函数表示在小于n的正整数中与n互质的数的个数。对于两个素数的乘积这个公式成立。选择公钥指数选择一个整数e满足1 e φ(n)且gcd(e, φ(n)) 1即e与φ(n)互质。通常选择65537因为它二进制表示中1很少计算效率高且安全性好。计算私钥指数计算d使得e * d ≡ 1 (mod φ(n))。d就是e关于模φ(n)的模逆元。这里有一个至关重要的细节整个系统的安全性依赖于φ(n)的保密性。因为一旦知道φ(n)就可以在不知道p和q的情况下直接计算dd gmpy2.invert(e, phi)使用Python的gmpy2库而知道n和φ(n)甚至可以反推出p和q。我们设n p * q φ(n) (p-1)*(q-1) p*q - p - q 1 n - (pq) 1那么我们可以得到p q n - φ(n) 1设s p q。又因为(p - q)^2 (pq)^2 - 4n s^2 - 4n。 所以p - q sqrt(s^2 - 4n)。 最后解方程p (s (p-q)) / 2 q (s - (p-q)) / 2因此φ(n)和(p, q)在信息上是等价的。泄露φ(n)等同于泄露了私钥。回到easyrsa题目会不会直接把φ(n)给我们了呢通常不会这么明显。更可能的情况是出题人构造了一个特殊的n使得φ(n)与n有某种简单的关系或者我们在计算过程中能轻易推导出φ(n)。例如一种经典的陷阱是当p和q都是素数但n并不是标准的p*q或者φ(n)的计算方式不是(p-1)*(q-1)。这听起来有点反直觉但在CTF中出题人有时会利用我们对公式的“肌肉记忆”来设置陷阱。4. 解题实战识别并利用特殊关系假设我们拿到了题目的具体数值为了讲解这里使用一组模拟数据原理相通n 112813799985004561249214542913328271626723356683471545653618496547726959293420798695013136551874141854078719183984329923590899316153206778500611288123562655111404665289848227533030998583118695890225827088300065025430260602263009152375216404255249307261638195155151456752382890740358306363872255212773634040023 e 65537 c 70009801067575633181507978696176915745729561431653778886744389228761510091726658252464934692844601752383149366595302177725340660939743340841097337925412776761171020263373658288777256257544952894531185886185789758512427074103647306677509171470206075904715500728489659660785739642953819971275055512913664448870第一步我们尝试用常规思路。使用factordb或yafu分解n。如果几分钟内没有结果基本可以断定这不是一道分解题。这时我们就应该高度怀疑n或φ(n)有特殊之处。一个关键的检查点是尝试用n本身去计算d。什么意思在正常的RSA中我们计算d invert(e, phi)其中phi(p-1)*(q-1)。但如果出题人犯了一个“低级错误”误将n当作φ(n)来生成私钥d了呢也就是说他实际计算的d满足e * d ≡ 1 (mod n) 而不是mod φ(n)。那么我们用这个错误的d去解密m pow(c, d, n)得到的结果大概率是乱码。但easyrsa这道题的精妙之处可能就在这里它或许就是故意用n代替φ(n)来生成的密钥对。如果是这样那么我们用n作为模数来计算d反而是正确的解密路径让我们用Python验证这个猜想import gmpy2 from Crypto.Util.number import long_to_bytes n 112813799985004561249214542913328271626723356683471545653618496547726959293420798695013136551874141854078719183984329923590899316153206778500611288123562655111404665289848227533030998583118695890225827088300065025430260602263009152375216404255249307261638195155151456752382890740358306363872255212773634040023 e 65537 c 70009801067575633181507978696176915745729561431653778886744389228761510091726658252464934692844601752383149366595302177725340660939743340841097337925412776761171020263373658288777256257544952894531185886185789758512427074103647306677509171470206075904715500728489659660785739642953819971275055512913664448870 # 尝试用 n 代替 phi 来计算 d try: d_wrong gmpy2.invert(e, n) # 注意这里模数是 n而不是 phi(n) m_wrong pow(c, d_wrong, n) print(用n作为模数计算的‘私钥’d:, d_wrong) print(解密结果长整数:, m_wrong) print(尝试转换为字节:, long_to_bytes(m_wrong)) except Exception as ex: print(计算逆元失败:, ex)如果这段代码运行后long_to_bytes(m_wrong)输出了一段有意义的字符串比如包含flag{或BJD{等标志那么恭喜你找到了本题的答案。这就是easyrsa的“简单”所在——它考察的不是高深的数论攻击而是你对RSA算法每一步定义的理解是否扎实。你能否意识到解密公式m c^d mod n成立的前提是d必须由φ(n)正确计算得出如果密钥生成时就用错了模数那么整个加解密过程就变成了基于另一个数学关系的、脆弱的“类RSA”系统。注意在实际的CTF比赛中题目数据是固定的上述模拟数据可能不会直接成功。但解题思路是一致的当常规分解走不通时检查是否d是基于n而非φ(n)计算的。此外还可能存在其他变种比如φ(n)被给成了(p-1)*(q-1)的倍数或约数或者n本身是一个素数的幂次方此时φ(n)的计算公式不同。这就需要我们根据具体数值进行试探和分析。5. 举一反三RSA在CTF中的其他常见“简单”考点通过easyrsa这一道题我们可以扩展到CTF中RSA题目的其他基础考点。这些考点往往不涉及复杂的攻击但考验选手对算法本身的理解深度和细心程度。5.1 模数n可以被直接分解这是最简单的情况。当n较小通常小于512位或者是由弱素数如p和q非常接近生成时可以在短时间内被分解。工具推荐在线网站factordb.com。它有一个庞大的已知因数数据库可能是你的第一选择。本地工具yafu“又一个因式分解工具”。它对很多特殊形式的数分解效率很高。Python库sympy的factorint函数适用于小整数。实操心得拿到n后先扔到factordb查一下说不定秒出结果。如果不行再用yafu尝试factor(n)。对于接近的p和q可以利用费马分解法其原理是如果p和q接近则(pq)/2接近sqrt(n)(p-q)/2很小。yafu会自动尝试多种算法。5.2 共模攻击当同一个明文m用相同的n但不同的公钥指数e1和e2加密得到密文c1和c2且gcd(e1, e2)1时可以在不知道私钥的情况下解密。利用扩展欧几里得算法找到r和s使得e1*r e2*s 1。那么明文m (c1^r * c2^s) mod n。为什么可行因为c1^r * c2^s ≡ (m^e1)^r * (m^e2)^s ≡ m^(e1*r e2*s) ≡ m^1 ≡ m (mod n)。这提醒我们绝对不要在不同系统中重用相同的RSA模数n。5.3 低加密指数攻击当公钥指数e很小如3并且明文m也很小使得m^e n时加密实际上没有取模操作因为c m^e在整数范围内。此时直接对密文c开e次方根即可得到明文m。防御方法总是使用标准的、足够大的e如65537并在加密前对明文进行适当的填充如OAEP确保m与n同量级。5.4 维纳攻击当私钥指数d相对n来说过小时存在一种高效的攻击方法称为维纳攻击。它基于连分数理论。一个经验法则是如果d (1/3) * n^(1/4)则私钥d可以在多项式时间内被恢复。因此在生成密钥时d不能太小。5.5p和q选取不当p和q过于接近如前所述可用费马分解法。p-1或q-1光滑即p-1的质因数都很小。这会导致n可以被波拉德p-1分解法快速分解。p和q由可预测的模式生成例如p和q是某个简单序列中的连续素数。这大大降低了搜索空间。避坑指南在实际应用或CTF出题中务必使用安全的随机数生成器来生成大素数并且确保它们满足安全要求如长度相等、差值大、p-1和q-1有大素因子等。6. 工具链与实战脚本编写对于CTF选手来说有一套顺手的工具和脚本模板至关重要。以下是我在处理RSA题目时的常用工具链和Python脚本片段。环境准备Python3主力编程语言。gmpy2库提供高精度大整数运算和高效的模逆、模幂计算。pip install gmpy2。pycryptodome库提供了丰富的密码学原语和工具函数如long_to_bytes,bytes_to_long。pip install pycryptodome。sage数学软件对于更复杂的数论计算和某些特定攻击如基于格的攻击非常有用。可以在线使用Cocalc或本地安装。万能解题脚本框架 你可以创建一个rsa_tool.py的模板包含以下函数from Crypto.Util.number import long_to_bytes, bytes_to_long, inverse import gmpy2 import sympy def factorize_n(n): 尝试分解n返回p, q # 1. 尝试factordb (这里需要网络请求略) # 2. 尝试sympy适用于小整数 factors sympy.factorint(n) if len(factors) 2 and all(pow(key, value) key for key, value in factors.items()): p, q list(factors.keys()) return p, q else: print(Sympy分解失败或n不是两个素数的乘积。) return None, None # 3. 可以集成调用yafu的命令行需本地安装yafu def rsa_decrypt(n, e, c, pNone, qNone, phiNone, dNone): 通用的RSA解密函数 # 如果直接给了d if d is not None: m pow(c, d, n) return long_to_bytes(m) # 如果给了p和q if p is not None and q is not None: phi (p-1)*(q-1) d gmpy2.invert(e, phi) m pow(c, d, n) return long_to_bytes(m) # 如果直接给了phi if phi is not None: d gmpy2.invert(e, phi) m pow(c, d, n) return long_to_bytes(m) # 如果什么都没给尝试用n当phi针对easyrsa这类题 try: d_wrong gmpy2.invert(e, n) m_wrong pow(c, d_wrong, n) result long_to_bytes(m_wrong) if bflag in result or bCTF in result or bBJD in result: # 常见flag格式 print(Warning: Used n as phi. This might be the intended solution!) return result except Exception as e: pass print(Insufficient parameters to decrypt.) return None # 使用示例 if __name__ __main__: n 0xabc... # 你的n e 65537 c 0xdef... # 你的c # 方法1: 尝试分解 p, q factorize_n(n) if p and q: print(fFound p{p}, q{q}) plaintext rsa_decrypt(n, e, c, pp, qq) print(Decrypted with p,q:, plaintext) # 方法2: 如果无法分解尝试phin的陷阱 plaintext rsa_decrypt(n, e, c) # 这会触发内部用n当phi的尝试 if plaintext: print(Decrypted with potential phin trick:, plaintext)这个框架覆盖了常规解密和easyrsa这类特殊陷阱。当遇到新题时可以快速修改和测试。7. 从CTF到真实世界RSA的正确实现与安全考量CTF题目是真实世界问题的简化或极端化体现。easyrsa反映了一个现实密码学算法的安全性不仅依赖于数学难题的硬度还依赖于其实现的正确性。一个微小的实现错误如用错模数就可能导致整个安全体系崩塌。在真实的软件开发中RSA的正确使用需要注意以下几点不要自己实现密码学核心永远使用久经考验的、权威的密码学库如OpenSSL, libsodium, 或你所用语言的标准密码学库如Python的cryptography。这些库已经处理了所有底层的复杂性和陷阱。使用正确的填充方案原始的“教科书式RSA”即m^e mod n是不安全的因为它具有确定性同样的明文产生同样的密文和可塑性等弱点。必须使用像OAEP最优非对称加密填充这样的填充方案。在CTF中为了简化题目经常使用无填充的RSA但在现实中绝对不行。密钥生成要安全确保素数p和q是随机、独立、长度足够目前推荐至少2048位、且满足安全条件的。避免使用任何有缺陷的随机数生成器。保护私钥私钥d和中间参数p,q,φ(n)必须严格保密。一旦泄露应立即撤销证书和密钥对。理解算法的边界RSA加密有长度限制它通常用于加密对称密钥如AES密钥而不是直接加密大量数据。大数据加密应采用混合加密体系用RSA加密一个随机的对称密钥再用该对称密钥加密实际数据。easyrsa这道题虽然简单但它像一面镜子照出了我们对基础知识的掌握程度。在CTF赛场上时间紧迫压力巨大越是看到“easy”越要警惕。它提醒我们在冲向复杂的攻击手段之前先停下来重新审视一遍最基础的定义和流程答案往往就藏在那些我们自以为熟练掌握、实则一知半解的地方。下次再遇到RSA题不妨先问自己这里的n、e、c、d、φ(n)它们之间的关系真的如我所想吗