【问题标题】:How to cleanse (overwrite with random bytes) std::string internal buffer?如何清理(用随机字节覆盖)std::string 内部缓冲区?
【发布时间】:2016-12-06 18:36:57
【问题描述】:

考虑一个场景,其中std::string 用于存储秘密。一旦它被消耗并且不再需要,最好清理它,即覆盖包含它的内存,从而隐藏 secret

std::string 提供了一个函数 const char* data() 返回指向(C++11 起)连续内存的指针。

现在,由于内存是连续的并且由于范围结束,变量将在清理后立即销毁,是否安全:

char* modifiable = const_cast<char*>(secretString.data());
OpenSSL_cleanse(modifiable, secretString.size());

根据此处引用的标准:

$5.2.11/7 - 注意:根据对象的类型,通过指针、左值或指向数据成员的指针的写操作由 const_cast 产生,它丢弃了 const-qualifier68 可能会产生未定义的行为 (7.1.5.1)。

这会建议其他情况,但上述条件(连续、即将删除)是否使其安全?

【问题讨论】:

  • 不使用OpenSSL_cleanse 和可能的UB,为什么不遍历字符串并从PRNG 中为其分配随机值?
  • 另请注意,从 C++17 开始 data 被重载以返回一个非 const 指针,以便您可以使用它。
  • @NathanOliver:我假设 OpenSSL_cleanse 已优化为使用尽可能少的 rand()(或类似)调用,同时保证可接受的安全性。想象一下在硬件上调用rand() 的情况,并且可能代价高昂。此外,重新发明轮子......另一件事 - 正如@ilotXXI 在下面的评论中所说 - 天真的清晰可能会被优化掉(通常是)。
  • 请注意,您还需要正确执行其他几件事才能进行秘密处理。其中包括使用 mlock'd 内存,因此它不能在交换中写入磁盘,确保它没有跨越缓存线边界,因此攻击者无法尝试使用超线程和缓存线反弹计数来检测有关它的东西。等等。真正考虑使用适当的安全库并使用它的数据结构,而不是 std::string。
  • 你知道,你真的不想使用std::string,而是一个真正的安全字符串类型来确保所有缓冲区都被清除。查看codereview.stackexchange.com/questions/107991/…,了解破解std::basic_string 以满足需求的方法。

标签: c++ c++11 stdstring


【解决方案1】:

在 CentOS 6、Debian 8 和 Ubuntu 16.04(g++/clang++、O0、O1、O2、O3)上测试的解决方案:

secretString.resize(secretString.capacity(), '\0');
OPENSSL_cleanse(&secretString[0], secretString.size());
secretString.clear();

如果你真的很偏执,你可以将清理后的字符串中的数据随机化,以免泄露字符串的长度或包含敏感数据的位置:

#include <string>

#include <stdlib.h>
#include <string.h>

typedef void* (*memset_t)(void*, int, size_t);

static volatile memset_t memset_func = memset;

void cleanse(std::string& to_cleanse) {
  to_cleanse.resize(to_cleanse.capacity(), '\0');
  for (int i = 0; i < to_cleanse.size(); ++i) {
    memset_func(&to_cleanse[i], rand(), 1);
  }
  to_cleanse.clear();
}

如果你愿意,也可以播种 rand()。

您也可以在不依赖 openssl 的情况下进行类似的字符串清理,方法是使用 explicit_bzero 将内容设为空:

#include <string>
#include <string.h>

int main() {
  std::string secretString = "ajaja";
  secretString.resize(secretString.capacity(), '\0');
  explicit_bzero(&secretString[0], secretString.size());
  secretString.clear();

  return 0;
}

【讨论】:

    【解决方案2】:

    有一个更好的答案:不要

    std::string 是一个设计为用户友好和高效的类。它在设计时并未考虑到密码学,因此几乎没有任何保证可以帮助您。例如,不能保证您的数据没有被复制到其他地方。充其量,您可能希望特定编译器的实现为您提供您想要的行为。

    如果您确实想将秘密视为秘密,则应使用专为处理秘密而设计的工具来处理它。事实上,您应该针对攻击者的能力开发威胁模型,并相应地选择您的工具。

    【讨论】:

    • 您如何处理必须与不了解您的SecretKeeper 类的第三方交互的情况?
    • @LThode 第三方没有OtherSecretKeeper 可以转换为?你为什么要使用它们?
    • @JAB -- 一些第三方的设计不是很好,很遗憾:/(有时你在这件事上也别无选择)
    • 在讨论第三方库等时,必须提出威胁模型。当您谈论随机化记忆时,而不是简单地将其归零时,您正在追求特别苛刻的净化水平。考虑到您的需求,您的第三方库可能根本不是最佳选择。
    • @JAB,我通过标准输出将它们传递给另一个程序。或者我对它们进行base64编码。或散列它们。或者适用于std::string 数据的通用方法。
    【解决方案3】:

    标准明确规定你不能写信给data()返回的const char*,所以不要那样做。

    有一些非常安全的方法来获取可修改的指针:

    if (secretString.size())
      OpenSSL_cleanse(&secretString.front(), secretString.size());
    

    或者如果字符串可能已经被缩小并且您想确保它的全部容量被擦除:

    if (secretString.capacity()) {
      secretString.resize(secretString.capacity());
      OpenSSL_cleanse(&secretString.front(), secretString.size());
    }
    

    【讨论】:

    • 这个,再加上在将整个密码输入字符串之前确保您有足够的空间用于整个密码(或扩大缓冲区可能会留下部分密码),就是答案。虽然这足以确保没有其他地方你不是OpenSSL_cleanseing?
    • 关于容量的问题:在 SSO 字符串的情况下,调整大小的调用是否可以从 inline-storage 转移到 heap-storage? (因此当秘密被内联存储时清理堆存储)我不太明白为什么会但是......
    • 只有白痴写的才行。在有空闲容量()的情况下添加元素永远不应该分配,这会使reserve() 完全没有意义,尽管我在标准中没有看到这样的保证。如果您不相信您的实现能做到这一点,那么您绝对不应该将std::string 用于敏感数据。
    • @MatthieuM:如果您担心可以清理、调整大小、再次清理。在我看来,即使新大小在容量范围内,标准也允许 resize() 使迭代器和引用无效,因此标准迎合(如乔纳森所说)白痴。
    • @JonathanWakely:嗯,我想我们都同意你可能不应该将std::string 用于敏感数据:)
    【解决方案4】:

    您可以使用std::fill 用垃圾填充字符串:

    std::fill(str.begin(),str.end(), 0);
    

    请注意,简单地清除或缩小字符串(使用clearshrink_to_fit 等方法)并不能保证字符串数据将从进程内存中删除。如果字符串没有被正确覆盖,恶意进程可能会转储进程内存并提取秘密。

    奖励:有趣的是,出于安全原因丢弃字符串数据的能力迫使某些编程语言(如 Java)返回密码为 char[] 而不是 String。在 Java 中,String 是不可变的,因此“丢弃”它会生成字符串的新副本。因此,您需要像char[] 这样不使用写时复制的可修改对象。

    编辑:如果你的编译器确实优化了这个调用,你可以使用特定的编译器标志来确保垃圾函数不会被优化:

    #ifdef WIN32
    
    #pragma optimize("",off)
    void trashString(std::string& str){
       std::fill(str.begin(),str.end(),0);
    }
    #pragma optimize("",on)
    
    #endif
    
    #ifdef __GCC__
    
    void __attribute__((optimize("O0"))) trashString(std::string& str) {
           std::fill(str.begin(),str.end(),0);
    }
    
    
    #endif
    
    #ifdef __clang__
    
    void __attribute__ ((optnone))  trashString(std::string& str) {
           std::fill(str.begin(),str.end(),0);
    }
    
    #endif
    

    【讨论】:

    • 我听说像std::fillmemset 这样的“休闲”方式可以被编译器优化。如果编译器没有看到字符串的后续用法,它不会产生过多的数据更改。像OpenSSL_cleanse 这样的函数是用来防止它的。我说的对吗?
    • @ilotXXI 这是真的。 OpenSSL_cleanse 似乎永远不会被优化掉。
    • 您可能需要考虑标准提供的保证:根据您组装字符串的方式,它的某些部分可能会留在其他地方如果 std::string 必须进行重定位,或者如果在调用堆栈中的某处制作了副本,则内存。
    • MSVC 创建memset_s 的确切原因是memset 可以被当今的优化编译器优化掉。由于编译器/链接器认为它是不需要的,因此丢弃不再使用的对象的内存变得越来越难。
    • 为什么您很难相信编译器会以 C++ 对象模型可以理解的方式优化掉不会产生明显副作用的代码?
    【解决方案5】:

    这可能是安全的。但不能保证。

    但是,由于C++11std::string 必须实现为连续数据,这样您就可以使用其第一个元素 &amp;secretString[0] 的地址安全地访问其内部数组。

    if(!secretString.empty()) // avoid UB
    {
        char* modifiable = &secretString[0];
        OpenSSL_cleanse(modifiable, secretString.size());
    }
    

    【讨论】:

    • 我个人喜欢这个答案,但是,前两句话包含太多可能性:) 如果数据以只读内存段结尾(或者会被编译器优化为读取-只有),这会导致应用程序进入UB区域吗? (如:operator&amp;const_cast 大不相同)
    • @hauron 获取读写元素的地址必须产生一个读写指针。我看不出编译器怎么会弄乱这个概念。
    • 从堆中的连续块中分配数据并写入(以在字符串内容之后存储空终止符)时,数据如何以只读段结尾?这样你就得到了char*,所以你可以写信给它。标准规定通过data() 返回的const char* 写入是未定义的。一个显然是安全的,一个是明确未定义的。使用哪个应该没有问题。
    • @JonathanWakely 和 Galik:你们都是正确的。它是一个可修改的值,它是一个连续块的一部分。修改安全。
    • @Jonathan Wakely:std::string 不承诺分配任何东西。它只承诺返回一个指向特定字节序列的指针。我们能想到的唯一实际的事情是通过分配来做到这一点,但这是一个实现细节而不是合同。举一个例子:针对 1 字节字符串优化的实现可以在不违反约定的情况下,通过返回(对于 1 字节字符串)指向 256 个可能的预构建 1 字节字符串中的一个的指针来避免所有内存分配只读存储器。
    【解决方案6】:

    std::string 是存储机密的糟糕选择。由于字符串是可复制的,并且有时副本会被忽视,因此您的秘密可能会“得到支持”。此外,字符串扩展技术可能会导致您的秘密片段(或全部)的多个副本。

    经验决定了一个可移动的、不可复制的、在销毁时擦干净、不智能(在引擎盖下没有棘手的副本)类。

    【讨论】:

    • 这是 OP 试图解决的“销毁时擦除”子问题。
    • 我断言析构函数是擦除的最佳位置。责任在于类设计者,而不是其使用的每个实例
    • @DavidThomas 但是编译器可能会优化析构函数中的重置,因为它知道不再使用该对象。
    • @DavidThomas 考虑一下:SecretHandler(std::string&amp;&amp; unprotected); - 你将如何废弃unprotected 输入? (这就是问题所在)
    • @hauron 挑战问题的假设是一个完全合理的答案。 “答案可以是‘不要那样做’,但也应该包括‘试试这个’。”见How do I write a good answer?。提问者有责任指定他们必须在哪些限制条件下运作,如果做某事是一个坏主意,那么说明为什么你不能摆脱它是很重要和必要的。
    猜你喜欢
    • 2013-06-16
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2010-12-23
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多