Introduction #
In our previous article (Hardening Against AI Reverse Engineering: Making Computers Struggle), we focused on what most code virtualization products disregarded over the years: Why data obfuscation is just as important as code obfuscation to us and why we want to provide a wider range of possibilities than what code obfuscators usually offer (basic string encryption utilities).
Now we'd like to take you through our SDK, its features and usage examples. We are still finalizing the product so at the time of release certain functionalities may be named or performed differently, but what we are presenting here is pretty much what it will look like.
In this post we'll go through our C++ implementation named POL (PROTEKKT Obfuscated Library), but we plan on expanding our SDK to other popular programming languages as well.
We are aware that many projects take longer to upgrade their toolset and C++ standard used, so the library is written to support C++17. If C++20 or C++23 are used, the SDK will automatically switch to a more efficient implementation or enable functionality that is not possible to implement without newer standards. When it becomes widely available, C++26 support in major compilers will allow us to implement even easier interfaces and new features.
Low-Level API #
At its core, everything is based on a low-level C API. The obfuscation process automatically replaces all instances and references to these functions with randomly generated encryption/decryption stubs implementing the logic behind them. This allows us to implement higher-level concepts in most programming languages that allow interaction with C libraries.
The API can be split into a few groups:
1. Runtime Value Obfuscation
Basic integer obfuscation for runtime values. Suitable for places where performance matters.
//! runtime value obfuscation
uint8_t protekkt_rvo_set8(uint8_t);
uint16_t protekkt_rvo_set16(uint16_t);
uint32_t protekkt_rvo_set32(uint32_t);
uint64_t protekkt_rvo_set64(uint64_t);
uint8_t protekkt_rvo_get8(uint8_t);
uint16_t protekkt_rvo_get16(uint16_t);
uint32_t protekkt_rvo_get32(uint32_t);
uint64_t protekkt_rvo_get64(uint64_t);
2. Advanced Integer Obfuscation
Complex integer obfuscation, supports standard operations without ever decoding complete values in plaintext form.
//! number obfuscation
void protekkt_num_init_u8(void* base);
void protekkt_num_init_u16(void* base);
// ...
void protekkt_num_add(void* dst, void const* src);
void protekkt_num_sub(void* dst, void const* src);
void protekkt_num_mul(void* dst, void const* src);
void protekkt_num_xor(void* dst, void const* src);
void protekkt_num_and(void* dst, void const* src);
// ...
3. Runtime Data Obfuscation
Used for obfuscating runtime data.
//! runtime data obfuscation
void protekkt_rdo_init(void* base);
void protekkt_rdo_decode(void const* base, size_t offset, void* dst, size_t size);
void protekkt_rdo_encode(void* base, size_t offset, void const* src, size_t size);
4. Static Data Obfuscation
Used for encrypting compile-time constants and any compile-time initialized data. Data is inserted between marker bytes to allow the obfuscator to locate data that needs to be obfuscated/encrypted.
//! static data obfuscation
void protekkt_sdo_decode(void const* base, size_t offset, void* dst, size_t size);
//! static data obfuscation markers
#define PTKT_SDO_BEG_MARKER { 'P', 'T', 'K', 'T', 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 'B', 'E', 'G', 0x00 }
#define PTKT_SDO_END_MARKER { 'P', 'T', 'K', 'T', 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0x00, 'E', 'N', 'D', 0x00 }
//! runtime data obfuscation prefix size
#define PTKT_RDO_MARKER_SIZE 16
Core C++ Types #
Types encapsulating the low-level C API, providing an intuitive way for initialization, reads and modification of obfuscated data. They are intended to be used directly, but are also the building blocks of more complex data structures.
Constructors, destructors and methods provided by these types are "force inline" to ensure unique obfuscations for different variables/objects instead of having a single point in the compiled binary that allows interception of all obfuscated values.
1. Runtime Value Obfuscation
A wrapper for integral/enum/boolean types and pointers. Provides overloaded operators to act as a drop-in replacement for any of them (assignment, retrieval, comparisons, arithmetic and bitwise operations, etc). RVO provides data encryption/decryption where performance matters more.
// protekkt/pol/rvo.hpp
template <typename T>
class rvo { /* implementation */ };
Instead of:
std::uint32_t value;
std::uint32_t bits;
std::uint32_t res = value << bits;
An obfuscated variant would be:
pol::rvo<std::uint32_t> value;
pol::rvo<std::uint32_t> bits;
pol::rvo<std::uint32_t> res = value << bits;
Or with pointers:
pol::rvo<my_class*> x;
if (x)
x->method();
2. Advanced Integer Obfuscation
Similar to RVO (a simpler and faster obfuscation for integral types), NUM is a significantly more obfuscated variant, a full implementation of integers. It supports all arithmetic and bitwise operations with both plaintext integral values and other instances of NUM, without ever decrypting the hidden values. The exception is the get()
method which is used when the developer explicitly wants to decrypt the value.
// protekkt/pol/num.hpp
template <typename T>
class num { /* implementation */ };
Usage example:
// initialize from runtime values
pol::num<std::uint64_t> score(1000);
pol::num<std::uint64_t> bonus(250);
// perform addition without exposing x, y or the result
x += y;
// perform multiplication without exposing score, multiplier or the result
pol::num<std::uint64_t> multiplier(2);
pol::num<std::uint64_t> result = score * multiplier;
// operator++ automatically encrypts a literal '1' into a temporary pol::num
// and performs obfuscated addition
++result;
// '4' is automatically encrypted into a temporary pol::num
// and obfuscated bitwise shift is performed
result <<= 4;
// pol::num decrypts stored values only if the user explicitly calls get()
// all operations are performed using obfuscated implementations on internal representation
auto plaintext = result.get();
3. Runtime Data Obfuscation
A class providing encryption/obfuscation of runtime data. The location of encrypted data is configurable through a template parameter depending on whether you want data to be part of the object itself or to be allocated on the heap.
// protekkt/pol/rdo.hpp
// storage policy tags
struct inline_t {};
struct dynamic_t {};
// reference types allowing access to specific members within
// complex obfuscated types (e.g. structs, arrays or arrays of structs)
template <typename U>
class const_rdo_ref { /* implementation */ };
template <typename U>
class rdo_ref { /* implementation */ };
// obfuscated wrapper (allowing inline and heap-allocated storage)
// supports single elements, struct instances and arrays
// configurable, internal data can be stored as RVO or NUM
template <typename T, typename Storage = inline_t>
class rdo { /* implementation */ };
Usage example:
struct InnerConfig
{
int id;
float threshold;
};
struct OuterConfig
{
int type;
InnerConfig inner;
};
// initialize the RDO container
pol::rdo<OuterConfig> config({ 1, { 42, 3.14f } });
// retrieving full config in unobfuscated form
auto full_config = config.read();
// retrieving threshold without decrypting full object
float threshold = config.ref(&OuterConfig::inner, &InnerConfig::threshold);
// modifying threshold without decrypting/encrypting the full object
config.ref(&OuterConfig::inner, &InnerConfig::threshold) = 9.99f;
Or, if you have functions that normally take a reference to a specific member:
void adjust_threshold(pol::rdo_ref<float> threshold_ref)
{
// reading implicitly decrypts just this float
float current_val = threshold_ref;
// writing implicitly encrypts the new value directly into the RDO buffer
if (current_val < 10.0f)
threshold_ref = 10.0f;
// the reference also supports standard operators
threshold_ref += 2.5f;
}
void example()
{
// initialize the RDO container
pol::rdo<OuterConfig> config({ 1, { 42, 3.14f } });
// pass the generated reference to the function
adjust_threshold(config.ref(&OuterConfig::inner, &InnerConfig::threshold));
}
4. Static Data Obfuscation
A class providing access to values known at compile-time. Supports automatic obfuscation of fundamental types as well as compile-time instances of more complex user-defined types (e.g. structs).
// protekkt/pol/sdo.hpp
// reference type allowing reads of specific members within
// complex obfuscated types (e.g. structs, arrays or arrays of structs)
template <typename U>
class sdo_ref { /* implementation */ };
template <auto V>
class sdo
{
public:
using T = decltype(V);
static_assert(std::is_trivially_copyable_v<T> || is_sdo_array_v<T>, "T must be trivially copyable for SDO");
// embed compile-time data inside markers, the data will be processed by the obfuscator
// during obfuscation the process alongside relevant access methods
struct layout_type
{
std::uint8_t beg_m[PTKT_RDO_MARKER_SIZE];
T data_m;
std::uint8_t end_m[PTKT_RDO_MARKER_SIZE];
};
private:
static inline PTKT_CONSTINIT layout_type data_m = { PTKT_SDO_BEG_MARKER, V, PTKT_SDO_END_MARKER };
public:
/* implementation */
};
Usage example:
// constant hidden in compiled binary
// retrieved into a temporary only upon request
auto x = pol::sdo<0x12345678>{}.get();
struct InnerConfig
{
int id;
float threshold;
};
struct OuterConfig
{
int type;
InnerConfig inner;
};
constexpr OuterConfig default_config_g = {
1, { 42, 3.14f }
};
void f()
{
pol::sdo<default_config_g> config;
// read just the threshold float without decrypting the entire object
float threshold = config.ref(&OuterConfig::inner, &InnerConfig::threshold);
// decrypts the entire OuterConfig object into a plaintext struct
auto full_config = config.get();
}
C++ Containers #
The components we went through (RVO, NUM, RDO and SDO) provide us with all functionality required to implement standard container types that allow developers to store data in encrypted/obfuscated form both as part of compile-time data embedded in the binary as well as runtime data. This does not refer only to the actual data being stored by the developer, but also internal book-keeping that containers naturally have to do. The primary focus is on ensuring that data is never decrypted into plaintext form except in cases where it is absolutely needed (usually only temporarily) or explicitly requested by the developer.
At the time of writing this article, POL implements obfuscated variants of the following standard containers: array, atomic, list, map, optional, pair, set, string, tuple, unordered_map, unordered_set, vector, unique_ptr and shared_ptr
.
The following examples demonstrate functionality and usage of certain containers. Most principles apply to all of them.
void string_example()
{
// runtime initialization from a C-string
// the string is immediately encrypted into the RDO heap buffer
pol::string my_str("Hello");
#if PTKT_HAS_CXX20
// C++20 compile-time initialization:
pol::string secure_str = pol::make_string<"Secret">();
#else
// C++17 compile-time initialization
pol::string secure_str = PTKT_SDO_STRING_C17("Secret");
#endif
// append standard C-strings directly to the encrypted buffer
my_str.append(", World");
my_str += "!";
// modify a specific character securely using the rdo_ref<char> proxy
// this encrypts the 'h' directly into the first index without decrypting the rest
my_str[0] = 'h';
// operator== provides comparison against plaintext strings
// or other instances of pol::string
if (my_str == "hello, World!")
std::print("The strings match.\n");
// read a specific character via the implicit decryption of the proxy
char fifth_char = my_str[4];
std::print("The 5th character is: {}\n", fifth_char);
// get the full string as a standard std::string
// this decodes the entire string into plaintext in memory
auto plaintext = my_str.get();
std::print("Exported plaintext: {}\n", plaintext);
}
#include "protekkt/pol/unordered_map.hpp"
struct InnerStats
{
int health;
float stamina;
};
struct PlayerData
{
int level;
InnerStats stats;
};
void unordered_map_example()
{
pol::unordered_map<int, PlayerData> players;
// insert using key and value directly
players.insert(1, { 10, { 100, 50.0f } });
// insert using pol::make_pair
players.insert(pol::make_pair(2, PlayerData{ 15, { 150, 75.0f } }));
// read via operator[] (implicitly decodes the full struct)
PlayerData player_one = players[1];
// read via find() and iterators
auto it = players.find(2);
if (it != players.end())
{
// iterators return a proxy reference pair, so we use (*it).second
// which yields an rdo_ref<PlayerData> that implicitly decodes on assignment
PlayerData player_two = (*it).second;
}
// operator[] returns an rdo_ref<PlayerData> -- we can use .ref()
// to securely target specific nested members in the obfuscated objects
// subtract 20 from player 1's health directly in the map
players[1].ref(&PlayerData::stats, &InnerStats::health) -= 20;
// assign a completely new value to player 1's stamina
players[1].ref(&PlayerData::stats, &InnerStats::stamina) = 100.0f;
// level up player 1 (modifying a top-level member)
players[1].ref(&PlayerData::level) += 1;
// range-based for loops are supported
// 'kv' gets deduced to pol::unordered_map_ref_pair<int, PlayerData>
for (auto kv : players)
{
int index = kv.first;
int health = kv.second.ref(&PlayerData::stats, &InnerStats::health);
std::print("Player Index: {} | Health: {}\n", index, health);
}
// erase a specific key
players.erase(2);
}