一个C++编译时字符串加密便利库
之前偶然在网上看到有文章说可以利用C++11引入的 constexpr 关键字实现编译时的字符串加密,使得二进制可执行文件中不含原来硬编码在源码中的字符串,从而增加逆向工程难度。经过一番研究后发现确实有可行性,但是网上的几个实现都有重大的 bug,忽略了其中的所有权问题,导致我一开始将其引入到一个中等项目的时候直接炸了。精心改进过的实现可以完美实现编译时加密和不保留原字符串的功能。
基本原理
constexpr
抄一下定义:
constexpr说明符声明编译时可以对函数或变量进行求值。这些变量和函数(给定合适函数实参情况下)即可用于需要编译期常量表达式的地方。
举个例子(不考虑VLA拓展):
1
2
constexpr int size = 4;
const char s[size] = "abc";
使用 constexpr 变量可以代替常量宏定义,且类型安全。
1
2
3
4
5
constexpr int get_size()
{
return 4;
}
const char s[get_size()] = "abc";
constexpr 函数同样可以应用于常量表达式语境,需要注意的一点是,constexpr 应用在函数上的语义是“ 可以在编译期求值”,也就是说编译期求值并不是强制的。
使用 constexpr 可以做到让编译器帮你做各种各样的编译期计算,让很多确定输入的算法变成O(1),我甚至觉得应该写成O(0),因为编译出来的汇编直接把结果作为立即数了,关于这点可以参考许多网络文章。编译期计算的功能并不是突然冒出来的(虽然说也是偶然发现的),早在C++98时期,就有人发现使用模板和类中的枚举项可以在编译期计算出质数,且不止数值,模板还可以对类型进行变换,模板元编程的能力远远超出了一开始为了支持泛型编程而引入的模板。后来C++标准也很重视元编程,一直在增加更友好的特性以支持和简化元编程,constexpr 就是一个例子。
编译期字符串加密与随机数生成
在逆向工程中,字符串字面量往往是程序的弱点,在源码中写下的字符串字面量编译后会放在程序的静态储存区中:
一旦知道字符串内容,使用IDA可以轻易地在.rdata段中搜索出其位置,进而找到引用字符串的所有代码片段。
如果只是修改字符串本身,甚至连逆向软件也不需要,只需要一个十六进制查看器即可:
如果我们在程序中存放的是经过加密后的字符串,等到程序运行时再将其解密,逆向所需的时间就会增大。这并不难做,我们完全可以在写程序时写加密过后的字符串,程序运行时需要读取的地方再手动解密,但是这样在使用上就十分麻烦,且牺牲了代码的可读性。理想的用法是不需要改动原字符串,只需增加一点修饰就可以自动完成加密工作。
那么要做到这一点,我们需要在编译期做加密的工作。加密方法当然是选择一个对称加密算法,例如异或。还有一个问题是,如果使用一个给定的密钥,那么安全性就会降低许多,更好的做法是让每一次编译的密钥都不相同。那么,如何让程序在编译期产生随机数呢?你肯定会说用伪随机数算法,但是我们还是需要一个随机产生的随机数种子,不然其随机序列也是固定的。
注意到C/C++的预处理器中有很多预定义宏,其中 __TIME__ 展开成程序编译时的时间,其形式如 "hh:mm:ss",我们可以用这个宏来产生一个随机数种子。
1
2
3
#define RANDOM_SEED ((__TIME__[0] - '0') * 1ULL + (__TIME__[1] - '0') * 10ULL + \
(__TIME__[3] - '0') * 60ULL + (__TIME__[4] - '0') * 600ULL + \
(__TIME__[6] - '0') * 3600ULL + (__TIME__[7] - '0') * 36000ULL)
上面的宏定义将 __TIME__ 转换成以秒为单位的整数作为随机数种子。
然后我们写一个递归的线性同余法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace detail {
constexpr unsigned long long LinearCongruentialGenerator(size_t times)
{
// same as std::minstd_rand
return 48271 * (times > 0 ? LinearCongruentialGenerator(times - 1) : RANDOM_SEED) % 2147483647;
}
constexpr unsigned long long GenerateRandom()
{
return LinearCongruentialGenerator((__TIME__[0] - '/') * (__TIME__[1] - '/'));
}
constexpr unsigned long long GetRandom(unsigned long long min, unsigned long long max)
{
return min + GenerateRandom() % (max - min + 1);
}
}
线性同余的参数采用标准库里定义的 minstd_rand 。函数 GenerateRandom 产生一个随机数,其调用线性同余产生器的次数也是由 __TIME__ 生成的,为秒的个位和十位分别加一后相乘的结果(加一避免出现零相乘减少随机性)。因为随机数的质量在这里不是很重要,在 GetRandom 中我们直接用取余得到目标范围内的随机数。
注意以上产生随机数的函数都有 constexpr 修饰,这样就可以在编译期调用并产生随机数了。
然后我们定义加密解密的函数:
1
2
3
4
5
6
7
8
9
10
11
12
namespace detail {
template<typename TChar>
constexpr TChar EncryptChar(TChar ch, TChar key)
{
return ~(ch ^ key);
}
template<typename TChar>
inline TChar DecryptChar(TChar ch, TChar key)
{
return ~ch ^ key;
}
}
加密我使用异或后取反,解密则是相反(异或的逆运算是其自身)。注意加密函数 EncryptChar 带的是 constexpr,而解密函数 DecryptChar 则没有,因为解密操作需要在运行期进行,带 inline 说明符是因为函数定义在头文件里,用于保证被不同源文件(.cpp)引入后只有一份定义。(是的,inline 的含义在Modern C++中已经变了。)(是的,我知道模板函数不是函数,不需要inline,但我们还是加上)
EncryptedString类
我们用一个类来储存加密字符串,在构造函数中进行加密,在另一个成员函数中解密并返回原字符串。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <climits>
template<typename TChar, size_t Size>
class EncryptedString
{
public:
constexpr EncryptedString(const TChar* s)
{
for (size_t i = 0; i < Size; i++)
str[i] = detail::EncryptChar(s[i], key + i);
}
operator const TChar*() const
{
for (size_t i = 0; i < Size; i++)
str[i] = detail::DecryptChar(str[i], key + i);
return str;
}
private:
static constexpr TChar key = static_cast<TChar>(
detail::GetRandom(0x00, (0x01ULL << sizeof(TChar) * CHAR_BIT) - 1)
);
mutable TChar str[Size] = {};
};
模板类 EncryptedString 的模板形参 TChar 用于区分不同的字符类型,例如 char 、wchar_t 、char16_t 、char32_t,在C++20中还新增了 char8_t 类型,模板形参 Size 表示字符串长度(包含零终止符)。
构造函数使用 constexpr 修饰,接受原字符串并加密储存至 str 。operator const TChar*() 定义类类型到其他类型的转换,除了函数签名比较特殊,它和正常的成员函数没什么两样。在转换函数中解密并返回指向字符串的指针,这与我们平时写下字符串字面量的语义基本是一致的,字符串字面量 "abc" 的类型为 const char[4],正常情况下使用的时候会退化成指针类型 const char* 。当然,这里更好的做法是返回原有类型,转换函数定义成:
1
2
3
4
5
6
7
...
using ReturnType = const TChar(&)[Size];
operator ReturnType() const
{
...
}
...
借助类型别名,返回完整数组类型的引用。
注意到在语义上我们应该使转换函数带上 const 限定符,那么为了能够修改 str 我们也要在上面加上 mutable 修饰符,以使其在 const 成员函数中也能修改。然而来自外网的一份实现中却在转换函数里先用 const_cast 将 this 去掉 const 限定符再进行修改,还特意注释这是“HACK”——显然他不知道 mutable 的语义所在。
密钥 key 的生成调用 GetRandom 产生 0 到字符类型能容纳的上限值,比如 wchar_t 是两个字节,那么随机数范围在 0~0xFFFF 。需要注意的是无符号整数转换为有符号的行为在C++20前是实现定义的。
这里还有一点细节就是加解密时用的密钥是会随着迭代次数变化的,这样有什么用呢?对于逐位异或操作,任何数异或 0 是其本身,也就是说如果字符串中出现很多 0 的话会很明显地有规律重复,因此 key + i 可以进一步增加复杂性。
使用接口
有了以上的实现之后,我们离能用就差一个用户使用的接口了,如何尽量简化用户的使用呢?我们首先能想到的就是利用宏,生成 EncryptedString 类的相关语句:
1
2
3
4
5
6
7
8
#include <type_traits>
#define GET_CHAR_TYPE(str) \
(std::remove_const_t<std::remove_reference_t<decltype((str)[0])>>)
#define GET_RETURN_TYPE(str) \
(std::decay_t<decltype(str)>)
#define _crypt(str) \
((GET_RETURN_TYPE(str))(EncryptedString<GET_CHAR_TYPE(str), sizeof(str)>(str)))
在C++17前没有类模板实参推导,我们需要指定模板实参。字符串长度用 sizeof(str) 就可以得到,而字符类型需要用一点类型变换得到,字符串字面量 "abc" 的类型是 const char[4],str[0] 的类型是 const char& (也可以用 *"abc" ),接着用 decltype() 得到类型,用 std::remove_reference_t<> 去掉引用,用 std::remove_const_t<> 去掉常量限定符,最终得到 char 。最后用 std::decay_t<> 得到结果类型并进行显式类型转换。
还有别的方法吗?C++11引入了用户定义字面量,也就是可以自己定义诸如 "abc"s 、123U 这样的字面量后缀,这样的话只需要在字符串末尾添加 _crypt 即可,而不用像宏那样两边都要加括号。那么我们就可以定义:
1
2
3
4
5
6
7
8
9
inline const char* operator""_crypt(const char* str, size_t len)
{
return EncryptedString<char, len>(str);
}
inline const wchar_t* operator""_crypt(const wchar_t* str, size_t len)
{
return EncryptedString<wchar_t, len>(str);
}
...
看起来不错,但是一编译就会发现一个问题:len 并不是一个常量表达式!
还记得 constexpr 修饰函数的含义吗?constexpr 函数是可以用于常量表达式,也就是说 len 并不是强制在编译期取得的,因此我们没法将其应用于模板实参。事实上,这也导致了 constexpr 构造函数具体能否在编译期执行是完全取决于编译器的优化能力和代码的复杂程度的。进一步而言,利用 constexpr 进行编译期加密的效果并不是百分百保证的。这点如果在代码量很大的项目里面应用就会显露出来。
……那先不管这个,我们能否做到不用宏来完成接口呢?我只想到了一个变通的方法,就是 EncryptedString内的字符数组改成定长的,然后加一个变量指示字符串长度:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<typename TChar>
class EncryptedString
{
public:
constexpr EncryptedString(const TChar* str, size_t len)
:size(len)
{
...
}
operator const TChar*() const
{
...
}
private:
static cosntexpr TChar key = ...;
size_t size;
mutable TChar str[1024] = {};
};
inline operator""_crypt(const char* str, size_t len)
{
return EncryptedString<char>(str, len);
}
...
使用起来就像这样子:
1
2
3
4
5
int main()
{
printf("%s", _crypt("abc")); // 1. 使用宏
printf("%s", "abc"_crypt); // 2. 使用自定义字面量
}
用IDA在 Release 编译的可执行文件中搜索,果然搜索不到原字符串了。
隐秘的bug——所有权问题
经过一番折腾,上面的代码看起来工作得很良好。但是如我开头所说,由于测试的程序规模太小,不足以暴露出其内在的bug,如果将其用于稍大规模的程序项目(1000行以上),问题就会显露无疑:加载解密字符串的时候出现了异常,指针指向的位置是乱码!
这是怎么一回事?通过debug我们可以发现,在解密之后的一段时间内,字符串在内存中还是有效的,但是当程序执行到需要调用字符串的时候(程序会多次经过需要字符串的地方),在某个时间点,内存里的有效字符串就突然被覆盖了。
是哪里出了问题?我们再看看 EncryptedString 的实现,和它的用法(用宏的实现同理):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <climits>
#include <cassert>
#include <string>
namespace detail {
...
}
template<typename TChar>
class EncryptedString
{
public:
constexpr EncryptedString(const TChar* s, size_t len)
: size(len)
{
for (size_t i = 0; i < size; i++)
str[i] = detail::EncryptChar(s[i], key + i);
}
operator const TChar*() const
{
assert(size <= sizeof str);
for(size_t i = 0; i < size; i++)
str[i] = detail::DecryptChar(str[i], key + i);
return str;
}
private:
static constexpr TChar key = static_cast<TChar>(
detail::GetRandom(0, (0x01ULL << sizeof(TChar) * CHAR_BIT) - 1)
);
size_t size;
mutable TChar str[1024] = {};
};
inline const char* operator""_crypt(const char* str, size_t len)
{
return EncryptedString<char>(str, len);
}
...
const char* secret = "IO1342I3U"_crypt;
int main()
{
...
use_secret1(secret);
...
use_secret2(secret);
...
}
当我们在第一行写下 = "IO1342I3U"_crypt 的时候发生了什么?首先,在理想情况下,编译器在编译时看到了 _crypt 后缀,匹配到了 operator""_crypt(),因为 EncryptedString 的构造函数带有 constexpr,其输入 "IO1342I3U" 又是一个常量表达式,类型和长度都已知,所以在编译时先将构造函数的工作做完了。然后在运行时再继续执行 operator const TChar*() 和 operator""_crypt() 里的内容。
在正常情况下,我们平时写下的字符串字面量的内容在编译后会存放在程序的静态储存区,具有静态储存期,在程序开始时分配,在程序结束时解分配,它的储存期跟全局变量是一样的。但是对于 "IO1342I3U"_crypt 来说,在 operator""_crypt() 里面返回的是一个 EncryptedString 的临时值,或者正式地叫纯右值。对于一个类类型,如果不用另外一个类实例接受这个临时值(通过拷贝或移动),那么这个临时值的生命周期仅限于在当前表达式,也就是说表达式求值结束后就会销毁临时值。换句话说,这跟直接写 new int[10] 是一样的,内存资源当场就泄露了。而上面的实现中类返回了字符串的指针,然而这个指针指向的内容过不了多久就被销毁了,成为了野指针。返回字符串的引用类型也是一样的,其实返回什么类型都一样,没有改变引用了一个即将销毁的内存位置的事实。至于为什么在小型项目里没有问题,我们都知道把电脑的文件删除其实不是真的删除了,而只是文件系统将其标记为已删除而已,所以数据短时间内还在那里。
这其实就是一个“所有权”的问题,EncryptedString 解密之后生成了一个原字符串的“资源”,它的生命周期需要程序员管理,而正常的字符串字面量储存在静态储存区,其生命周期可以看成是由语言规范指定的抽象机所管理的。然而就是这个看起来很简单的问题,我看到的所有实现中都没有考虑到,这导致其实现与其说是残废,不如说是暗藏炸弹。
那么我们应该怎么修复?很简单,我们只要返回一个具有所有权语义的类型就可以了,比如 std::unique_ptr<const char>,但是更好的做法是返回 std::string,因为它本身作为值语义的容器类型,天生就是自带所有权管理的。于是我们可以改成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<typename TChar>
class EncryptedString
{
...
operator std::basic_string<TChar>() const
{
assert(size <= sizeof str);
std::basic_string<TChar> decrypted;
decrypted.reserve(size);
for (size_t i = 0; i < size; i++)
decrypted += detail::DecryptChar(str[i], key + i);
return decrypted;
}
...
TChar str[1024] = {};
};
inline std::string operator""_crypt(const char* str, size_t len)
{
return EncryptedString<char>(str, len);
}
inline std::wstring operator""_crypt(const wchar_t* str, size_t len)
{
return EncryptedString<wchar_t>(str, len);
}
...
注意 operator""_crypt() 的第二个参数是字符串不包括零终止符的长度,所以在 decrypted += 的时候不会多添加个 '\0' 进去。str 也不再需要 mutable,因为我们不用修改它了。
修改方法看起来很简单,对吧?但是意识到这里面蕴含的抽象的(其实也不能算抽象)所有权问题才是难点所在,我个人认为这才是C++中重要的方法论,是其精髓所在。
那么现在,这个库总算是能称得上正确且能用,但说实话能否起作用还是要看编译器的优化能力,编译器能否优化掉原字符串全看代码复杂程度。
But Wait!!!
后来我发现C++20引入了新的特性,解决了这其中最核心的问题,从而使编译时字符串加密可以完美地实现。
完善EncryptedString
C++20前的缺陷
1
inline std::string operator""_crypt(const char* str, size_t len);
再次观察用户定义字面量运算符的签名,之所以 len 不能用于指定 EncryptedString 的模板形参,是因为作为一个函数入参,即使有 constexpr 修饰,也不能保证 len 一定在常量表达式中取得——因为 constexpr 只是能用于常量表达式语境。这样一来,为了保证 len 能用于正常的运行时函数调用,编译器不会同意将其作为模板实参。当时遇到这个问题的时候,我绞尽脑汁也没能想出变通的方法,只有用宏才能完整实现,便觉得这是C++标准的缺陷。事实上这确实是个缺陷,因为字符串字面量分明是个在编译期就已知的常量,凭啥不能在编译期取得。直到C++20标准出炉,事情才有了转机。
C++20引入的新特性
一开始我以为新引入的 consteval 和 constinit 可以通过声明函数只在编译期执行来解决问题,但一番折腾后却无功而返。后来我发现标准用了一种更聪明的方法来解决这个问题。
对于模板的形参列表,可以有模板类型形参、模板非类型形参和模板模板形参。举个例子,EncryptedString 中的 template<typename TChar, size_t Size>,typename TChar 就是模板类型形参,TChar 表示一个类型,而 size_t Size 是模板非类型形参,Size 表示 size_t 类型的一个实例,这个和函数形参是一样的意思。在C++20之前,模板非类型形参只能使用左值引用类型、整数类型、指针类型和枚举类型,在C++20后可以使用浮点类型和符合要求的类类型。而支持使用类类型简直是爆炸性的特性,大大地开拓了模板元编程的使用范围。
在模板非类型形参中使用类类型需要满足以下要求:
- 为字面类类型(即有
constexpr构造函数)- 所有基类与非静态数据成员是
public且非mutable的- 所有基类与非静态数据成员的类型都是结构化类型或它的(可能多维的)数组
并且类类型的模板非类型形参是一个静态储存期对象,它的名字指代一个 const T 的对象。
接着,由这个新特性引出了字符串字面量运算符模板:
1
2
3
4
5
6
struct A {
constexpr A(const char*);
};
template<A a>
A operator""_x();
对于用户重载的字面量运算符,要么是形如 operator""_x(const char*, size_t) 的非模板签名,要么是形如 template<A a> auto operator""_x() 的模板签名。
在C++20的完整实现
通过使用模板,可以用模板非类型形参接收字符串字面量,这个过程一定是发生在编译期的,并且实参是静态储存期的,不用我们手动管理生命周期。这样不仅解决了在编译期拿不到字符串长度的问题,还解决了实际效果依赖于编译器能力的问题。并且我们也不用重复写五个不同字符类型的函数重载了,现在就可以改成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
template<typename TChar, size_t Size>
class EncryptedString
{
public:
using CharType = TChar;
constexpr EncryptedString(const TChar(&s)[Size])
{
for (size_t i = 0; i < Size; i++)
str[i] = detail::EncryptChar(s[i], key + i);
}
operator std::basic_string<TChar>() const
{
std::basic_string<TChar> decrypted;
decrypted.reserve(Size - 1);
for (size_t i = 0; i < Size - 1; i++)
decrypted += detail::DecryptChar(str[i], key + i);
return decrypted;
}
public:
TChar str[Size] = {};
private:
static constexpr TChar key = static_cast<TChar>(
detail::GetRandom(0, (0x01ULL << sizeof(TChar) * CHAR_BIT) - 1)
);
};
template<EncryptedString Str>
inline std::basic_string<typename decltype(Str)::CharType> operator""_crypt()
{
return Str;
}
int main()
{
std::string s = "abc"_crypt;
}
由于标准的要求我们把 str 改成 public,并且现在 EncryptedString 的实例在静态储存区,因此在所有权上我们仍然要返回一个 std::basic_string,因为静态储存区的东西是不能修改的,这里我们可以认为解密操作生成了一个临时字符串。
需要注意的是,换成模板形式的 operator""_crypt() 后,传递给 EncryptedString 的字符串是包含零终止符的,所以 Size 是包含零终止符的长度,在解密的时候只循环 Size - 1 次,避免在 std::basic_string 中多加 '\0' 。
这里还有个细节,EncryptedString 在模板形参实例化的过程中使用了C++17引入的类模板实参推导,使得我们不用手动指定模板参数,也使得我们不需要写多个函数重载,其实际过程是通过构造函数的参数进行推导的,因此构造函数的参数也改成了数组的引用。同样由于这个原因,operator""_crypt() 的返回类型需要通过 decltype(Str)::CharType 拿到。
效果测试
使用在C++20上的实现进行测试:
第二行有红线是Visual Studio的智能提示有bug,编译是可以通过的。(已于VS 17.5.0中修复)
在IDA中搜索:
果然搜索不到加密的字符串了,查看 test() 的反汇编代码:
也可以看出没有对明文字符串的调用。
事实上,上面的代码是在 Debug 模式下编译的!用 Release 编译就更看不出来了。这说明C++20的实现已经避免了看脸的情况,是真正意义上的在编译期执行的加密操作。到了这一步,EncryptedString 才真正算是一个能用、优雅的库了。
当然,这里面还需要一点其他的细节,具体可以参考我放在Github上的实现。
参考资料




