是否可以将 Golang 字符串的内存 "safely" 归零?

Is it possible to zero a Golang string's memory "safely"?

最近我一直在使用 cgo 在我的一个项目中设置 libsodium,以便使用 crypto_pwhash_strcrypto_pwhash_str_verify 函数。

这一切都非常顺利,我现在有一小部分函数以纯文本密码的形式接收 []byte 并对其进行哈希处理,或者将其与另一个 []byte 进行验证。

我使用 []byte 而不是 string 的原因是,根据我目前对 Go 的了解,我至少可以遍历纯文本密码和零所有字节,甚至将指针传递给 libsodiumsodium_memzero 函数,以免它在内存中停留的时间超过需要的时间。

这对于我能够直接读取字节输入的应用程序来说很好,但我现在正尝试在一个小型 Web 应用程序中使用它,我需要使用 POST方法。

根据我在 Go 源代码和文档中看到的内容,在请求处理程序中使用 r.ParseForm 会将所有表单值解析为 map of strings。

问题是因为 Go 中的 strings 是不可变的,我认为我无法做任何事情来将表单中 POSTed 的密码的内存清零;至少,只使用 Go。

所以我唯一(简单)的选择似乎是将 unsafe.Pointer 连同字节数传递给 C 中的函数,然后让 C 为我清零内存(例如,传递它到前面提到的 sodium_memzero 函数)。

我试过了,毫不奇怪,它确实有效,但是我在 Go 中留下了一个不安全的 string,如果在像 fmt.Println 这样的函数中使用它会崩溃程序。

我的问题如下:

编辑: 澄清一下,Web 应用程序和表单 POST 只是一个方便的例子,我可能会因为使用 Go 的标准而收到敏感数据string 形式的库。我更感兴趣的是我的所有问题是否 possible/worthwhile 在某些情况下尽快清理内存中的数据更像是一个安全问题。

鉴于关于这个问题的 activity 似乎不多,我假设大多数人之前没有 needed/wanted 研究过这个问题,或者没有'认为这是值得的时间。因此,尽管我对 Go 的内部工作原理一无所知,但我将 post 我自己的发现作为答案。

我应该以免责声明作为这个答案的开头,因为 Go 是一种垃圾收集语言,我不知道它在内部是如何工作的,以下信息实际上可能无法保证任何内存实际上被清除为零,但是不会阻止我尝试;毕竟,在我看来,内存中的明文密码越少越好。

考虑到这一点,这就是我发现的与 libsodium 一起工作的一切(据我所知);到目前为止 none 它至少让我的任何程序崩溃了。

首先,你可能已经知道 Go 中的 strings 是不可变的,所以从技术上讲它们的值不应该改变,但是如果我们使用 unsafe.Pointerstring 在 Go 中或在 C 中通过 Cgo,我们实际上可以覆盖存储在 string 值中的数据;我们只是不能保证内存中其他地方没有任何其他数据副本。

出于这个原因,我让我的密码相关函数专门处理 []byte 变量,以减少可能在内存中复制的纯文本密码的数量。

我还 return 传递给所有密码函数的纯文本密码的 []byte 参考,因为将 string 转换为 []byte 将分配新的内存并将内容复制过来。这样,至少如果您将 string 就地转换为 []byte 而没有先将其分配给变量,您仍然可以在函数调用完成后访问新的 []byte并将该内存也归零。

以下是我想出的要点。您可以填写空白,包括 libsodium C 库并编译它以查看结果。

对我来说,它在调用 MemZero* 函数之前输出:

pwd     : Correct Horse Battery Staple
pwdBytes: [67 111 114 114 101 99 116 32 72 111 114 115 101 32 66 97 116 116 101 114 121 32 83 116 97 112 108 101]

然后在调用 MemZero* 函数之后:

pwd     :
pwdBytes: [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
Hash: $argon2i$v=19$m=131072,t=6,p=1$N05osI8nuTjftzfAYBIcbAyb92yt9S9dRmPtlSV/J8jY4DG3reqm+2eV+fi54Its

所以它看起来像成功了,但由于我们不能保证内存中其他地方没有纯文本密码的副本,我认为这是我们所能做到的 go 用它。

下面的代码简单地将一个unsafe.Pointerbyte的数量传递给C中的sodium_memzero函数来实现这一点。所以内存的实际清零最多留到libsodium.

如果我在代码中留下任何拼写错误或任何不起作用的内容,我深表歉意,但我不想粘贴太多,只粘贴相关部分。

例如,如果您真的需要,您也可以使用像 mlock 这样的函数,但是由于这个问题的重点是将 string 归零,我将在这里展示它。

package sodium

// Various imports, other functions and <sodium.h> here...

func init() {
    if err := sodium.Init(); err != nil {
        log.Fatalf("sodium: %s", err)
    }
}

func PasswordHash(pwd []byte, opslimit, memlimit int) ([]byte, []byte, error) {
    pwdPtr := unsafe.Pointer(&pwd[0])
    hashPtr := unsafe.Pointer(&make([]byte, C.crypto_pwhash_STRBYTES)[0])

    res := C.crypto_pwhash_str(
        (*C.char)(hashPtr),
        (*C.char)(pwdPtr),
        C.ulonglong(len(pwd)),
        C.ulonglong(opslimit),
        C.size_t(memlimit),
    )
    if res != 0 {
        return nil, pwd, fmt.Errorf("sodium: passwordhash: out of memory")
    }
    return C.GoBytes(hashPtr, C.crypto_pwhash_STRBYTES), pwd, nil
}

func MemZero(p unsafe.Pointer, size int) {
    if p != nil && size > 0 {
        C.sodium_memzero(p, C.size_t(size))
    }
}

func MemZeroBytes(bytes []byte) {
    if size := len(bytes); size > 0 {
        MemZero(unsafe.Pointer(&bytes[0]), size)
    }
}

func MemZeroStr(str *string) {
    if size := len(*str); size > 0 {
        MemZero(unsafe.Pointer(str), size)
    }
}

然后全部使用:

package main

// Imports etc here...

func main() {
    // Unfortunately there is no guarantee that this won't be
    // stored elsewhere in memory, but we will try to remove it anyway
    pwd := "Correct Horse Battery Staple"

    // I convert the pwd string to a []byte in place here
    // Because of this I have no reference to the new memory, with yet
    // another copy of the plain password hanging around
    // The function always returns the new []byte as the second value
    // though, so we can still zero it anyway
    hash, pwdBytes, err := sodium.PasswordHash([]byte(pwd), 6, 134217728)

    // Byte slice and string before MemZero* functions
    fmt.Println("pwd     :", pwd)
    fmt.Println("pwdBytes:", pwdBytes)

    // No need to keep a plain-text password in memory any longer than required
    sodium.MemZeroStr(&pwd)
    sodium.MemZeroBytes(pwdBytes)
    if err != nil {
      log.Fatal(err)
    }

    // Byte slice and string after MemZero* functions
    fmt.Println("pwd     :", pwd)
    fmt.Println("pwdBytes:", pwdBytes)

    // We've done our best to make sure we only have the hash in memory now
    fmt.Println("Hash:", string(hash))
}

在内存中处理安全值在 Go 中比在 C 或 C++ 中更难。那是因为 GC,它到处复制和弄乱它感觉的任何内存。

因此,第一步是获取一些 GC 不能乱用的内存。为此,我们可以根据需要启动 cgo 和 malloc;或者使用像 mmap 和 VirtualAlloc 这样的系统调用;然后像往常一样传递生成的切片。

下一步是告诉 OS 你不希望这个内存被换出到磁盘,所以你 mlock 或 VirtualLock 它。

在退出之前,使用 libsodium 或通过简单地迭代将切片归零,将每个元素设置为零。这对于字符串是不可能的,而且我不确定我是否会推荐手动擦除字符串的内存。我的意思是,我不能立即发现它有什么问题,但是……就是感觉不对。无论如何,没有人使用字符串作为安全值。

有一个库(我的)是专门为存储安全值而设计的,它可以完成我上面描述的内容以及其他一些事情。您可能会发现它很有用:https://github.com/awnumar/memguard

"No one uses strings for secure values anyway."

KDF中用于解开密文或直接解密的密码除外

如果您尝试改变字符串的底层缓冲区,则字符串分配中使用的内存会触发分段错误:

https://medium.com/kokster/mutable-strings-in-golang-298d422d01bc

与 memguard 不可变缓冲区相同。

我已经尝试在给定的地址上使用 unix.Mprotect 但我认为诀窍是我必须找到存储字符串缓冲区的实际内存页地址,而不是指向缓冲区开头的指针,有效地做到这一点。

暂时找到合适的解决方案对我来说工作量有点大,但是知道字符串是不可变的并且从这里到王国的副本堆积在内存中,我认为这应该是一个规则,如果你使用 memguard 并且必须处理密码,首先将其放入 memguard 缓冲区,然后仅使用该形式的数据。

正是出于这样的原因,Qubes 被设计出来,以在应用程序之间设置更牢固的边界。如果您的程序装在 VM 容器内,则它根本无法到达该盒子之外。如果您的程序运行恶意代码,则只有攻击向量。

由于网络数据包以 [] 字节的形式到达,因此可以根据需要将其中的任何敏感信息清零。由于键盘输入端由 OS 控制,因此只需找到(或编写)一个直接进入可变字节片的控制台文本输入函数,然后应用我在顶部引用的语句。

牢记这一点,我现在正在更改我的代码,以便在使用后需要将数据归零的任何地方不使用字符串变量。

如果您想接受多字节字符的密码,我认为您的方案通常不会奏效。

处理包含多字节字符的密码需要您先将它们规范化(有多个不同的字节序列可能在“Å”之类的基础上,并且您得到的输入会因键盘、操作系统和可能的阶段而异月亮。

因此,除非您想重写 Go 的所有 Unicode 规范化代码以处理您的字节数组,否则您将 运行 遇到问题。

Given that there doesn't seem to be much activity on this question, I'm going to just assume that most people haven't needed/wanted to look into this before, or haven't thought it was worth the time.

其实我今天才注意到这个问题。相信我,我已经考虑过了。