文章总结: 本文解析Hack.lu2017CTF的Salsa密码题。文章指出Salsa20实现中错误地将计数器当作字节偏移量,导致密钥流复用漏洞。攻击者利用服务端回显功能,发送已知明文构造密文对,通过异或运算还原密钥流并解密Flag。文中提供了完整Python利用脚本,并总结流密码安全原则与漏洞成因。 综合评分: 98 文章分类: CTF,漏洞分析,代码审计,漏洞POC
Hack.lu 2017 CTF Crypto 题解:Salsa
原创
破镜安全 破镜安全
破镜安全
2026年3月2日 08:00 四川
Hack.lu 2017 CTF Crypto 题解:Salsa
前言
本文分析 Hack.lu 2017 CTF 竞赛中的一道密码学题目,题目名称为 salsa,考察对 Salsa20 流密码算法的理解,以及如何利用其错误实现中的密钥流复用漏洞恢复明文。
题目文件总览
题目给出两个文件:
server.py:服务端逻辑,负责接收连接、发送加密 flag、回显加密数据salsa20.py:Salsa20 算法的完整实现
第一步:理解 Salsa20 算法
Salsa20 是一种流密码(Stream Cipher),由 Daniel J. Bernstein 设计。流密码的工作方式是:根据密钥(Key)和随机数(Nonce)生成一段伪随机的”密钥流”(Keystream),然后将密钥流与明文按字节逐一做异或(XOR)运算,得到密文。解密时,用同样的密钥流再次异或密文即可还原明文。
其核心结构可以用下面的公式表示:
密文[i] = 明文[i] XOR 密钥流[i]
Salsa20 内部将密钥流按 64 字节为一个块(Block)生成。每个块的生成依赖:密钥(Key)、随机数(Nonce)以及一个块计数器(Block Counter)。块计数器从 0 开始,每生成一个 64 字节块后加 1。
正确的 Salsa20 行为是:无论从哪个计数器值开始加密,都从该计数器对应的 64 字节块的第 0 个字节开始使用密钥流。
第二步:阅读服务端逻辑
KEY = os.urandom(32)
class TCPHandler(asyncore.dispatcher_with_send):
def __init__(self, socket):
asyncore.dispatcher_with_send.__init__(self, socket)
self.Nonce = os.urandom(8)
self.MsgCounter = 0
self.sendMessage("Hello my dear friend, the flag is %s!" % FLAG)
def sendMessage(self, data):
text = base64.b64encode(data)
for i in range(0, len(text), 128):
msg = {"cnt" : self.MsgCounter, "data" : text[i: i+128]}
msgtext = json.dumps(msg)
encdata = salsa20.s20_crypt(KEY, self.Nonce, self.MsgCounter, msgtext)
self.MsgCounter += 1
self.send(encdata)
def handle_read(self):
data = self.recv(8192)
if data:
self.sendMessage(data)
服务端的行为如下:
- 每次建立连接时,随机生成一个 32 字节的 KEY 和一个 8 字节的 Nonce,并初始化消息计数器
MsgCounter = 0。 - 立即将 flag 消息通过
sendMessage发送出去。sendMessage会先对数据做 Base64 编码,然后把编码后的内容和当前计数器值一起包装成 JSON 格式,调用s20_crypt加密后发送,同时将MsgCounter加 1。 - 当收到客户端发来的任意数据时,同样通过
sendMessage将其回显,即用当前计数器值加密后发送回来。
因此,客户端连接后收到的第一条消息是用 MsgCounter = 0 加密的,明文格式为:
{"cnt": 0, "data": "<base64编码的flag消息>"}
客户端发送任意数据后,收到的回显消息是用 MsgCounter = 1 加密的,明文格式为:
{"cnt": 1, "data": "<base64编码的我们发送的数据>"}
第三步:发现密钥流复用漏洞
现在来看 s20_crypt 函数的实现:
def s20_crypt(key, nonce, si, data):
key = [ord(c) for c in key]
nonce = [ord(c) for c in nonce]
n = [0] * 16
for i in range(8):
n[i] = nonce[i]
if (si % 64) != 0:
b0, b1, b2, b3 = s20_rev_littleendian(si / 64)
n[8] = b0
...
keystream = s20_expand32(key, n)
outp = ""
for i, c in enumerate(data):
if ((si+i) % 64) == 0:
b0, b1, b2, b3 = s20_rev_littleendian((si+i) / 64)
n[8] = b0
...
keystream = s20_expand32(key, n)
outp += chr(ord(c) ^ keystream[(si+i) % 64])
return outp
关键在于这一行:
outp += chr(ord(c) ^ keystream[(si+i) % 64])
这里用来取密钥流字节的下标是 (si + i) % 64。其中:
si是传入的计数器值(即MsgCounter)i是当前正在处理的字节在数据中的位置(从 0 开始)(si + i)是这个字节的”全局位置”
函数在加密每个字节时,取的是全局位置 (si + i) 对应的那个密钥流字节。换句话说,它把 MsgCounter 当作全局字节偏移量来使用。
用具体数字来说明这个问题:
当 si = 0(第一条消息,加密 flag)时,第 i 个字节使用的是密钥流的第 i 个字节:
C0[i] = P0[i] XOR KS[i]
当 si = 1(第二条消息,加密回显数据)时,第 i 个字节使用的是密钥流的第 i+1 个字节:
C1[i] = P1[i] XOR KS[i+1]
对比两者:
| 全局位置 | 加密 flag 时 (si=0) | 加密回显时 (si=1) | | — | — | — | | 1 | C0[1] = P0[1] XOR KS[1] | C1[0] = P1[0] XOR KS[1] | | 2 | C0[2] = P0[2] XOR KS[2] | C1[1] = P1[1] XOR KS[2] | | k | C0[k] = P0[k] XOR KS[k] | C1[k-1] = P1[k-1] XOR KS[k] |
两条消息在全局位置 k 上使用的是同一个密钥流字节 KS[k]。 这就是密钥流复用。
这与标准 Salsa20 的行为完全不同。标准实现中,无论计数器值是多少,加密总是从该计数器对应的 64 字节块的起点(偏移 0)开始,不同消息的密钥流不会发生这种一位偏移的重叠。
第四步:推导攻击方案
由于:
C0[k] = P0[k] XOR KS[k]
C1[k-1] = P1[k-1] XOR KS[k]
两式相减(XOR 运算自反,XOR 即减法),得:
C0[k] XOR C1[k-1] = P0[k] XOR P1[k-1]
如果 P1(即第二条消息的明文)完全已知,那么只需将 C1 和 P1 做异或,就能得到密钥流:
KS[k] = C1[k-1] XOR P1[k-1]
拿到密钥流之后,就可以解密第一条消息(flag 的密文):
P0[k] = C0[k] XOR KS[k]
因此,攻击的核心是:构造一段完全已知明文的数据发给服务器,让服务器用计数器 1 加密后回显,从而还原密钥流,进而解密 flag。
第五步:计算需要发送的数据长度
首先需要确定第一条消息(flag 密文)的长度。
flag 消息明文的结构是:
{"cnt": 0, "data": "<base64(Hello my dear friend, the flag is FLAG!)>"}
假设原始 flag 字符串总长约 50 字节,Base64 编码后约 68 字节,整个 JSON 字符串约 90 字节。连接后接收到的第一条密文长度设为 flag_len。
第二条消息(回显)的明文结构是:
{"cnt": 1, "data": "<base64(我们发送的数据)>"}
JSON 的固定部分 {"cnt": 1, "data": ""} 共 22 个字符。要使回显消息的总长度等于 flag_len,则 Base64 编码后的部分需要占 flag_len - 22 个字节,即我们的原始数据 Base64 编码后需恰好是 flag_len - 22 个字节。
我们可以直接发送长度为 flag_len - 22 个 A 字母的 Base64 编码数据。AAAA...A(全 A 字符的 Base64)解码后再编码回来,结果仍是 AAAA...A,因此回显的 data 字段内容完全可预期。
payload_b64 = 'A' * (flag_len - 22)
payload_raw = base64.b64decode(payload_b64)
将 payload_raw 发送给服务器,服务器回显的密文所对应的明文将是:
{"cnt": 1, "data": "AAAA...A"}
这是完全已知的明文,长度与 flag_len 相同。
第六步:恢复密钥流
设:
flag_obj:收到的 flag 密文(字节序列)echo_plaintext:我们构造的完全已知的回显明文echo_obj:服务器回显的密文(字节序列)
由于回显消息用 si = 1 加密,则:
echo_obj[i] = echo_plaintext[i] XOR KS[i+1]
因此:
KS[i+1] = echo_obj[i] XOR echo_plaintext[i]
用代码表示:
echo_plaintext = '{"cnt": 1, "data": "' + 'A' * (flag_len - 22) + '"}'
# 从 i=0 开始还原密钥流,注意对应关系是 KS[i+1]
keystream = [0] # KS[0] 无法从此方法还原,用 0 占位
for i in range(len(echo_obj)):
keystream.append(ord(echo_obj[i]) ^ ord(echo_plaintext[i]))
这里 keystream[k] 就是 KS[k](对于 k >= 1)。KS[0] 因为没有对应的已知明文-密文对,无法直接还原。但 flag 密文的第 0 个字节对应的明文是 {(JSON 的固定开头),这一个字节可以通过推断或忽略来处理,不影响 flag 的最终提取。
第七步:解密 flag
flag 密文用 si = 0 加密,每个字节满足:
flag_obj[k] = P0[k] XOR KS[k]
因此:
P0[k] = flag_obj[k] XOR KS[k]
用代码表示:
plaintext = ''.join([chr(ord(flag_obj[k]) ^ keystream[k]) for k in range(len(flag_obj))])
得到的 plaintext 即为:
{"cnt": 0, "data": "<base64编码的flag消息>"}
解析 JSON 并 Base64 解码 data 字段,即可得到:
Hello my dear friend, the flag is FLAG{...}!
完整利用代码
from pwn import *
import json
import base64
p = remote('flatearth.fluxfingers.net', 1721)
# 第一步:接收加密的 flag 消息
flag_obj = p.recv()
flag_len = len(flag_obj)
print('[+] 收到 flag 密文,长度 = %d' % flag_len)
# 第二步:构造已知明文的 payload
# 目标:让服务器回显的明文为 {"cnt": 1, "data": "AAAA...A"}
# 固定 JSON 部分 '{"cnt": 1, "data": ""}' 共 22 字节
# 因此 data 字段需要 flag_len - 22 个 'A'
payload_b64 = 'A' * (flag_len - 22)
payload_raw = base64.b64decode(payload_b64)
p.send(payload_raw)
# 第三步:接收回显密文
echo_obj = p.recv()
echo_plaintext = '{"cnt": 1, "data": "' + 'A' * (flag_len - 22) + '"}'
# 第四步:恢复密钥流
# echo_obj[i] = echo_plaintext[i] XOR KS[i+1]
# => KS[i+1] = echo_obj[i] XOR echo_plaintext[i]
keystream = [0] # KS[0] 无法恢复,用 0 占位
for i in range(len(echo_obj)):
keystream.append(ord(echo_obj[i]) ^ ord(echo_plaintext[i]))
# 第五步:解密 flag
plaintext = ''.join([chr(ord(flag_obj[k]) ^ keystream[k]) for k in range(len(flag_obj))])
print('[+] 解密结果:', plaintext)
# 第六步:提取 flag
import re
obj = json.loads('{' + plaintext[1:]) # 修复第 0 个字节
flag_message = base64.b64decode(obj['data']).decode()
print('[+] FLAG:', flag_message)
漏洞根本原因分析
这道题的漏洞源于一个对 Salsa20 计数器语义的误解。
正确实现中,计数器(Block Counter)表示的是”当前生成第几个 64 字节密钥流块”,加密任意数据时,始终从选定块的第 0 个字节开始。不同计数器值对应完全独立的 64 字节块,块与块之间没有重叠。
本题的错误实现中,计数器(MsgCounter)被当作全局字节偏移量使用,即”从密钥流的第几个字节处开始”。这导致:
- 计数器 0 使用密钥流字节 KS[0], KS[1], …, KS[N]
- 计数器 1 使用密钥流字节 KS[1], KS[2], …, KS[N+1]
- 计数器 2 使用密钥流字节 KS[2], KS[3], …, KS[N+2]
每次计数器增加 1,密钥流只向后”滑动”一个字节。相邻两次加密的密钥流几乎完全相同,仅相差一个字节的偏移。
这本质上是一种”两次密钥流复用”(Two-Time Pad)攻击场景的变体。一旦攻击者能够控制其中一条消息的明文,即可完整还原密钥流,进而解密另一条消息。
关键知识点总结
流密码安全使用原则:同一个(密钥,随机数)对下生成的密钥流,绝对不能被用于加密两条不同的消息。一旦复用,攻击者将密文两两异或即可消去密钥流,直接得到两段明文的异或结果,配合已知明文攻击即可逐步还原所有内容。
Salsa20 计数器的正确含义:计数器是块级别的,用于寻址独立的 64 字节块,而不是字节级别的全局偏移量。
已知明文攻击:当攻击者能够选择部分明文并观察对应密文时,即可通过异或运算直接还原密钥流,这是对流密码最直接、最高效的攻击手段。
服务器回显功能的危险性:当加密服务提供”回显”功能,且回显消息与 flag 消息共用同一套密钥和随机数时,攻击者可以将服务器当作解密机,通过精心构造的已知明文恢复密钥流。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:破镜安全 破镜安全 破镜安全《Hack.lu 2017 CTF Crypto 题解:Salsa》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论