在.NET中对SecureString进行哈希处理

24
在.NET中,我们有SecureString类,这很好,但是当你尝试使用它时会遇到问题,因为(例如)要对字符串进行哈希处理,你需要明文。我在这里编写了一个函数,可以给定一个接受字节数组并输出字节数组的哈希函数,对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;
}

我相信这个函数可以正确地哈希字符串,并且在函数返回时,假设提供的哈希函数行为良好并且不会复制任何输入而不是自己擦除它,它也可以正确地清除任何明文副本。我有没有漏掉什么重要的内容?


1
请注意,SecureString 可能过于严格。如果攻击者可以读取您的内存,那么您已经百分之百失败了。 - usr
1
@usr SecureString使用受保护的内存,因此只有调用进程才能解密该内存位置。如果您想在应用程序崩溃时创建一个minidump并将其发送给开发人员,这尤其有用:除了您的密码外,他们可以获得整个上下文、堆栈跟踪等信息。 - M.Stramm
@M.Stramm 是的,对于“冷启动”式攻击很有用,但对于运行中的系统(这占攻击面的99%)则不是。攻击者如果能够读取内存,通常可以读取按键和数据等信息。当然也有合法的使用情况,我承认这一点。 - usr
@usr 有一些方法可以设计来防止键盘记录器(例如让用户点击具有随机布局的屏幕键盘)。SecureString并不是为了使对正在运行的进程的攻击变得不可能,而是为了防止内存转储(没有系统内存的转储)。即使对于正在运行的进程,攻击者也需要在受攻击的进程下拥有执行权限才能检索未加密的字符串-而不仅仅是读取权限。 - M.Stramm
@M.Stramm 攻击者可以通过窗口消息逐个读取堆栈中的字符。显然,有方法可以设计防止键盘记录器。但是,SecureString与此无关。 - usr
@M.Stramm,同样重要的是要意识到你必须以某种方式处理输入的密码。可以将其哈希或发送到其他系统进行解码。这就需要解码操作。因此,SecureString只能防止内存转储攻击,而这在99.999%的软件中并不是一个攻击场景。 - usr
4个回答

14
我有没有漏掉什么?
是的,你漏掉了一个相当根本的问题。当垃圾收集器压缩堆时,你不能清除数组的副本。Marshal.SecureStringToBSTR(ss)没问题,因为BSTR分配在非托管内存中,所以会有一个可靠的指针不会改变。换句话说,这个没有问题。
然而,你的byte[] bytes数组包含了字符串的副本,并且被分配在GC堆上。使用hashed[]数组很可能会引起垃圾回收。这很容易避免,但是你显然无法控制进程中的其他线程分配内存并引发集合。或者背景GC已经在你的代码开始运行时进行。
SecureString的目标是永远不要在垃圾收集的内存中拥有明文副本。将其复制到托管数组中违反了该保证。如果你想使这段代码安全,那么你将不得不编写一个hash()方法,它接受IntPtr并只能通过该指针读取。
请注意,如果你的哈希需要与另一台计算机上计算的哈希匹配,则不能忽略该计算机用于将字符串转换为字节的编码。

嗯,实现一个有状态的哈希回调函数并初始化它,然后每次只输入一个字节,这样哈希回调函数本身只需要处理受控类型,这不是更合理吗? - Konrad Rudolph
棘手的问题在于哈希函数(例如我计划使用的Rfc2898DeriveBytes.GetBytes)只接受托管类型。您有没有什么替代方案,可以仅使用非托管内存? - Mark Raymond
关于编码 - BSTRs 是 UTF-16,所以据我所知,在不同的机器上它将保持一致。 - Mark Raymond
关于调用DeriveBytes.GetBytes,你是否可以直接使用Hans/Konrad哈希函数的结果作为GetBytes的参数?我知道这实际上是双重哈希(可能有其自身的弱点),但至少只有哈希在托管的byte[]中... - jimbobmcgee

5

有时候可以使用未被管理的CryptoApiCNG函数。

请注意,SecureString是为具有完全控制内存管理的未被管理的消费者而设计的。

如果你想继续使用C#,你应该固定临时数组,以防止垃圾回收在你清除其内容之前移动它:

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();
    }
}

2
现在我理解了你的评论,就我所看到的,这个解决方案应该是有效的。唯一需要注意的是,客户端可能会尝试实现一个hash函数,它重用byte[]参数作为返回值(计算原地哈希)。这显然会导致严重失败。不过我认为这不是一个非常真实的问题。 - Konrad Rudolph
2
一个更真实的问题是,哈希函数的实现者不应该复制那个 byte[] 参数。可悲的是,加密命名空间中的大多数哈希器似乎都是出于性能原因而这样做的。哦,好吧,又是另一个问题 :) - M.Stramm

3
作为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; }
    }
}

1
挑剔一下:如果垃圾回收器在恰当的时刻移动了byte[],这可能会泄漏第一个密钥字节的未加密内容。无论如何+1,因为使用SecureString本来就是一个错误。人们不能对这样的系统期望太多。 - usr
1
@usr / @KonradRudolph - 这个问题可以通过使用 GCHandle 来缓解吗?例如:byte[] bytes; GCHandle handle = GCHandle.Alloc(bytes = new byte[1], GCHandleType.Pinned); handle.Free(); - jimbobmcgee
@jimbobmcgee 是的,但这意味着客户端代码(哈希器)必须处理非托管内存,这正是我一开始想要避免的。 - Konrad Rudolph
@KonradRudolph -- 谢谢。我现在明白了。只是为了澄清,这是你的SingleByteXor实现的问题,而不是整体概念的问题? - jimbobmcgee
@jimbobmcgee 这是我的简单实现的问题,但也是任何纯管理解决方案的问题。 - Konrad Rudolph
显示剩余9条评论

2
作为进一步的补充,您能不能将@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)));
        }
    }
}

1
这个问题和HansPassant提出的问题是一样的,你仍然会得到缓冲区中字符串的一个副本。你可以让Read每次只返回一个字节,作为Read的实现者,你有权这样做,但是你无法保证ComputeHash(Stream)不会将传入的缓冲区存储到托管内存中。 - Scott Chamberlain
@ScottChamberlain - 我喜欢“Read”每次只返回1个字节的想法(并已修改示例以显示此内容)。我保留原始评论“取决于HashAlgorithm实现”的位置,这已经与您的“没有保证...不仅仅是存储”达成了一致;-) - jimbobmcgee
1
你的新示例中有一个小故障,不是很重要,但你没有处理 count == 0 的情况。你可以将 if 语句改为 if (count > 0 && _pos++ < _length) - Scott Chamberlain

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接