[C++]コンパイル時に文字列リテラルに軽い難読化を施す
C++で文字列リテラルを使用するとその文字列は直接バイナリに埋め込まれます。
そのため、exe
ファイルなどをバイナリエディタで開けば簡単にどのような文字列が埋め込まれているのかを確認できます。
以下のようなHello, World!
を出力するプログラムをビルドします。
int main()
{
constexpr auto str = "Hello, World!";
std::cout << str << std::endl;
return 0;
}
ビルド後に出力されたexe
をバイナリエディタで開きHello, World!
を検索すると文字列がバイナリに埋め込まれているのが確認できます。
プログラム中に埋め込む必要のある文字列で誰かに見られて困るもの、というのはそう多くはありません。開発中に変更することがほとんどない文字列は事前に難読化を施しておき実行時に複合化する程度でも十分です。
ですが、せっかくC++を書いているのでコンパイル時に難読化できないかを試してみます。
実装例
MSVC++
で動作確認を行っています。
手始めに簡単な難読化プログラムを実装します。
template<class T, std::size_t N>
struct encrypted_string;
template<std::size_t N>
struct encrypted_string<char, N> final
{
constexpr encrypted_string(const std::array<char, N>& data) noexcept
: data(data)
{
}
constexpr std::string_view to_str_view() const noexcept
{
return { data.data(), data.size() };
}
constexpr char operator[](std::size_t i) const noexcept
{
return data[i];
}
const std::array<char, N> data;
};
文字列リテラルには終端文字(\0
)が含まれているので除外しておきます。
難読化コードにはconstexpr
を使用し、コンパイル時に難読化が行えるようにします。
template<std::size_t N>
constexpr auto encrypt(const char(&input)[N])
{
constexpr auto SIZE = N - 1;
std::array<char, SIZE> result;
for (std::size_t i = 0; i < SIZE; i++)
{
// +1ずらす
result[i] = static_cast<char>(input[i] + 1);
}
return encrypted_string<char, SIZE>{ result };
}
複合化プログラムではconstexpr
を使用しません。
コンパイル時に複合化を行ってしまうと複合化された文字列が埋め込まれてしまいます。
template<std::size_t N>
std::string decrypt(const encrypted_string<char, N>& input)
{
std::string result;
result.resize(input.data.size());
for (std::size_t i = 0; i < result.size(); i++)
{
// -1もどす
result[i] = static_cast<char>(input[i] - 1);
}
return result;
}
上記のコードを使用し、難読化・複合化を出力します。
難読化関数を使用する場合は必ずconstexpr
を使用してください。
int main()
{
constexpr auto ret = encrypt("Hello, World!");
std::cout << "encrypted: " << ret.to_str_view() << std::endl;
std::cout << "decrypted: " << decrypt(ret.to_str_view()) << std::endl;
return 0;
}
encrypted: Ifmmp-!Xpsme"
decrypted: Hello, World!
exe
をバイナリエディタで開き確認するとHello, World!
の文字列がどこにも見当たらないのが確認できます。
難読化されたデータであるIfmmp-!Xpsme
はそのまま連続した領域には埋め込まれてはいませんが4byteごとの塊で埋め込まれていることが確認できます。
(コンパイラ実装や最適化による理由などがあるかもしれません)
もう少し複雑に
文字の加算や減算では単純で、加算や減算によってオーバーフローが発生してしまうのも避けたいです。
なので文字ごとにXOR
を、XOR
で使用する値を乱数にすることで少し複雑な難読化を行います。
乱数生成器はコンパイル時に計算できるものであればなんでもいいですが今回はxor shift
を採用しました。
class xorshift_random_generator final
{
public:
constexpr xorshift_random_generator(std::uint32_t seed) noexcept
: seed(seed)
{
}
template<class Out>
constexpr Out next() noexcept
{
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
// 縮小変換が行われる可能性があるので変換先の型のbitでマスクする
return static_cast<Out>(seed & std::numeric_limits<Out>::max());
}
private:
std::uint32_t seed;
};
文字列格納クラスに乱数のシード値を格納できるようにします。
struct encrypted_string<char, N> final
{
constexpr encrypted_string(const std::array<char, N>& data, std::uint32_t seed) noexcept
: data(data), seed(seed)
{
}
// ~~ 中略 ~~
const std::uint32_t seed = 0;
};
難読化・複合化関数にはシード値を指定できるようにし、XOR
で文字列の値のビットを反転させます。
template<std::size_t N>
constexpr auto encrypt(const char(&input)[N], std::uint32_t seed)
{
constexpr auto SIZE = N - 1;
xorshift_random_generator gen{ seed };
std::array<char, SIZE> result;
for (std::size_t i = 0; i < SIZE; i++)
{
result[i] = input[i] ^ gen.next<char>();
}
return encrypted_string<char, SIZE>{ result, seed };
}
template<std::size_t N>
std::string decrypt(const encrypted_string<char, N>& input)
{
xorshift_random_generator gen{ input.seed };
std::string result;
result.resize(input.data.size());
for (std::size_t i = 0; i < result.size(); i++)
{
result[i] = input[i] ^ gen.next<char>();
}
return result;
}
ここまでのプログラムを実行するとより複雑な文字列が出力されることが確認できました。
int main()
{
constexpr auto ret = encrypt("Hello, World!", 1);
std::cout << "encrypted: " << ret.to_str_view() << std::endl;
std::cout << "decrypted: " << decrypt(ret) << std::endl;
return 0;
}
encrypted: id)#>|:eJ'S+
decrypted: Hello, World!
これで乱数によるXOR
を使用した難読化・複合化が実現できました。
__COUNTER__
を使用して呼び出し箇所ごとに乱数のシード値を変化をさせたり、より複雑にするのであればブロック暗号化の方式の採用を検討するのも面白いかもしれません。
使いどころは少ないですがカジュアルハック対策にはなるかと思います。
Discussion