看到nk战队的wp上HyperJump题用的时序侧信道攻击,参照nk战队的wp进行时序攻击的学习和思考

时序测信道攻击的原理和防范措施

核心思想:通过测量一个系统执行操作所花费的时间,系统的执行时间与处理的数据内容、内部操作序列密切相关,攻击者通过精准测量执行时间分析差异来推断出系统内部的秘密信息(如加密密钥、密码等)。

防范措施:既然攻击者利用的是操作时间差,那么我们就能知道防御的核心原则是——消除执行时间与秘密数据之间的任何依赖关系

  1. 恒定时间编程
  2. 添加随机延迟
  3. 算法级屏蔽
    操作执行前,先用一个随机值对秘密数据进行盲化。操作完成后,再去除盲化因子。这样,算法核心处理的就是随机化的数据,其执行时间不再与原始秘密相关

以上三种措施都能有效防范该攻击

对wp中的exp分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import subprocess  #用于启动外部程序并与其交互
import timeit #用于精确测量代码执行时间
import string #提供字符串常量(如字母、数字)

def spawn(cmd: str, _stdin: bytes) -> float:
f = lambda: subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL
).communicate(_stdin)
return timeit.timeit(f, number=1)

cmd = "./hyperjump"
keys = string.ascii_letters + string.digits + "_!}"
print(f"[-] Finding password")
p = "flag{"
for j inrange(24 - len(p)):
sum_of_elapsed = {}
for k in keys:
sum_of_elapsed[k] = 0
payload = p + k + keys[0] * (24 - 1 - len(p)) + '\n'
sum_of_elapsed[k] += spawn(cmd, payload.encode())

c = max(sum_of_elapsed, key=sum_of_elapsed.get)
p += c
print(f"[+] Done: Assuming p[{j}] to be {c}")

iflen(p) == 0:
print("[-] Attack failed.")

print(f"[+] Done! Password is \n{p}")
print(p)

整体就是检测程序运行的时间和爆破

检测程序运行的时间函数分析

1
2
3
4
5
def spawn(cmd: str, _stdin: bytes) -> float:  指定运行程序路径和要输入给目标程序的数据(必须是字节类型,因为程序输入需要二进制数据),然后返回一个浮点数的值
f = lambda: subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL
).communicate(_stdin)
return timeit.timeit(f, number=1)

subprocess.Popen:启动外部程序

subprocess是 Python 用于调用外部程序的库,Popen是其中最核心的类,用于创建一个新的进程来运行目标程序。这里的参数:

  • cmd:要运行的程序路径(比如./hyperjump)。
  • stdin=subprocess.PIPE:表示 “允许向这个程序的标准输入(stdin)发送数据”(可以理解为给程序 “喂” 输入)。
  • stdout=subprocess.DEVNULL:表示 “忽略程序的标准输出(stdout)”(我们只关心程序运行时间,不关心它输出了什么,所以用DEVNULL丢弃输出)。

执行subprocess.Popen(...)后,目标程序会被启动,但此时它会等待输入(因为我们指定了stdin=PIPE)。

.communicate(_stdin):向程序输入数据并等待结束

communicatePopen对象的方法,作用是:

  • 向程序的标准输入发送_stdin数据。
  • 等待程序处理完数据并退出(即等待进程结束)。

所以,subprocess.Popen(...).communicate(_stdin)的完整流程是:启动目标程序 → 发送_stdin数据给它 → 等待程序处理完毕并退出
lambda: ...则将这个流程包装成了一个可调用的函数f(调用f()就会执行上述流程)。

测量时间:return timeit.timeit(f, number=1)

timeit.timeit是 Python 用于精确测量代码执行时间的工具,这里的作用是测量f()函数的执行时间:

  • 第一个参数f:要测量的函数
  • 第二个参数number=1:表示只执行f()一次
  • 返回值:f()执行一次所花费的总时间(单位是秒)。
  • timeit而不是简单的time.time()计时,是因为timeit会自动屏蔽一些系统干扰(比如其他进程的短暂占用),测量更精确,适合这种对时间差异敏感的侧信道攻击。

爆破部分

1
keys = string.ascii_letters + string.digits + "_!}"

keys通过三部分拼接包含了可能候选字符的字符串:

  1. string.ascii_letters这是 Python 标准库string提供的常量,包含所有大小写英文字母,即:'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'

  2. string.digits同样是string库的常量,包含所有数字字符,即:'0123456789'

  3. "_!}"
    这里没有直接用ASCll的可视化字符就是因为ASCII 表中的可视化字符(可打印字符)约有 95 个(从0x20空格到0x7E波浪号),但是其中有些字符比如#$%^&*()等这些在ctf中基本不会出现,而由这三部分拼接的字符只有65个,可以显著缩短总耗时(如果题目有说特殊字符什么的再进行添加)

然后进行循环爆破,通过创建字典sum_of_elapsed = {}手收集每个字符运行程序所用的时间,方便后面进行比对

1
2
3
sum_of_elapsed[k] = 0  。。初始化字典
payload = p + k + keys[0] * (24 - 1 - len(p)) + '\n' //将该字符前面拼接完成了的字符串,后面拼接0
sum_of_elapsed[k] += spawn(cmd, payload.encode()) //得到k字符的时间,以字典的形式

max()函数的基本用法

Python 内置的max()函数用于找出可迭代对象中的最大值。其基本语法为:

1
max(可迭代对象, key=函数)
  • 可迭代对象:这里是sum_of_elapsed(字典),迭代时默认取字典的(即候选字符)。
  • key=函数:指定一个函数,用于计算每个元素的 “比较值”,max()会根据这个 “比较值” 来判断最大值。

key=sum_of_elapsed.get的作用是根据键获取对应的值

  • max()遍历字典的键(字符)时,key=sum_of_elapsed.get会告诉max()不要直接比较键本身(字符),而是比较每个键对应的 值(时间)

c = max(sum_of_elapsed, key=sum_of_elapsed.get)取运行时间最大的字符,然后将其拼接在字符串p里面

该题目采用的是逐字节对比,错误就直接退出,因此对的字符就接着进行下一轮比对,所以正确字符所用时间是大于错误字符的

噪音

噪音就是任何会干扰或污染你想要测量的那个纯净时序信号(也就是测量执行时间)的东西

时序测信道攻击本就依靠比对时间的差异来推断出系统内部的秘密信息,因此需要极为准确的时间
而很多因素都能影响测量时间(进程调度干扰,硬件资源竞争,指令执行波动,软件层面的额外操作,网络延迟波动,硬件环境变化),如果直接运行该脚本,这样得到运行时间必然会出现误差,从而导致得出的结果错误

因此wp中做了’睡眠‘处理和cpu限制处理

1
2
3
4
5
6
7
8
9
mov rax, 23h ; syscall 35 nanosleep  
push 989680h ; 10 毫秒
push 0
mov rdi, rsp ; rsp 指向时间结构体
xor rsi, rsi
syscall
pop rax
pop rax
ret

前者可以使输入对的字符总是比错的字符多执行 10 毫秒,后者可以减少时序测量的噪声
在多核CPU环境中:

  • 进程可能在多个CPU核心之间迁移

  • 不同核心的负载、频率可能不同

  • 核心间的缓存不一致会导致时间波动

缓存效应

  • 第一次运行:指令和数据需要加载到缓存

  • 后续运行:如果调度到不同核心,缓存失效

  • 这会导致时间差异,干扰真正的时序信号

同时现代CPU有动态频率调整

  • 轻负载时降频节能

  • 重负载时升频

  • 不同核心可能运行在不同频率

限制CPU为1的效果

攻击前(不限制CPU)

1
2
字符 'a': 20.1ms, 19.8ms, 25.3ms, 20.2ms  ← 波动很大
字符 'b': 19.9ms, 20.1ms, 19.7ms, 24.8ms ← 难以区分

攻击后(限制CPU为1)

1
2
字符 'a': 30.1ms, 30.2ms, 30.1ms, 30.3ms  ← 稳定
字符 'b': 20.1ms, 20.2ms, 20.1ms, 20.3ms ← 稳定

正确字符明显多10ms!

限制代码

Linux

运行程序时绑定到特定CPU核心

1
2
3
4
5
6
7
8
9
10
# 绑定到 CPU 0
taskset -c 0 python3 solve.py

# 或者绑定到 CPU 0 运行二进制程序
taskset -c 0 ./hyperjump

# 同时限制两个程序
taskset -c 0 python3 solve.py
# 在另一个终端
taskset -c 0 ./hyperjump

对已运行的进程进行绑定

1
2
3
4
# 查找进程ID
ps aux | grep FileName
# 绑定到 CPU 0
taskset -cp 0 <进程ID>
Windows

使用 start 命令 /affinity 参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 绑定到 CPU 0
start /affinity 1 python solve.py

# 绑定到 CPU 1
start /affinity 2 python solve.py

# 绑定到 CPU 2
start /affinity 4 python solve.py

# 绑定到 CPU 3
start /affinity 8 python solve.py

# 绑定到 CPU 0 和 1
start /affinity 3 python solve.py

优化

但即使是仅经过了前两步的‘降噪’操作,运行时仍然有很大的出错概率

我一开始将‘睡眠’时间加到50ms乃至100ms,但仍是无法得到正确的flag,单纯的’睡眠‘无法得到准确的运行时间,还会大幅提高爆破时间

因此我在spawn 函数里增加了循环测量:

1
2
3
4
5
6
7
8
9
def spawn(cmd: str, _stdin: bytes) -> float:
# 测量3次取最小值(减少异常值影响)
measurements = []
for _ in range(3):
f = lambda: subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL
).communicate(_stdin)
measurements.append(timeit.timeit(f, number=1))
return min(measurements) # 取最小值而不是平均值

通过多次测量取最小值,取最小值能过滤掉因系统中断导致的异常大值

1
2
测量结果: [30.1, 29.8, 30.3] → 取29.8
测量结果: [20.1, 25.3, 20.2] → 取20.1 # 异常值25.3被过滤

差异: 29.8 - 20.1 = 9.7ms (明显!)

为什么取最小值而不是平均值?

  • 系统中断通常让时间变长而不是变短

  • 正确字符的最小值代表”无干扰”情况下的真实时间

  • 错误字符的最小值也代表其真实基准时间

    这三个降低测量时间的不稳定性方法使得我得到了绝大部分正确的flag,但是还有一个问题也就是最后一个字符错误

最后一个误差来自结尾的字符也就是},这是因为程序逻辑的特殊性导致的,在最后一个字符运行完毕后输出结果后退出,而不管是正确还是错误的字符,它们都是相同的睡眠时间,正确字符对比结束后没有再多执行10ms的时间,时间差别太小,但是爆破花括号内的字符也够了,能得到flag