Will Rust greedily eval a Lazy if it produces a constant value?

Viewed 89

I am playing with attribute macros by writing one that will obfuscate strings in the source code, and it works great in debug mode, but in release mode, it doesn't seem to have any effect.

My guess is that the compiler sees that the SyncLazy will always produce the same value, and during optimizations, it goes ahead and evaluates at compile time, although I don't want this behavior.

I tried wrapping the evaluation in a black_box but the compiler didn't seem to take the hint. I also tried to use the optimize_attribute feature, though it looks like there isn't an optimize(none) available yet.

Example based on the results of cargo expand:

#![feature(once_cell)]
#![feature(bench_black_box)]

use std::hint;
use std::lazy::SyncLazy;

// everything in the lazy was generated by the macro to get the original string back at run-time
static VAL: SyncLazy<String> = SyncLazy::new(|| {
    hint::black_box(String::from_utf8(
        [
            140, 155, 158, 142, 158, 142, 166, 156, 150, 130, 148, 171, 128, 134, 132, 150, 137,
        ]
        .iter()
        .enumerate()
        .map(|(i, e)| i as u8 ^ !e as u8)
        .collect::<Vec<u8>>(),
    ))
    .unwrap()
});

fn main() {
    println!("{}", *VAL);
}
# expected result:
$ rustc -C opt-level=1 main.rs && if grep 'secret' main; then echo '"secret" is still readable'; else echo "obfuscation worked"; fi
obfuscation worked

# does not hide data:
$ rustc -C opt-level=2 main.rs && if grep 'secret' main; then echo '"secret" is still readable'; else echo "obfuscation worked"; fi
grep: main: binary file matches
"secret" is still readable

My workaround is to set a lower release optimization level in my Cargo.toml, but I'm still curious about what's happening, or if there is a good way to postpone evaluations to runtime. Greedily evaluating a lazy type seems to defeat the purpose of it, so if there's a better explaination of what actually happened, that might help lead to a better solution, too.

1 Answers

This behavior is not specific to SyncLazy. The compiler can see that iteration over the array and subsequent computation is static, and will "pre-compute" the result as far as possible at compile time as part of its constant folding pass. The exact behavior is not specified, though, as the program can't observe if the compiler did this optimization or not. In general, if the computation is too large or too complex, the compiler will just give up and defer to runtime. Again, as this is not part of the observable program behavior, you can't predict if the compiler does this not at all, in part, or completely. As a rule of thumb, you should expect the compiler to constant-fold if you rely on it not to, and expect it to not constant-fold if you hope it will...

For example, if you compile the following with optimizations enabled...

pub fn foo() -> Vec<u8> {
    let cypher = [47u8; 15];  // The `encrypted` bytes
    cypher
        .iter()
        .enumerate()
        .map(|(idx, p)| 1 + *p ^ (idx as u8))  // The `decryption`
        .collect()  // The `decrypted` Vec
}

... you will see in the generated assembly that the compiler has completely removed the computation and replaced the function with a single allocation, and moved the "decrypted" result directly into the newly allocated Vec by means of registers:

playground::foo:
    pushq   %rbx
    movq    %rdi, %rbx
    movl    $15, %edi
    movl    $1, %esi
    callq   *__rust_alloc@GOTPCREL(%rip)
    testq   %rax, %rax
    je  .LBB0_1
    movq    %rax, (%rbx)
    movabsq $3978425819141910832, %rcx // This is the `decrypted` result
    movq    %rcx, (%rax)               // as part of the instruction-stream
    movl    $993671480, 8(%rax)  
    movw    $15676, 12(%rax)
    movb    $62, 14(%rax)
    movaps  .LCPI0_0(%rip), %xmm0
    movups  %xmm0, 8(%rbx)
    movq    %rbx, %rax
    popq    %rbx
    retq // No computation in this function at all

If we change the length of the array from 15 to 16, the compiler can't use registers but bakes the "decrypted" bytes into the executable; the result is moved into the newly allocated Vec in one fell swoop:

playground::foo:
    pushq   %rbx
    movq    %rdi, %rbx
    movl    $16, %edi
    movl    $1, %esi
    callq   *__rust_alloc@GOTPCREL(%rip)
    testq   %rax, %rax
    je  .LBB0_1
    movq    %rax, (%rbx)
    movaps  .LCPI0_0(%rip), %xmm0 
    movups  %xmm0, (%rax)
    movaps  .LCPI0_1(%rip), %xmm0
    movups  %xmm0, 8(%rbx)
    movq    %rbx, %rax
    popq    %rbx
    retq  // Again, no computation

If we make the array large enough, the compiler just gives up: It allocates a piece of memory, memset it to the value we set in the code (47 in the example above), and does the whole computation; this is done every single time the function foo is called. The point at which the compiler gives up seems to be exactly 609 bytes, which should be considered completely arbitrary and version-specific.

Due to size, I'll not paste the code here...

As it was pointed out in the comment, the obfstr implements the behavior you aim for. It has to go through great pains in order to convince LLVM to not move the de-obfuscation to compile-time.

As a more general answer to your question: You should never hand secrets to the compiler, and if you do, expect the compiler to leak those secrets to the user. In your case, the obfuscation algorithm is the secret, and the compiler happily leaked it by putting the de-obfuscated bytes into the executable. If you have to do this, you'll need to spend the extra work (as the obfstr crate does) to "trick" the compiler, and heavily, heavily monitor the result.

Related