无法解释来自侧信道攻击尝试的数据

信息安全 定时攻击
2021-08-23 08:54:11

我从我正在使用的加密库中找到了下面的比较函数(稍作修改)。我很好奇旁道攻击的潜在漏洞。具体来说,仅当被比较的字符在两个字符串的范围内时才进行字符比较。我怀疑这可能允许攻击者确定字符串长度。

也许这种差异太小而无法受到计时攻击,但我在下面尝试了一下。我基本上创建长度增加的字符串并与给定的初始字符串进行比较。我期望在第二个字符串变长之前和之后的比较时间可能会出现线性增长,但由于执行的操作不同,因此可能具有不同的斜率。

相反,我看到了下面的数据(注意被比较的字符串长度为 27 个字符)。任何关于为什么我不知道我在说什么的解释将不胜感激:)

一个小提示,我确实尝试过-O0,以防出现一些奇怪的优化问题。从这里我唯一能想到的就是开始深入研究生成的程序集。

字符串比较时间与字符串长度

#include <string.h>
#include <sys/time.h>
#include <stdio.h>


int CompareStrings(const char* s1, const char* s2) {
    int eq = 1;
    int s1_len = strlen(s1);
    int s2_len = strlen(s2);

    if (s1_len != s2_len) {
        eq = 0;
    }

    const int max_len = (s2_len < s1_len) ? s1_len : s2_len;

    // to prevent timing attacks, should check entire string
    // don't exit after found to be false
    int i;
    for (i = 0; i < max_len; ++i) {
      if (s1_len >= i && s2_len >= i && s1[i] != s2[i]) {
        eq = 1;
      }
    }

    return eq;
}

double time_diff(struct timeval x , struct timeval y) {
    double x_ms , y_ms , diff;

    x_ms = (double)x.tv_sec*1000000 + (double)x.tv_usec;
    y_ms = (double)y.tv_sec*1000000 + (double)y.tv_usec;

    diff = (double)y_ms - (double)x_ms;

    return diff;
}

void test_with_length(char* str1, int n) {
    char str2[n + 1];
    struct timeval tp1;
    struct timeval tp2;
    int i;

    for (i = 0; i < n; i++) {
        str2[i] = 'a';
    }
    str2[n] = '\0';

    gettimeofday(&tp1, NULL);
    for (i = 0; i < 20000000; i++) {
        CompareStrings(str1, str2);
    }
    gettimeofday(&tp2, NULL);
    printf("%d %.01f\n", n, time_diff(tp1, tp2));
}

int main() {
    char *str1 = "XXXXXXXXXXXXXXXXXXXXXXXXXXX";

    int i = 0;
    for (i = 1; i <= 100; i++) {
        test_with_length(str1, i);
    }
}
2个回答

首先,对代码的一些注释:

  • 你的调用strlen是一个黑匣子——你不可能知道它们对定时攻击的反应。
  • 用完最短的字符串后,比较循环退出,泄漏较短的长度。
  • 您没有消除test_with_length.
  • 数组访问的时间复杂度不是O(1),尤其是当每个元素都小于 CPU 的字宽时。

补偿此类攻击的更好方法:

bool CompareStr(char* strA, char* strB)
{
    bool result = true;
    int lenA, lenB = 0;

    while(strA[n] != 0)
        lenA++;
    while(strB[n] != 0)
        lenB++;

    int maxLen = (lenA > lenB) ? lenA : lenB;

    // compensate for array access to some extent
    int diff = (lenA > lenB) ? lenA - lenB : lenB - lenA;
    char dummy = '\0';
    for(int i = 0; i < diff; i++) { dummy = strA[0]; }

    // better way to avoid timing attacks
    for(int i = 0; i < maxLen; i++)
    {
        if ( ((i >= lenA) ? strA[0] : strA[i]) != ((i >= lenB) ? strB[0] : strB[i]) )
            result = false;
    }

    return false;
}

// compute loop overhead
time_pre_loop = getTime();
for(int i = 0; i < LOOP_COUNT; i++) { }
time_post_loop = getTime();
double diff = time_diff(time_post_loop, time_pre_loop);

time_pre_test = getTime();
for(int i = 0; i < LOOP_COUNT; i++)
    CompareStr(strA, strB);
time_post_test = getTime();

double result = time_diff(time_post_test, time_pre_test) - diff;

无论如何,这就是有趣的部分开始的地方。考虑以下可用于计算两个字符串是否相等的汇编代码,删除了作为基本定时攻击防御的早期优化:

 xor ecx, ecx                         ; clear counter to 0
 mov dword ptr ds:[0040AAAA], 1       ; set result to true
loopStart:
 mov ah, byte ptr ds:[ecx+00401234]   ; read char from string A
 mov bh, byte ptr ds:[ecx+00402468]   ; read char from string B
 cmp ah, bh                           ; compare the chars
 jz match                             ; are they equal?
 mov dword ptr ds:[0040AAAA], 0       ; strings don't match! set result to true
match:
 cmp ah, 0                            ; are we at the end of string A?
 jz done
 cmp bh, 0                            ; are we at the end of string B?
 jz done
 inc ecx                              ; increment counter and continue loop
 jmp loopStart
done:
 ret

这里实际上有一些定时攻击。前两条指令是O(1)但是,随后的两个单字节读取不是。

虽然内存读取通常是O(1),但实际上它们不会。事实上,它们根本不适合大 O 模型。在 32 位系统上,每 4 个字节的读取将比其他字节快。在 64 位系统上,每 4 个字节会更快,每 8 个字节会更快。这是由于未对齐的内存读取。CPU 擅长获取与其总线大小对齐的数据块,但不擅长获取随机的单个字节。CPU 将尝试通过流水线指令并将块加载到其 L2 缓存中来优化这一点,这使得这个问题更加微妙,但它仍然存在。

下一个技巧是处理大于 CPU 缓存的字符串。如果你有一个超过这个大小的字符串(对于 L2 缓存通常为 128KB 或更高),你会看到每次读取超过该边界时内存获取的轻微延迟。这通常只是一个小的延迟,因为 L3 缓存将用于存储下一个块,但我们可以看到更大的字符串 (8MB+) 的差异更大,因为内存提取必须从系统内存中完成。

如果我们将块内存副本视为 O(n),其中 n 是内存长度,这是有道理,但不能正确描述由实现特性引起的时间上的微小差异。

最后,我们可以看到,mov dword ptr ds:[0040AAAA], 0只要一个字符串与另一个字符串不匹配,就会执行该指令。这会以两种方式泄露信息:

  • 允许我们通过识别重复设置结果时使用的额外时间来估计较短字符串的相对长度。
  • 如果我们能够准确测量,就可以让我们识别出哪些字符不同。

正如我们所见,要防止这些问题非常困难。这是我对更好系统的尝试,假设固定长度的字符串与空字节对齐:

int r, d = 0;
int* bufferA = (int*)stringA;
int* bufferB = (int*)stringB;
for(int i = 0; i < BUFFER_SIZE; i += 4)
{
    d = bufferA[i] ^ bufferB[i]; // if equal, d = 0
    r |= d; // if all cases are 0, r will be 0
}
return r; // result is 0 if equal, non-zero if not equal

由于以下原因,这几乎是完美的 O(n):

  • 对齐整数的按位异或是 O(1)
  • 按位或对齐的整数是 O(1)
  • 内存读取是对齐的。
  • 由于BUFFER_LENGTH填充,两个长度都没有泄漏。
  • 没有分支,不包括循环。

首先,您的比较功能存在错误;循环中的“ eq = 1;”应为“ eq = 0;”。但是,假设编译器不使用优化器玩游戏,它不应该改变时间。为了让你的代码更能抵抗优化游戏,你应该把基准函数(你的CompareStrings())和基准代码(test_with_length())放在不同的模块中(单独编译的C源文件,并祈祷编译器不要执行模块间优化——gcc应该是安全的)。

你看到的看起来是正确的。这两个strlen()调用的成本应该随着两个字符串的长度之和大致线性上升;strlen()为了更快,玩一些漂亮的游戏。特别是,strlen()将尝试按整个对齐的块(可能是 4、8、16 字节)读取数据字节,这将起作用,因为编译器知道MMU的内存访问控制在页面粒度上起作用,因此可以使在许多情况下,“安全”的越界访问。小字符串将有专门的版本,并且这里的长度(最多 100 个字节)太小而无法真正表现出渐近行为。

就循环而言,两者的成本strlen()可能很小。for但是,它在图表中引入一些“噪音”,因此您不应该太仔细地看这些点。

循环将for运行max_len步数,这是两个字符串中较长的一个的长度。因此,作为第一个近似值,我们预计该循环的成本与两个字符串中较大的一个的长度呈线性关系,因此当第二个操作数长度从 1 增长到 27 时,您应该看到大致恒定的时间,然后增加-- 这就是你在图中看到的。