String Obfuscation

Hiding strings in a binary


This is a technique used by programs that don’t want their secrets found out by trivial means. Normally if we have some code like such:

fn main() {
	println!("Hello, world!");
}

We can then compile it with cargo build then, uppon running the code we get

$ ./target/release/program
Hello, world!

If you’ve done any programming this should be trivial.

Now let’s suppose that instead of printing to the terminal with println!() the code is making calls to our web API. Perhaps this is an internal API that we don’t want people making their own clients for and abusing. If we have calls in our code like:

let result = http_get("https://oliveratkinson.net/api/do_something")

We may not want people to know that /api/do_something is API endpoint.

But if we are distributing our compiled binary, the end user won’t be able to see that string because it’s only in the source code… right?

Wrong! :)

If we take our binary called program, and we run the strings command against it, we will get lots of output. Most of it is random garbage:

[A\A]A^A_]
t%H9]
t$H9
/t$L9
4.debH
ib/debugK
/usr/libK
j+^H
AWAVAUATSH

But, if we know we are looking for urls, we can narrow the output down with grep:

$ strings program | grep "https://"
https://oliveratkinson.net/api/do_something

There it is! Our url is just sitting there in the binary! Free for anybody to find!

So how can we make it harder for people to find this?

Well, the command strings is (by default) looking for a sequence of four or more printable characters. We’ve seen this just above. “Printable” in this context means that the numeric value of the character is between 32-126. This is because the characters are stored as ascii in the binary. You can look here if you are currious on what all the values are.

So, 32-126… how much space does this give us to work with? Well, the storage size for each character is 1 byte, 8 bits: 0b1111_1111, aka 0-255.

Adding

To understand what we are going to do, you should be familiar with binary:

DecimalBinary
10b0000_0001
20b0000_0010
30b0000_0011
1260b0111_1110
1280b1000_0000
2550b1111_1111

I put 126 in that table so you can see the max pritable value, as well as 255 which is the actual max value. So you can see (just from knowing that 255 > 126) there is a lot of room to play with.

One option for making each character “unprintable” would be to would be to just set the “128” bit (the first “1”) of each character. For changing the first letter in word that would equate to:

// change by "or"
for letter in word {
    letter = letter as number | 128 // 128 is the same as 0b1000_0000 or 1<<7
}

Now, we could just build a program where we input a string and it outputs the byte array corresponding to our character values + 128. However, just setting the 128 bit is quite predictable, we can do better.

XOR

XOR means “not OR”. An “OR” operation produces “true” if either input is “true”. A “NOT” operation produces the opposite of whatever you put in. Therefor, a “NOT OR” gate, aka “XOR”, has the folowing truth table:

In AIn BOut
TrueTrueFalse
FalseTrueTrue
TrueFalseTrue
FalseFalseFalse

In code, we can do logical operations (such as XOR) on each bit of a number. The the XOR operator is ^. By following the binary table and the XOR truth tables above, see if you can make sense of the following:

2 ^ 1 = 3;
1 ^ 1 = 0;

3 ^ 1 = 2;

As you can see if we XOR by the same value twice, we end up with the value we started with. This means that instead of just adding and subtracing 128 from each char/number, can can XOR in any value 0-255 we want! Now we can do something like to decode and encode our string.

for letter in word {
	letter = letter ^ 0b1100_1100;
}

We still benefit from keeping the high bit (ie: 0b1000_0000) to make sure that our numbers land above 126 (keeping them unprintable). But we could use anything.

So that’s cool and all, but are we going to have to open a separate program (like a calculator) to encode all our values into the source code? Absolutely not! Programmers are much to lazy for that…

Meta-Programming

Languages like C and Rust (and probably more, idk a lot of compiled langauges) have something called “macros”. Macros are a way to do meta-programming, or in plain english: You can write code that writes more code. I could have my source code look like this:

let url = [
'e8', // h
'f4', // t
'f4', // t
'f0', // p
'f3', // s
'ba', // :
'af', // /
'af', // /
'ef', // o
'ec', // l
'e9', // i
'f6', // v
'e5', // e 
'f2', // r
// ... and so on
]

But that looks cumbersome and like a lot of work, so instead, let’s write code that does that for us!

In rust, macros look like functions, but the name ends with ”!”. These macros then run/expand during compile time (when the code is being converted from human-redable to computer-readable). Then these expanded macros get compiled. This will make more sense if we take a look:

// This is our sorce code where we get to put nice looking strings that make development easy.
let endpoint: String = obfuscate!("https://oliveratkinson.net/api/do_something");

Our macro definition looks like this:

#[proc_macro]
pub fn obfuscate(input: TokenStream) -> TokenStream {
    // This part of the code runs during compile time
    let input = parse_macro_input!(input as LitStr);
    let input = input.value();

    let mask = 0b1000_1000;

    let obscured: Vec<u8> = input
        .as_bytes()
        .iter()
        .map(|byte| *byte ^ mask)
        .collect();

    // Everything inside the curly brackets {} get's inserted into the macro's call site
    quote! {{
        const ENCODED: &[u8] = &[#(#obscured),*];
        ENCODED
            .iter()
            .map(|byte| byte ^ #mask)
            .map(char::from)
            .collect::<String>()
    }}
    .into()
}

If you don’t know code don’t worry too much about exactually what this does, but you can probably get the jist based on the names and how it’s written.

So now this code:

let endpoint: String = obfuscate!("https://oliveratkinson.net/api/do_something");

Gets expanded into this:

let endpoint = {
    const ENCODED: &[u8] = &[
        224u8, 252u8, 252u8, 248u8, 251u8, 178u8, 167u8, 167u8, 231u8, 228u8, 225u8,
        254u8, 237u8, 250u8, 233u8, 252u8, 227u8, 225u8, 230u8, 251u8, 231u8, 230u8,
        166u8, 230u8, 237u8, 252u8, 167u8, 233u8, 248u8, 225u8, 167u8, 236u8, 231u8,
        215u8, 251u8, 231u8, 229u8, 237u8, 252u8, 224u8, 225u8, 230u8, 239u8,
    ];
    ENCODED
        .iter()
        .map(|b| b ^ 136u8)
        .map(char::from)
        .collect::<String>()
};

Our API endpoint string gets translated into an array of non-printable characters which no longer show up when running strings on the binary. This is because the array is what ends up in the compiled binary, then at runtime it gets decoded into a real usable string again.


Tags:

BlogLinux