Hack.lu2017CTFCrypto题解:Salsa

admin 2026-03-17 23:37:09 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文解析Hack.lu2017CTF的Salsa密码题。文章指出Salsa20实现中错误地将计数器当作字节偏移量,导致密钥流复用漏洞。攻击者利用服务端回显功能,发送已知明文构造密文对,通过异或运算还原密钥流并解密Flag。文中提供了完整Python利用脚本,并总结流密码安全原则与漏洞成因。 综合评分: 98 文章分类: CTF,漏洞分析,代码审计,漏洞POC


cover_image

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)

服务端的行为如下:

  1. 每次建立连接时,随机生成一个 32 字节的 KEY 和一个 8 字节的 Nonce,并初始化消息计数器 MsgCounter = 0
  2. 立即将 flag 消息通过 sendMessage 发送出去。sendMessage 会先对数据做 Base64 编码,然后把编码后的内容和当前计数器值一起包装成 JSON 格式,调用 s20_crypt 加密后发送,同时将 MsgCounter 加 1。
  3. 当收到客户端发来的任意数据时,同样通过 sendMessage 将其回显,即用当前计数器值加密后发送回来。

因此,客户端连接后收到的第一条消息是用 MsgCounter = 0 加密的,明文格式为:

{"cnt": 0, "data": "<base64编码的flag消息>"}

客户端发送任意数据后,收到的回显消息是用 MsgCounter = 1 加密的,明文格式为:

{"cnt": 1, "data": "<base64编码的我们发送的数据>"}

第三步:发现密钥流复用漏洞

现在来看 s20_crypt 函数的实现:

def&nbsp;s20_crypt(key, nonce, si, data):
&nbsp; &nbsp; key = [ord(c)&nbsp;for&nbsp;c&nbsp;in&nbsp;key]
&nbsp; &nbsp; nonce = [ord(c)&nbsp;for&nbsp;c&nbsp;in&nbsp;nonce]

&nbsp; &nbsp; n = [0] *&nbsp;16

&nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(8):
&nbsp; &nbsp; &nbsp; &nbsp; n[i] = nonce[i]

&nbsp; &nbsp;&nbsp;if&nbsp;(si %&nbsp;64) !=&nbsp;0:
&nbsp; &nbsp; &nbsp; &nbsp; b0, b1, b2, b3 = s20_rev_littleendian(si /&nbsp;64)
&nbsp; &nbsp; &nbsp; &nbsp; n[8] = b0
&nbsp; &nbsp; &nbsp; &nbsp; ...
&nbsp; &nbsp; &nbsp; &nbsp; keystream = s20_expand32(key, n)

&nbsp; &nbsp; outp =&nbsp;""

&nbsp; &nbsp;&nbsp;for&nbsp;i, c&nbsp;in&nbsp;enumerate(data):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;((si+i) %&nbsp;64) ==&nbsp;0:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; b0, b1, b2, b3 = s20_rev_littleendian((si+i) /&nbsp;64)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; n[8] = b0
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ...
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; keystream = s20_expand32(key, n)

&nbsp; &nbsp; &nbsp; &nbsp; outp += chr(ord(c) ^ keystream[(si+i) %&nbsp;64])

&nbsp; &nbsp;&nbsp;return&nbsp;outp

关键在于这一行:

outp += chr(ord(c) ^ keystream[(si+i) %&nbsp;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 =&nbsp;'A'&nbsp;* (flag_len -&nbsp;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 =&nbsp;'{"cnt": 1, "data": "'&nbsp;+&nbsp;'A'&nbsp;* (flag_len -&nbsp;22) +&nbsp;'"}'

# 从 i=0 开始还原密钥流,注意对应关系是 KS[i+1]
keystream = [0] &nbsp;# KS[0] 无法从此方法还原,用 0 占位
for&nbsp;i&nbsp;in&nbsp;range(len(echo_obj)):
&nbsp; &nbsp; 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 =&nbsp;''.join([chr(ord(flag_obj[k]) ^ keystream[k])&nbsp;for&nbsp;k&nbsp;in&nbsp;range(len(flag_obj))])

得到的 plaintext 即为:

{"cnt": 0, "data": "<base64编码的flag消息>"}

解析 JSON 并 Base64 解码 data 字段,即可得到:

Hello my dear friend, the flag is FLAG{...}!

完整利用代码

from&nbsp;pwn&nbsp;import&nbsp;*
import&nbsp;json
import&nbsp;base64

p = remote('flatearth.fluxfingers.net',&nbsp;1721)

# 第一步:接收加密的 flag 消息
flag_obj = p.recv()
flag_len = len(flag_obj)
print('[+] 收到 flag 密文,长度 = %d'&nbsp;% flag_len)

# 第二步:构造已知明文的 payload
# 目标:让服务器回显的明文为 {"cnt": 1, "data": "AAAA...A"}
# 固定 JSON 部分 '{"cnt": 1, "data": ""}' 共 22 字节
# 因此 data 字段需要 flag_len - 22 个 'A'
payload_b64 =&nbsp;'A'&nbsp;* (flag_len -&nbsp;22)
payload_raw = base64.b64decode(payload_b64)
p.send(payload_raw)

# 第三步:接收回显密文
echo_obj = p.recv()
echo_plaintext =&nbsp;'{"cnt": 1, "data": "'&nbsp;+&nbsp;'A'&nbsp;* (flag_len -&nbsp;22) +&nbsp;'"}'

# 第四步:恢复密钥流
# echo_obj[i] = echo_plaintext[i] XOR KS[i+1]
# => KS[i+1] = echo_obj[i] XOR echo_plaintext[i]
keystream = [0] &nbsp;# KS[0] 无法恢复,用 0 占位
for&nbsp;i&nbsp;in&nbsp;range(len(echo_obj)):
&nbsp; &nbsp; keystream.append(ord(echo_obj[i]) ^ ord(echo_plaintext[i]))

# 第五步:解密 flag
plaintext =&nbsp;''.join([chr(ord(flag_obj[k]) ^ keystream[k])&nbsp;for&nbsp;k&nbsp;in&nbsp;range(len(flag_obj))])
print('[+] 解密结果:', plaintext)

# 第六步:提取 flag
import&nbsp;re
obj = json.loads('{'&nbsp;+ plaintext[1:]) &nbsp;# 修复第 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》

评论:0   参与:  0