在 .NET 中使用 FixedTimeEquals 應對計時攻擊

2022-06-01 15:00:14

計時攻擊

在電腦保安中,計時攻擊(Timing attack)是旁道攻擊 (Side-channel attack) 的一種,而旁道攻擊是根據計算機處理過程發出的資訊進行分析,包括耗時,聲音,功耗等等,這和一般的暴力破解或者利用加密演演算法本身的弱點進行攻擊是不一樣的。

舉個例子

假如您有一個後端 webapi, GetConfig 介面用來獲取設定資訊,呼叫時需要在 Header 中傳入一個祕鑰,然後判斷是否正確並進行返回,如下

X-Api-Key: x123
[HttpGet]
public IActionResult GetConfig()
{
    var key = Request.Headers["X-Api-Key"].FirstOrDefault();
    if (key != "x123")
    {
        return Unauthorized();
    }

    return Ok(configuration);
}  

注意,這裡我們為了判斷兩個字串相等,通常會使用 == 或者 != , 實際上背後使用了 String 的 Equals() 方法,如下

// Determines whether two Strings match.
public static bool Equals(string? a, string? b)
{
    if (object.ReferenceEquals(a, b))
    {
        return true;
    }

    if (a is null || b is null || a.Length != b.Length)
    {
        return false;
    }

    return EqualsHelper(a, b);
}

而內部又使用了 SequenceEqual() 方法

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool EqualsHelper(string strA, string strB)
{
    Debug.Assert(strA != null);
    Debug.Assert(strB != null);
    Debug.Assert(strA.Length == strB.Length);

    return SpanHelpers.SequenceEqual(
            ref Unsafe.As<char, byte>(ref strA.GetRawStringData()),
            ref Unsafe.As<char, byte>(ref strB.GetRawStringData()),
            ((uint)strA.Length) * sizeof(char));
}

大概的邏輯是,先判斷兩個字串長度是否一致,如果不是,直接返回 false,然後迴圈字串進行逐位對比,一旦發現不相同,直接返回 false,虛擬碼如下

public bool Equals(string str1, string str2)
{
    if (str1.Length != str2.Length) 
    {
        return false;
    } 

    for (var i = 0; i < str1.Length; i++)
    {
        if (str1[i] != str2[i])
        {
            return false;
        }
    } 

    return true;
}

這裡有一個問題是,如果字串第一位不相同,直接就返回 false,如果最後一位不相同,那就需要遍歷到最後,然後返回 false。不一樣的字串,計算的時長可能不一致。

嘗試破解

假如使用者知道了我們的祕鑰的固定長度是 4 位。

GET /GetConfig  
X-Api-Key:a000
Cost: 2ns

本次耗時了 2ns, 接下來又輸入 b000, c000....

GET /GetConfig  
X-Api-Key:b000
Cost: 2ns
 
GET /GetConfig  
X-Api-Key:c000
Cost: 2ns 

...

GET /GetConfig  
X-Api-Key:x000
Cost: 4ns

直到輸入了 x000, 發現其他的耗時都是 2ns, 而這裡是 4ns,大概率判定第一位是 x。

注意,這裡的測試進行了放大,可能每個 case 分別呼叫了 100 次,然後統計了 P50(中位數)得出的結果。

然後用同樣的方法,測試第二位,第三位....., 最終破解拿到了祕鑰。

使用固定時間的演演算法

雖然看上去有點扯,但確實是真實存在的,包括大名鼎鼎的針對 TLS 的 Lucky 13 攻擊,有興趣的同學可以看一下。在安全性要求比較高的場景中,確實要考慮到計時攻擊,當涉及到安全時,還是寧可信其有。

所以我們的演演算法的執行耗時應該是固定的,不應該在不匹配時,就立即返回,我們嘗試改造一下程式碼

public bool Equals(string str1, string str2)
{
    if (str1.Length != str2.Length)
    {
        return false;
    }

    bool reult = true;

    for (var i = 0; i < str1.Length; i++)
    {
        if (str1[i] != str2[i])
        {
            reult = false;
        }
    }

    return reult;
}

不管怎麼樣,都會遍歷完整個字串,然後返回結果,看上去沒什麼問題,時間總是固定的,但在現代的 CPU 和 .NET 上卻不是的,因為我們要考慮到分支預測,特別是 if 條件。

好吧,那我們調整一下程式碼

public bool Equals(string str1, string str2)
{
    if (str1.Length != str2.Length)
    {
        return false;
    }

    bool reult = true;

    for (var i = 0; i < str1.Length; i++)
    { 
       reult &= str1[i] == str2[i]; 
    }

    return reult;
}

我們用了運運算元 &,來代替 If, 只有全部為 true 時,才會返回 true,其中任意一個字元不匹配,就會返回 false,看上去不錯。

但是,還有一些問題,對於bool型別的 result (true/false), 我們的 .NET JIT 和 x86 指令執行仍然會進行一些優化,我們再調整一下程式碼

public bool Equals(string str1, string str2)
{
    if (str1.Length != str2.Length)
    {
        return false;
    }

    int reult = 0;

    for (var i = 0; i < str1.Length; i++)
    { 
       reult |= str1[i] ^ str2[i]; 
    }

    return reult == 0;
}

我們把 bool 改成了 int 型別,然後使用了運運算元 ^ 和 |,同樣的,只有字串全部匹配時,result 為 0,,才會返回 true, 其中任意一個不匹配,result 就不為 0,會返回 false。

最後,為了防止 JIT 對我們的程式碼進行其他的優化,我們可以加一個特性,告訴 JIT 不要管它,就像這樣

[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
public bool Equals(string str1, string str2)
{
    if (str1.Length != str2.Length)
    {
        return false;
    }

    int reult = 0;

    for (var i = 0; i < str1.Length; i++)
    { 
       reult |= str1[i] ^ str2[i]; 
    }

    return reult == 0;
} 

上面我們實現了一個針對字串比較的固定時間的演演算法,來應對計時攻擊。

實際上, 從 .NET Core 2.1 開始就已經做了內建支援,我們可以直接使用 FixedTimeEquals 方法, 看一下它的實現

[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
public static bool FixedTimeEquals(ReadOnlySpan<byte> left, ReadOnlySpan<byte> right)
{ 
    if (left.Length != right.Length)
    {
        return false;
    }

    int length = left.Length;
    int accum = 0;

    for (int i = 0; i < length; i++)
    {
        accum |= left[i] - right[i];
    }

    return accum == 0;
}

現在用起來也很方便:

var result = CryptographicOperations.FixedTimeEquals(
   Encoding.UTF8.GetBytes(str1), Encoding.UTF8.GetBytes(str2)
); 

總結

在安全性比較高的場景中,應該要考慮到計時攻擊,可以使用固定時間的演演算法來應對。在其他的開發語言中,也都有本文中類似的演演算法,而在 .NET 中,現在我們可以直接使用 CryptographicOperations.FixedTimeEquals。