Creating a "Two-Face" Rust binary on Linux

Written by Maxime Desbrus - 28/10/2025 - in Développement, Cryptographie, Système - Download

In this article we will describe a technique to easily create a "Two-Face" Rust binary on Linux: an executable file that runs a harmless program most of the time, but will run a different, hidden code if deployed on a specific target host. We will also detail how to make the "hidden" binary more difficult to inspect in memory.

Looking to improve your skills? Discover our trainings sessions! Learn more.

Problem statement

Let’s say you want to run a malicious program on a specific target machine. One way to do this is to distribute the program very widely, and hope that the target will end up running it. The specific distribution vector is out of the scope of this article, but you can imagine for example a pre-compiled binary file, as developers often download on their favorite project GitHub project page.

However if you want to maximize the chance of reaching the target, you probably want to mimic the behavior of a harmless program, and avoid anything suspicious (ie. connecting to a C&C server) that could trigger detection by various solutions (sandboxs, LSM, auditd, etc.).

Up until now, it sounds rather simple, so let’s see how we could build this.

Designing our schizophrenic binary

In the rest of this article, we name “hidden” the program we want to run on the target host, and “normal” the harmless one we will run on the others.

A naive way to build such program is to make a decision early on what code to actually run, ie. :

if is_running_on_target_host() {
    hidden_program();
} else {
    normal_program();
}

That would work as far as basic runtime detection goes, but is not great:

  • the hidden program will still be present and observable in memory
  • worse, the binary file can be analyzed and disassembled, and the “hidden” program exposed
  • even worse, is_running_on_target_host exposes who we are targeting

What if we want to improve this? Here the fundamental issue is that the binary exposes everything we want to hide. So let’s hide that data and encrypt the target program and even the host data we are probing, and that should solve it, right? Of course this is not that simple, as that encrypted data would need to be decrypted at runtime, so the key would need to be embedded in the binary alongside the encrypted data, only adding a layer of obfuscation over our previous solution.

However what if we build upon the encryption idea, but instead don’t directly store the key alongside the encrypted program, but derive it from the unique host data of the machine we are targeting?

The steps on program startup would be:

  1. Extract data from the host, that uniquely identify the target (more on this later)
  2. Derive a key embedded in the binary with the previous host data using HKDF, producing a new key
  3. Decrypt the “hidden” encrypted embedded binary data, from the derived key
  4. If decryption succeeds, run the decrypted “hidden” program, else run the “normal” program
high level flow
High level flow

 

Now, this is starting to be interesting. Such binary would be by construction unable to decrypt the “hidden” program if not running on the target host, because the extracted host data would be different, which would lead to an invalid decryption key.

For this we will choose a symmetric block based encryption algorithm that also provides authentication, so that we detect the invalid key if not running on the target, instead of running a garbage program. AES-GCM is a common possible algorithm choice for this.

Choosing derivation info

The data used to identify the target host, and derive the key as previously described needs to be chosen carefully.

It needs to be:

  • Sufficiently unique, otherwise our “hidden” program may run on the wrong target
  • Stable over time, otherwise our “hidden” program may never run, even on the right target
  • Hard to guess from someone not having access to the target machine, so that extracting the “hidden” program is not possible from third-party not knowing the target system

Note that “hard to guess” here is different from a classic secret such as a password. The serial number of your motherboard for example would be difficult for me to guess, but is not really a secret as it can be read easily from /sys/class/dmi/id/, or maybe on its packaging.

Some candidates are:

  • user UID: not unique enough, most workstation users have a value of 1000, also severely lacking entropy
  • WAN interface IPv6: may not be stable, may be guessed from other channels
  • hardware serial numbers from /sys/class/dmi/id/: requires root privileges to read, may not be present on all devices, not much entropy
  • CPU model as shown by grep ^model /proc/cpuinfo: may not be unique enough for example in virtual machines, company laptop fleets…
  • disk partition UUIDs as shown by ls /dev/disk/by-uuid: actually random values generated when creating partitions, so good entropy and unicity, this one matches all our needs!

Build-time code

To make it easy to use for developers, we will integrate all this logic into a single twoface Rust crate. Luckily for us, Rust has great support for build-time code, in addition to being a modern system level language. Our library will have two main parts enabled with feature flags, a build-time part that controls the encryption of the "hidden" binary, and generates data to embed for the second, runtime part, that will do the decryption processing and dispatch execution either to the "normal" or "hidden" binary. 

Packing our two "normal" and "hidden" binaries into a new “Two-Face” one, including all crypto and embedding operations can be done from a build.rs file, the final binary code simply needs:

build.rs:

use std::io;

fn main() -> io::Result<()> {
    twoface::build::build::<twoface::host::HostPartitionUuids>()
}

Here HostPartitionUuids is a generic type used to customize how to extract host data, that implements the HostData trait.

/// System partition UUIDs, as shown in `ls /dev/disk/by-uuid | LANG=C sort`
#[derive(serde::Serialize, serde::Deserialize)]
pub struct HostPartitionUuids {
    part_uuids: Vec<String>,
}

impl HostData for HostPartitionUuids {
    fn from_host() -> io::Result<Self> {
        let mut part_uuids: Vec<_> = fs::read_dir("/dev/disk/by-uuid")?
            .filter_map(Result::ok)
            .filter_map(|e| e.file_name().into_string().ok())
            .collect();
        part_uuids.sort_unstable();
        Ok(Self { part_uuids })
    }
}

Its code is very short, and it would be easy to customize it or implement another source of data.

Then we can write a JSON file that contains the data we expect to match on our target host, for example:

{
    "part_uuids": [
        "02e989c5-32dc-45ad-98f8-f284e9ac23c0",
        "0e2fcda2-5ca1-4e38-841d-68e5d3a46f93",
        "f99b45d8-d76d-48a3-94a2-3b0c6316d899"
    ]
}

The final code also needs a few environment variables to build, to pass both binaries and the previous JSON paths:

export TWOFACE_HOST_INFO="/path/to/host_partition_uuids.json"
export TWOFACE_NORMAL_EXE="/path/to/normal_exe"
export TWOFACE_HIDDEN_EXE="/path/to/hidden_exe"
cargo build

During build-time, this will:

  1. load “normal” executable, and generate a const array from it to be used from the runtime code
  2. load “hidden” executable, and compress it
  3. load host data from the file passed with TWOFACE_HOST_INFO
  4. generate a random key, and generate a const array from it to be used from the runtime code
  5. derive the key with the host data from step 3
  6. encrypt the “hidden” executable compressed data with the derived key, and generate a const array to be used from the runtime code

Then in the main.rs (runtime code), we simply need to include the .rs file generated at build-time, and pass the generated const arrays to the run function which will run either the “normal” or “hidden” binary :

use std::io;

include!(concat!(env!("OUT_DIR"), "/target_exe.rs"));

fn main() -> io::Result<!> {
    twoface::run::run::<twoface::host::HostPartitionUuids>(
        NORMAL_EXE,
        HIDDEN_EXE_BLACK,
        HIDDEN_EXE_KEY,
        &HIDDEN_EXE_DERIVATION_SALT,
    )
}

Running from memory

Attentive readers may have noticed that we take binary ELF files as input at build-time, and launch them as-is at run-time, which can be tricky to do from an already executing ELF. A possible way to do this would be to write the program to execute on the filesystem, then run exec syscall on it. However for the “hidden” program that would require writing the decrypted binary in a form that can be easily isolated/observed, which is something we want to avoid. Other possible approaches would be to create a file with O_TMPFILE flag (file invisible from other processes), or to map all target ELF pages in memory (tedious, and would require mapping executable pages, which could trigger runtime detection or hardening issues).

Instead we opt for the memfd_create syscall which basically creates a file descriptor, not backed up by a file. Once the target binary has been written to it, the fexecve syscall will replace the current process image by the new one and our job is done.

Adding another layer of fun

Now we have a nice solution to pack two binaries into one at build-time, extract host data to identify our target at run-time, and run our “normal” or “hidden” binary from memory depending on the result.

At this point the decrypted “hidden” binary is never present as a whole in the process memory, because when we decrypt the AES blocks, we can write them on the fly to the file descriptor we will then execute. This is a nice property, however the writing operation is trivially observable, even for a non privileged user.

If we take for example a one line Python program that creates a memfd and writes to it, we can see the written data easily with strace:

$ strace -e write python3 -c 'import os; fd = os.memfd_create(""); f = open(fd, "wb"); f.write(b"secret data")'
write(3, "secret data", 11)             = 11
+++ exited with 0 +++

Each decrypted AES block could be observed the same way, and our complete “hidden” binary reconstructed. Of course this would require running the analysis on the target system, yet it would be great if we could avoid this.

To improve this we will use different ways of writing the decrypted “hidden” program ELF data, to the target file descriptor, each having pros and cons:

  • with io_uring: no write syscall is issued, so for example strace won’t see any written data, however it may not be supported or disabled on the system
  • by mmap’ing memory segments: no write to trace either, but requires many syscalls to map/unmap each chunk (performance impact), so that the whole decrypted file is not visible in memory at a given point
  • fallback on classic write: the complete decrypted file data still won’t be in process memory, but write calls can easily be traced

Note that in any case, this would not resist some more advanced runtime analysis from a privileged user. While the in-memory file descriptor data is not mapped in user space memory, it can be accessed and extracted from the kernel.

The result

The whole code can be seen at https://github.com/synacktiv/twoface, and contains a sample "harmless"/"normal" binary, another "hidden"/"evil" one, the twoface library, and an example to test it all together:

test-example
harmless_binary
├── Cargo.toml
└── src
    └── main.rs
evil_binary
├── Cargo.toml
└── src
    └── main.rs
example
├── build.rs
├── Cargo.toml
├── host.json
└── src
    └── main.rs
twoface
├── Cargo.toml
└── src
    ├── build.rs
    ├── crypto
    │   ├── dec.rs
    │   ├── enc.rs
    │   └── mod.rs
    ├── exe_writer
    │   ├── io_uring.rs
    │   ├── mmap.rs
    │   └── mod.rs
    ├── host.rs
    ├── lib.rs
    └── run.rs

Running test-example will:

  • build the “harmless” binary
  • build the “evil” binary
  • load partitions UUIDs from example/host.json
  • build an example binary that packs both the “harmless” and “evil” (encrypted) ELF
  • run it so you can see which one actually runs

Conclusion

This proof of concept shows how we can leverage the Rust build-time code facilities to create advanced yet developer friendly mechanisms, and implement our "Two-Face" binary.

This is only a glimpse of what could be done though, to push things further, we could:

  • add build-time obfuscation, for example to hide that we read partitions UUIDs from /dev/disk/by-uuids
  • add runtime anti-debugging techniques
  • use already in-memory host specific data to derive key, for example by hashing shared library pages
  • chain several levels of loaders, each using different source of derivation data
  • dynamically decrypt ELF memory pages on the fly using for example userfaultfd

...this may be the subject of another article.