在 .NET 中散列 SecureString

Posted

技术标签:

【中文标题】在 .NET 中散列 SecureString【英文标题】:Hashing a SecureString in .NET 【发布时间】:2012-12-26 22:21:02 【问题描述】:

在 .NET 中,我们有 SecureString 类,在您尝试使用它之前一切都很好,至于(例如)散列字符串,您需要明文。给定一个哈希函数,它接受一个字节数组并输出一个字节数组。

private static byte[] HashSecureString(SecureString ss, Func<byte[], byte[]> hash)

    // Convert the SecureString to a BSTR
    IntPtr bstr = Marshal.SecureStringToBSTR(ss);

    // BSTR contains the length of the string in bytes in an
    // Int32 stored in the 4 bytes prior to the BSTR pointer
    int length = Marshal.ReadInt32(bstr, -4);

    // Allocate a byte array to copy the string into
    byte[] bytes = new byte[length];

    // Copy the BSTR to the byte array
    Marshal.Copy(bstr, bytes, 0, length);

    // Immediately destroy the BSTR as we don't need it any more
    Marshal.ZeroFreeBSTR(bstr);

    // Hash the byte array
    byte[] hashed = hash(bytes);

    // Destroy the plaintext copy in the byte array
    for (int i = 0; i < length; i++)  bytes[i] = 0; 

    // Return the hash
    return hashed;

我相信这将正确地散列字符串,并在函数返回时正确地从内存中清除任何明文副本,假设提供的散列函数表现良好并且不会复制它的输入不会擦洗自己。我在这里错过了什么吗?

【问题讨论】:

注意,SecureString 可能是矫枉过正。如果攻击者可以读取你的记忆,你就失去了 100%。 @usr SecureString 使用受保护的内存,因此只有调用进程才能解密内存位置。如果您想在应用程序崩溃时创建一个小型转储并将其发送给开发人员,这将特别有用:他们获取整个上下文、堆栈跟踪等,除了您的密码 @M.Stramm 是的,对“冷启动”式攻击很有用,但对正在运行的系统(这是 99% 的攻击面)无效。可以读取内存的攻击者通常可以读取击键和数据等。有有效的用例。我同意你。 @usr 有一些方法可以针对键盘记录器进行设计(例如让用户单击随机布局的屏幕键盘)。 SecureString 不应该使对正在运行的进程的攻击成为不可能,只能对内存转储(没有系统内存的转储)进行攻击。尽管如此,即使对于正在运行的进程,攻击者也需要对受到攻击的进程具有执行权限才能检索未加密的字符串 - 而不仅仅是读取权限 @M.Stramm 攻击者可以从堆栈中读取作为窗口消息进入的字符。显然,有 种方法可以针对键盘记录器进行设计。但是,SecureString 与此无关。 【参考方案1】:

我错过了什么吗?

是的,你有,一个相当基本的。当垃圾收集器压缩堆时,您无法清理留下的数组副本。 Marshal.SecureStringToBSTR(ss) 没问题,因为 BSTR 是在非托管内存中分配的,所以会有一个不会改变的可靠指针。换句话说,擦洗那个没问题。

您的byte[] bytes 数组包含字符串的副本,并且 分配在GC 堆上。您可能会使用 hashed[] 数组引发垃圾收集。很容易避免,但当然您几乎无法控制进程中分配内存和引发集合的其他线程。或者就此而言,当您的代码开始运行时,后台 GC 已经在进行中。

SecureString 的目的是永远在垃圾回收内存中拥有字符串的明文副本。将其复制到托管数组中违反了该保证。如果你想让这段代码安全,那么你将不得不编写一个 hash() 方法,它接受 IntPtr 并且只读取该指针。

请注意,如果您的哈希需要匹配在另一台机器上计算的哈希,那么您不能忽略该机器用于将字符串转换为字节的编码。

【讨论】:

嗯。实现一个有状态的哈希回调,它被初始化然后一次输入一个字节,这样哈希回调本身只需要处理托管类型不是更有意义吗? 棘手;问题是像 Rfc2898DeriveBytes.GetBytes 这样的散列函数,这是我计划使用的,只接受托管类型。您对只使用非托管内存的替代方案有什么建议吗? 关于编码 - BSTR 是 UTF-16,因此 AFAIK 将在机器之间保持一致。 关于调用DeriveBytes.GetBytes,你能不能只使用Hans/Konrad 的散列函数的结果作为GetBytes 的参数?我知道它在技术上是双重哈希(可能有其自身的弱点),但至少现在只有哈希在托管 byte[]...【参考方案2】:

作为对 Hans 回答的补充,这里有一个如何实现哈希器的建议。 Hans 建议将指向非托管字符串的指针传递给哈希函数,但这意味着客户端代码(= 哈希函数)需要处理非托管内存。这并不理想。

另一方面,您可以将回调替换为以下接口的实例:

interface Hasher 
    void Reinitialize();
    void AddByte(byte b);
    byte[] Result  get; 

这样,哈希器(尽管它变得稍微复杂一些)可以完全在托管土地上实施,而不会泄露安全信息。您的 HashSecureString 将如下所示:

private static byte[] HashSecureString(SecureString ss, Hasher hasher) 
    IntPtr bstr = Marshal.SecureStringToBSTR(ss);
    try 
        int length = Marshal.ReadInt32(bstr, -4);

        hasher.Reinitialize();

        for (int i = 0; i < length; i++)
            hasher.AddByte(Marshal.ReadByte(bstr, i));

        return hasher.Result;
    
    finally 
        Marshal.ZeroFreeBSTR(bstr);
    

注意finally 块以确保非托管内存被清零,无论哈希器实例做什么恶作剧。

这里有一个简单(但不是很有用)Hasher 实现来说明接口:

sealed class SingleByteXor : Hasher 
    private readonly byte[] data = new byte[1];

    public void Reinitialize() 
        data[0] = 0;
    

    public void AddByte(byte b) 
        data[0] ^= b;
    

    public byte[] Result 
        get  return data; 
    

【讨论】:

这里吹毛求疵:如果 GC 在正确的时刻移动字节 [],这可能会泄漏第一个未加密的密钥字节。无论如何+1,因为首先使用 SecureString 是一个错误。人们不能对这样的系统抱有太多期望。 @usr 是的,就我而言,非常不满意。不幸的是,我没有找到解决办法,这也是 Hans 建议的代码遇到的一个基本问题,除非整个散列计算是在非托管内存中完成的。也许这实际上是唯一安全的方法。 @usr / @KonradRudolph - 这可以用GCHandle 缓解吗?例如byte[] bytes; GCHandle handle = GCHandle.Alloc(bytes = new byte[1], GCHandleType.Pinned); handle.Free(); @jimbobmcgee 是的,但这再次意味着客户端代码(哈希器)必须处理非托管内存,这是我一开始想避免的。 @KonradRudolph - 将Hasher 抽象化,GCHandle.Alloc 在受保护的构造函数中,Free 在 Dispose 中并提供对固定byte[] 的受保护访问,并记录强烈建议Hasher 的实现者只将此数组用于临时存储? (公平地说,我只是在反复思考以尝试更好地理解这一点——我还没有弄清楚为什么它会泄漏第一个字节!!)【参考方案3】:

作为进一步的补充,您能否将 @KonradRudolph 和 @HansPassant 提供的逻辑包装到自定义 Stream 实现中?

这将允许您使用HashAlgorithm.ComputeHash(Stream) 方法,该方法将保持接口受到管理(尽管您需要及时处理流)。

当然,对于一次最终在内存中存储多少数据,您完全受 HashAlgorithm 实现的支配(但是,当然,这就是参考源的用途!)

只是一个想法......

public class SecureStringStream : Stream

    public override bool CanRead  get  return true;  
    public override bool CanWrite  get  return false;  
    public override bool CanSeek  get  return false;  

    public override long Position
    
        get  return _pos; 
        set  throw new NotSupportedException(); 
    

    public override void Flush()  throw new NotSupportedException(); 
    public override long Seek(long offset, SeekOrigin origin)  throw new NotSupportedException(); 
    public override void SetLength(long value)  throw new NotSupportedException(); 
    public override void Write(byte[] buffer, int offset, int count)  throw new NotSupportedException(); 

    private readonly IntPtr _bstr = IntPtr.Zero;
    private readonly int _length;
    private int _pos;

    public SecureStringStream(SecureString str)
    
        if (str == null) throw new ArgumentNullException("str");
        _bstr = Marshal.SecureStringToBSTR(str);

        try
        
            _length = Marshal.ReadInt32(_bstr, -4);
            _pos = 0;
        
        catch
        
            if (_bstr != IntPtr.Zero) Marshal.ZeroFreeBSTR(_bstr);
            throw;
        
    

    public override long Length  get  return _length;  

    public override int Read(byte[] buffer, int offset, int count)
    
        if (buffer == null) throw new ArgumentNullException("buffer");
        if (offset < 0) throw new ArgumentOutOfRangeException("offset");
        if (count < 0) throw new ArgumentOutOfRangeException("count");
        if (offset + count > buffer.Length) throw new ArgumentException("offset + count > buffer");

        if (count > 0 && _pos++ < _length) 
        
            buffer[offset] = Marshal.ReadByte(_bstr, _pos++);
            return 1;
        
        else return 0;
    

    protected override void Dispose(bool disposing)
    
        try  if (_bstr != IntPtr.Zero) Marshal.ZeroFreeBSTR(_bstr); 
        finally  base.Dispose(disposing); 
    


void RunMe()

    using (SecureString s = new SecureString())
    
        foreach (char c in "jimbobmcgee") s.AppendChar(c);
        s.MakeReadOnly();

        using (SecureStringStream ss = new SecureStringStream(s))
        using (HashAlgorithm h = MD5.Create())
        
            Console.WriteLine(Convert.ToBase64String(h.ComputeHash(ss)));
        
    

【讨论】:

这与HansPassant 提出的问题相同,您仍然会在缓冲区中获得字符串的副本。您可以让Read 一次只返回一个字节,这是您作为Read 的实施者的权利,但是您不能保证ComputeHash(Stream) 不会只将传入的缓冲区存储到托管内存也是。 @ScottChamberlain - 我喜欢Read 一次只返回 1 个字节的想法(并修改了示例以显示这一点)。我保留我原来的评论“受 HashAlgorithm 实现的摆布”,这已经与您的 “不保证......不仅仅是存储” 一致;-) 没那么重要,但您的新示例中有一个小故障,您不处理 count == 0,您可以将 if 语句写入 if (count &gt; 0 &amp;&amp; _pos++ &lt; _length)【参考方案4】:

总有可能使用非托管的CryptoApi 或CNG 函数。 请记住,SecureString 在设计时考虑了对内存管理具有完全控制权的非托管消费者。

如果你想坚持使用 C#,你应该固定临时数组以防止 GC 在你有机会清理它之前移动它:

private static byte[] HashSecureString(SecureString input, Func<byte[], byte[]> hash)

    var bstr = Marshal.SecureStringToBSTR(input);
    var length = Marshal.ReadInt32(bstr, -4);
    var bytes = new byte[length];

    var bytesPin = GCHandle.Alloc(bytes, GCHandleType.Pinned);
    try 
        Marshal.Copy(bstr, bytes, 0, length);
        Marshal.ZeroFreeBSTR(bstr);

        return hash(bytes);
     finally 
        for (var i = 0; i < bytes.Length; i++)  
            bytes[i] = 0; 
        

        bytesPin.Free();
    

【讨论】:

现在我理解了您的评论,据我所知,这个解决方案确实应该有效。唯一需要注意的是,客户端可能会尝试实现hash 函数,该函数将byte[] 参数重用为返回值(计算就地哈希)。这显然会大大失败。我不认为这是一个非常现实的问题。 一个更现实的问题是哈希函数的实现者不应该复制byte[] 参数。遗憾的是,出于性能原因,Crypto 命名空间中的大多数哈希器似乎都是这样做的。哦,好吧,另一个问题的另一个问题:)

以上是关于在 .NET 中散列 SecureString的主要内容,如果未能解决你的问题,请参考以下文章

在redis中散列哈希

无法在平均堆栈中散列密码

在 Python 中散列文件

在路由文件中散列密码并更新

MongoDB游标在Perl中散列

如何使用胡椒在 BigQuery 中散列数据并保密?