Home Making Attackers Work Harder: Data Obfuscation in C++
Post
Cancel

Making Attackers Work Harder: Data Obfuscation in C++

In the endless race between game developers and reverse-engineers, obfuscation has been extensively utilized by both parties. It’s wielded by game developers, protecting their games from being attacked, and cheat developers, looking to hide from anti-cheat systems. One of the most common techniques is XOR-based string obfuscation. However, the same principles applied to string obfuscation can be applied to other types of data.

Motivations for Data Obfuscation

Game developers often employ obfuscation to deter cheating. For example, a great way to increase the difficulty of cheat development is to encrypt important pointers. This increases the complexity of static-analysis and discourages cheaters from using memory exportation tools such as ReClass .NET. Additionally, any attacker that wants to read an obfuscated pointer must deobfuscate the pointer at runtime. If the obfuscation changes with each game update, maintaining a cheat can become a continually evolving challenge.

On the flip side, cheat developers utilize obfuscation to prevent detections by anti-cheats and deter reverse-engineering from red teams or other cheaters. Many cheaters opt to obfuscate strings, which can help protect their strings from detections such as BattlEye’s laughable memory pattern searches. Another benefit of string obfuscation is the increased effort required to analyze the cheat. Recognizable strings such as “Aimbot” or “ESP” can typically provide clues about the functionality of adjacent code or be detected by anti-cheats.

The Principles

The Heart of Obfuscation - XOR

The XOR (exclusive OR) binary operation (often represented as ‘^’) has been used as a cornerstone for a number of obfuscation techniques due to its simplicity and reversibility. For example, the CBC mode of AES XORs each block of plaintext with the previous ciphertext block before being encrypted. Another prime example is RC4, where a key is used to generate a pseudo-random stream of bits called a keystream. This keystream is then XORed with the plaintext to produce the ciphertext.

Fowler–Noll–Vo

When developing software, there are numerous reasons why you might need to generate a hash. Perhaps you’re ensuring data integrity, building a hash table, or even obfuscating sensitive information. Fowler–Noll–Vo (FNV) is a fairly simple and fast non-cryptographic hashing algorithm. It is designed to have a low collision rate, making it particularly useful for hash table-based lookups. The FNV hash function comes in different flavors, the most common being FNV-1 and FNV-1a.

Here’s a brief outline of how FNV-1 works:

  1. Start with an initial hash value, which is an offset basis
  2. For each byte in the input, multiply the current hash value by a prime number, then XOR it with the current byte

FNV-1a is an improvement of FNV-1 which simply swaps the order of the XOR and multiplication instructions and is what I chose to use for hashing in my project. A somewhat unique aspect of my implementation is its ability to produce semi-unique values per compilation by hashing the time of compilation. This feature can is invaluable for generating diverse code across different compilations, requiring continuous reverse-engineering.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
constexpr uint64_t FNV_PRIME_64 = 1099511628211;
constexpr uint64_t FNV_BASIS_64 = 14695981039346656037;

template <uint64_t Seed, typename CharType> 
constexpr uint64_t fnv64(std::basic_string_view<CharType> data)
{
    uint64_t result = FNV_BASIS_64 ^ Seed;
    for (CharType c : data)
    {
        result ^= static_cast<uint64_t>(c);
        result *= FNV_PRIME_64;
    }
    return result;
}

template <uint64_t Seed>
constexpr uint64_t fnv64(std::string_view data)
{
    return fnv64<Seed, char>(data);
}

Utilizing the MSVC Compiler

As some might know, the MSVC (Microsoft Visual C++) compiler (and many other compilers) can evaluate code. This means code, typically pure functions, can be executed while the program is still compiling. Thus the compiler can be utilized to transform data before an executable file even touches the filesystem! This has been used in many projects to obfuscate strings. However, the compiler is a very powerful tool and can be utilized to obfuscate practically any deterministic data, such as integers and floating point values.

Another great use of the compiler is to generate pseudo-random values for our application to utilize at run time. By hashing the time of compilation (__TIME__ macro) as previously described, the key64 function can generate unique values with each compilation.

1
2
3
4
5
template <uint64_t Seed>
constexpr uint64_t key64()
{
    return fnv64<Seed>(__TIME__);
}

Building on this concept, my UNIQUE_SEED64 macro leverages a combination of these preprocessor macros: line number, counter value, file name, and compile time. Utilizing the diversity of these macros, UNIQUE_SEED64 is designed to produce a distinct value each time it’s invoked.

1
#define UNIQUE_SEED64 hash::key64<hash::fnv64<USABLE_LINE+__COUNTER__>(__FILE__)>()

SIMD

SIMD (Single Instruction, Multiple Data) is a parallel computing concept where a single instruction operates on multiple data elements simultaneously. In more simple terms, it’s like giving one command that affects several pieces of data at the same time.

SIMD operations are implemented using specialized instructions sets such as AVX (Advanced Vector Extensions) and SSE (Streaming SIMD Extensions). For this project, I chose SSE for its smaller register size and support for older processors. A fun exercise for the reader could be upgrading instruction set from 128-bit SSE to 256-bit or 512-bit AVX. But why use SIMD in the first place? By utilizing SIMD operations, simple code can become less intuitive as seen in these examples:

  • Simple XOR (Without SIMD):
    1
    2
    3
    
    uint32_t a = 0x12345678;
    uint32_t b = 0x87654321;
    uint32_t result = a ^ b;
    
  • Obfuscated XOR using SSE (With SIMD):
    1
    2
    3
    
    __m128i a = _mm_set_epi32(0, 0, 0, 0x12345678);
    __m128i b = _mm_set_epi32(0, 0, 0, 0x87654321);
    uint32_t result = _mm_extract_epi32(_mm_xor_si128(a, b), 0);
    

In the obfuscated version, only 32-bits of the each SSE register are used to perform the XOR operation. This is overkill, but makes the code harder to understand at a glance. While this might deter a casual reverse-engineer, a dedicated attacker with knowledge of SIMD can still easily understand the operations.

Constant Obfuscation

Constants can easily give reverse-engineers information about the code they are analyzing. For example, say you run across an array of static integers while reverse-engineering a program. The first integer is 0x7b777c63. A quick Google search reveals that this integer represents the first 4 bytes of the AES algorithm’s substitution box, thus revealing potentially critical information about the program in this scenario. The XorConstant class is designed to encrypt constants at compile time by employing XOR operations and basic arithmetic. With an emphasis on minimal overhead, the encryption and decryption processes are kept straightforward.

Compile-Time Encryption

When the XorConstant is instantiated by the compiler, the provided constant is encrypted in two steps:

  1. Two unique values are generated (XorKey and AddKey) are generated by adding/subtracting the line number from the Seed template parameter. By subtracting/adding the line number, we can ensure that the keys will be unique even if they are declared on the same line of code.
1
2
constexpr static uintptr_t XorKey = hash::key<Seed - __LINE__>();
constexpr static uintptr_t AddKey = hash::key<Seed + __LINE__>();
  1. The constant is encrypted using this simple function, which takes the input data and applies an XOR operation followed by an addition operation. The result of these operations is then stored in m_encryptedData.
1
2
3
4
5
6
7
constexpr void EncryptData(uintptr_t data)
{
    __m128i result = _mm_set1_epi64x(data);
    result = _mm_xor_si128(result, _mm_set1_epi64x(XorKey));
    result = _mm_add_epi64(result, _mm_set1_epi64x(AddKey));
    m_encryptedData = result;
}

Decryption

The constant decryption process utilizes many of the same intrinsic functions as the encryption routine. The addition key is applied to the encrypted data followed by an XOR operation. Then, the lower 32-bit/64-bit integer in result is returned.

1
2
3
4
5
6
7
8
9
10
11
__declspec(noinline) uintptr_t DecryptData()
{
    __m128i result = m_encryptedData;
    result = _mm_sub_epi64(result, _mm_set1_epi64x(AddKey));
    result = _mm_xor_si128(result, _mm_set1_epi64x(XorKey));
#ifdef _WIN64
    return _mm_cvtsi128_si64(result);
#else
    return _mm_cvtsi128_si32(result);
#endif
}

Something to note about the DecryptData function is the use of __declspec(noinline) to discourage the MSVC compiler from performing the SIMD operations at compile time and thus revealing the decrypted constant. Typically, I would use the optimization pragma to disable optimizations for an individual function. However, for templated functions (or in this case, a function within a templated class), the optimization pragma can’t be used on a per-function basis because template specializations are compiled when the end of the source file is reached.
In spite of this never being mentioned on the above-linked Microsoft Learn page, it is the intended behavior of the compiler.

Variable Obfuscation

The XorVariable class is designed to encrypt/decrypt variables (including pointers) at runtime. The primary use of this class is to obfuscate dynamic data such as a player’s score or a pointer to the table of entities in a game. This can be particularly useful to protect against trivial attacks such as Cheat Engine scans. Please note this class should not be used to obfuscate constants or values the compiler can deduce. Under these circumstances, the compiler will reveal the initial value of the variable being obfuscated.

Keys

Three constant keys are utilized by each template specialization of the XorVariable class. This trio of keys are obfuscated using the XorConstant class. Computing diverse pseudo-random values from a limited set of random inputs can pose a significant challenge. In the case of the XorVariable class, the Seed template parameter and the __LINE__ (USABLE_LINE) macro are the only sources of randomness available, and we must generate multiple values based on them. The ComputeKeySeed and ComputeKeyValue functions are very simple and only serve to compute key seeds/values based on the line number input and the XorVariable’s seed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <uint32_t LineNumber>
static constexpr uintptr_t ComputeKeySeed()
{
    return Seed % (LineNumber + 1);
}

template <uint32_t LineNumber>
static constexpr uintptr_t ComputeKeyValue()
{
    return UNIQUE_SEED + ComputeKeySeed<LineNumber>();
}

__m128i m_encryptedData = {};

XorConstant<uintptr_t, ComputeKeyValue<USABLE_LINE>(), ComputeKeySeed<USABLE_LINE>()> Key1 = {};
XorConstant<uintptr_t, ComputeKeyValue<USABLE_LINE>(), ComputeKeySeed<USABLE_LINE>()> Key2 = {};
XorConstant<uintptr_t, ComputeKeyValue<USABLE_LINE>(), ComputeKeySeed<USABLE_LINE>()> Key3 = {};

Encryption

When the XorVariable constructor or assignment operator is called, the input data undergoes a series of operations:

  1. Initially, the data is transformed into a 128-bit SIMD integer using the Encode function. This function simply loads an integer, pointer, float, or double into a 128-bit integer using the respective SSE intrinsic.
  2. Once encoded, the data is subjected to a sequence of XOR, subtraction, and addition operations. Something to note is the use of both decrypted and encrypted XorConstant values. This approach conveniently provides multiple constant values to aid in the decryption/encryption routines.
1
2
3
4
5
6
7
8
9
10
11
__forceinline void Encrypt(T data)
{
    __m128i result = Encode(data);
    result = _mm_add_epi64(result, _mm_set1_epi64x(Key1.GetCrypt()));
    result = _mm_xor_si128(result, _mm_set1_epi64x(Key1.Get()));
    result = _mm_sub_epi64(result, _mm_set1_epi64x(Key2.GetCrypt()));
    result = _mm_xor_si128(result, _mm_set1_epi64x(Key2.Get()));
    result = _mm_add_epi64(result, _mm_set1_epi64x(Key3.GetCrypt()));
    result = _mm_xor_si128(result, _mm_set1_epi64x(Key3.Get()));
    m_encryptedData = result;
}

Decryption

The encrypted data can be decrypted in two scenarios: the GetCrypt function is called or the arrow operator is used on an XorVariable object. In either case, the decryption process is very similar to the encryption routine:

  1. The XOR, subtraction, and addition operations, initially used during encryption, are now applied in reverse to the encrypted data. This means that both the order in which the SSE intrinsics are called and the order in which the keys are used are reversed.
  2. The decrypted data is now transformed from a 128-bit SIMD integer back to an integer, pointer, float, or double using the Decode function.
1
2
3
4
5
6
7
8
9
10
11
__forceinline T Decrypt()
{
    __m128i data = m_encryptedData;
    data = _mm_xor_si128(data, _mm_set1_epi64x(Key3.Get()));
    data = _mm_sub_epi64(data, _mm_set1_epi64x(Key3.GetCrypt()));
    data = _mm_xor_si128(data, _mm_set1_epi64x(Key2.Get()));
    data = _mm_add_epi64(data, _mm_set1_epi64x(Key2.GetCrypt()));
    data = _mm_xor_si128(data, _mm_set1_epi64x(Key1.Get()));
    data = _mm_sub_epi64(data, _mm_set1_epi64x(Key1.GetCrypt()));
    return Decode(data);
}

String Obfuscation

Strings are an absolute goldmine for reverse-engineers. They can reveal error messages, debug information, variable names, file names, etc. The best way to ensure reverse-engineers never find your strings is by removing them from your codebase. However, this typically isn’t feasible or scalable in any capacity. So, what is the best alternative? Obfuscating strings. The XorString class is designed to encrypt each character in a byte or wide string at compile time using a constant key.

Compile-Time Encryption

Using the XOR operation, a simple method of string obfuscation can be achieved. However, this obfuscation is trivial to break and should not be utilized for any secrets such as private keys. The XorVariable class has two template parameters that are utilized during encryption: Length and Key. The compile-time encryption of a string is the responsibility of the Encrypt function, where each character of the provided string is XORed with the key. In an attempt to discourage attacks such as brute force or frequency analysis, the current index (up to 13, an arbitrary number) is added to the key.

1
2
3
4
5
6
7
constexpr void Encrypt(const CharType* string)
{
    for (size_t i = 0; i < Length; i++)
    {
        m_encryptedData[i] = string[i] ^ (Key + (i % 13));
    }
}

Decryption

When the original string is needed, the Decrypt function decrypts the string by performing the same operations on the encrypted data that were performed on the plaintext in the encryption routine. However, the decryption routine uses SIMD instructions to add slight confusion for any prying eyes.

1
2
3
4
5
6
7
8
9
10
11
12
13
__forceinline std::basic_string<CharType> Decrypt()
{
    std::basic_string<CharType> result = {};
    result.reserve(Length);
    
    for (size_t i = 0; i < m_encryptedData.size(); i++)
    {
        __m128i decrypted = _mm_xor_si128(_mm_set1_epi16(m_encryptedData[i]), _mm_set1_epi16(Key + (i % 13)));
        result.push_back(static_cast<CharType>(_mm_extract_epi16(decrypted, 0)));
    }

    return result;
}

Optimization

A renowned enemy of compile-time XOR string obfuscation is the MSVC compiler itself, but only in certain circumstances. When optimization is turned off, the compiler typically prefers to not obfuscate the string. Instead, the string is left in plaintext to be encrypted at runtime. This is an obvious issue if the entire point of string obfuscation is hide the plaintext.

By utilizing a lambda, we can encourage the compiler to perform the required obfuscation:

1
2
3
4
5
6
7
8
[&]() 
{
    using CharType = std::remove_const_t<std::remove_reference_t<decltype(*str)>>;
    constexpr size_t length = sizeof(str) / sizeof(CharType) - 1;
    constexpr CharType key = hash::fnv64<UNIQUE_SEED64>(str) & ((1ull << (sizeof(CharType) * 8)) - 1);
    constexpr auto result = enc::XorString<CharType, length, key>(str);
    return result;
}()

Notice that each variable declared in the lambda is constexpr. This implicitly makes the lambda constexpr. Additionally, this lambda can be easily utilized for variable assignment and parameter passing using the MakeXorString macro, which is the origin of the str variable seen in the body of the lambda:

1
auto string = std::string(MakeXorString("hello, world! i am encrypted").GetCrypt());

Lifetime

A key feature of the XorString class is the construction of a new std::basic_string object containing the plaintext for each call to Decrypt or GetCrypt. Since a new object is created, the plaintext will exist on the heap until the object’s deconstructor is called. Another consideration is the allocated memory that the string occupied on the heap will not be filled with zeros when freed, potentially leaving remnants of the plaintext in memory.

The alternative approach is to store the decrypted string in the XorString class and return a reference to this member after decrypting the string. However, this strategy requires the string to be re-encrypted after the calling code is finished with the plaintext.

I opted to return a new string object for a few key reasons:

  1. No additional call to re-encrypt the XorString’s data member is needed. The class will never have a member variable that holds plaintext, which could expose the string to static analysis in the rare case the program’s memory is dumped while the string is in a decrypted state. In my approach, the plaintext will be temporarily exposed on the heap, but tracking the origin of the string will require some dynamic analysis.
  2. Using a member variable to store plaintext/ciphertext can lead to concurrency issues in multi-thread environments, though there are potential workarounds. The following scenario illustrates this pitfall: Suppose ‘Thread A’ and ‘Thread B’ utilize a global XorString object. ‘Thread A’ decrypts the member variable. While ‘Thread A’ is still processing the data, ‘Thread B’ simultaneously obtains a reference to the same member variable. Both threads operate on this data concurrently. However, ‘Thread A’ completes its tasks and re-encrypts the member variable before ‘Thread B’ finishes. This leaves ‘Thread B’ operating on the assumption that the data is still in plaintext, even though it has been re-encrypted. This can lead to expected behavior.

Improvements

There are several improvements which can easily be to the XorString class. Arguably, the most obvious improvement is implementing a blocking system. For example, each 16 bytes of the plaintext could be stored in a 128-bit SIMD integer. The encrypted data member variable would then be an array of 128-bit SIMD integers, instead of an array of characters. Another potential improvement is to upgrade the Key template parameter. The size of the key should be increased to a 32-bit, 64-bit, 128-bit integer. Additionally, the key should be utilized in a less predictable way than what is currently implemented. Each block or character of plaintext should be encrypted using a value derived from the key based on the block/character index.

Conclusion

Obfuscation techniques serve as an important deterrent against various attacks and reverse-engineering. While game developers employ obfuscation to safeguard the experiences of players worldwide, cheat developers are always seeking to bypass these protective measures. This dynamic interplay pushes both sides to innovate and adapt.

We explored three classes: XorConstant, XorVariable, and XorString. The XorConstant class is designed to encrypt constants at compile time with minimal overhead. The XorVariable class focuses on encrypting and decrypting variable values at runtime. Lastly, the XorString class stands out for its ability to encrypt strings at compile time. Together, these classes form a robust toolkit for developers to expand and modify, with the goal of enhancing their application’s security through obfuscation

In closing, data obfuscation is an important asset, but it should not solely be relied upon. Performance implications might arise, and dynamic analysis can bypass obfuscation. The adage “defense in layers” rings truer than ever when protecting your application. Just as a castle doesn’t rely on walls alone but also on moats, guards, and watchtowers, a robust security implementation requires multiple layers of defense. The goal isn’t to create an impregnable fortress but to continuously evolve and adapt, keeping adversaries guessing.

Disclaimer: The obfuscation techniques described here are not intended to be foolproof. They serve as a foundation that should be enhanced further. The goal of this project is not to provide absolute protection but to obfuscate data effectively. Please note the provided code is designed for MSVC and requires C++17 or newer.

This post is licensed under CC BY 4.0 by the author.
Trending Tags