Recovering Rust stripped symbols on MinGW targets

Jun 6, 2024

This article is the continuation of a previous blog post about rust symbol recovery. If you are unfamiliar with symbol recovery, I recommand you go read it before reading this entry.

In my previous blog post, I talked about how one can recover symbols from a stripped rust executable, and difficulties you can encounter when dealing with certain compilation options.

Some actors sometimes cross compile their code from Linux to Windows, which requires a specific linker. In its plaform support documentation, rust defines two targets that are Tier 1 targets (officialy supported by rust) that allow easy cross compilation between Linux and Windows: x86_64-pc-windows-gnu and i686-pc-windows-gnu. Both use MinGW as a linker.

As a reminder, a simplified schema of the compilation pipeline of a rust program should look something like:

rust source code (.rs) -> rustc -> llvm -> object files (.o) -> link

In the case of x86_64-pc-windows-gnu and i686-pc-windows-gnu targets, the linkage step is done by MinGW.

Installing such targets is pretty straighforward on Ubuntu:

apt-get install mingw-w64
rustup target add x86_64-pc-windows-gnu
rustup install stable-x86_64-pc-windows-gnu

You can easily check that everything works with:

cargo new hello_world && cd hello_world
cargo +stable build --target x86_64-pc-windows-gnu

Why does it matter for symbol recovery ?

As a reminder, there are two things we want to sign:

  • any dependencies used and identified in the target we want to apply our signature to ;
  • the standard library and its runtime.

To sign dependencies, we will be needing to compile them. rustc is the important part of the compilation pipeline, since it is the component that understands rust code. This is the key component that will generate the code, thus knowing which version to use is important while doing a signature.

Another important part of the compilation toolchain is the linker used. It will have an impact on generated code. The impact can be rather small, but using two different linkers will result in different code. Regarding MinGW, the version of MinGW/GCC should not matter for the linkage part, any MinGW version should do the trick.

However, generating signatures for the standard library and the runtime is a bit trickier. Since those are not generated by rustc (in the case of MinGW), every runtime of every MinGW version will be slighly different.

For example, here is the function pei386_runtime_relocator which comes from MinGW runtime, compiled with MinGW-8/GCC-10.3.0:

push    rbp
push    r15
push    r14
push    r13
push    r12
push    rdi
push    rsi
push    rbx
sub     rsp, 38h
lea     rbp, [rsp+80h]
mov     esi, cs:was_init_94382
test    esi, esi
jz      short loc_1400973A8
lea     rsp, [rbp-48h]
pop     rbx
pop     rsi
pop     rdi
pop     r12
pop     r13
pop     r14
pop     r15
pop     rbp
retn

And here is the code of the same function but from MinGW-10/GCC-12.2.0:

push    rbp
push    r15
push    r14
push    r13
push    r12
push    rdi
push    rsi
push    rbx
sub     rsp, 48h            ; different
lea     rbp, [rsp+40h]      ; different
mov     r12d, cs:was_init_0 ; different
test    r12d, r12d          ; different
jz      short loc_1400BC900
lea     rsp, [rbp+8]        ; different
pop     rbx
pop     rsi
pop     rdi
pop     r12
pop     r13
pop     r14
pop     r15
pop     rbp
retn

Making a signature for each MinGW runtime is a tedius and boring work to do, so I did it for you. You can find those signatures on this repo. Almost every version referenced in mingw official download page should be signed.

MinGW version identification

Before applying a runtime signature to a target, we must first identify the MinGW version our target uses. To do so, I created YARA rules that help identify the MinGW version of x86_64 linux rust executable binaries.

The process to create those rules was:

  • install the specific version of MinGW to sign ;
  • build a hello-world with it ;
  • compare the result of runtime functions between all hello-world programs from other MinGW version.

Those rules are now implemented in rustbininfo to help identify MinGW versions.

Test case

A good test case was the program archiver, from 2024’s FCSC CTF. It can be downloaded here. While this challenge can be solved with a blackbox approach, I wanted to try and recover function names.

Here is the result of rustbininfo on the target:

TargetRustInfo(
    rustc_version='1.79.0',
    rustc_commit_hash='3c85e56249b0b1942339a6a989a971bf6f1c9e0f',
    dependencies=[
        Crate(name='aead', version='0.5.2', features=[], repository=None),
        Crate(name='aes', version='0.8.4', features=[], repository=None),
        Crate(name='aes-gcm', version='0.10.3', features=[], repository=None),
        Crate(name='anstream', version='0.6.13', features=[], repository=None),
        Crate(name='anstyle', version='1.0.6', features=[], repository=None),
        Crate(name='anstyle-parse', version='0.2.3', features=[], repository=None),
        Crate(name='anstyle-wincon', version='3.0.2', features=[], repository=None),
        Crate(name='bincode', version='1.3.3', features=[], repository=None),
        Crate(name='block-buffer', version='0.10.4', features=[], repository=None),
        Crate(name='cipher', version='0.4.4', features=[], repository=None),
        Crate(name='clap_builder', version='4.5.2', features=[], repository=None),
        Crate(name='clap_lex', version='0.7.0', features=[], repository=None),
        Crate(name='ctr', version='0.9.2', features=[], repository=None),
        Crate(name='digest', version='0.10.7', features=[], repository=None),
        Crate(name='generic-array', version='0.14.7', features=[], repository=None),
        Crate(name='hmac', version='0.12.1', features=[], repository=None),
        Crate(name='polyval', version='0.6.2', features=[], repository=None),
        Crate(name='rand_core', version='0.6.4', features=[], repository=None),
        Crate(name='serde', version='1.0.197', features=[], repository=None),
        Crate(name='sha2', version='0.10.8', features=[], repository=None),
        Crate(name='strsim', version='0.11.0', features=[], repository=None),
        Crate(name='universal-hash', version='0.5.1', features=[], repository=None)
    ],
    rust_dependencies_imphash='b5386521b71aa121e153e8b45f9986e1',
    guessed_toolchain='Mingw-w64 (Mingw8-GCC_10.3.0)'
)

Then I simply used rustbinsign to generate a signature for this target.

The command I used was the following:

$ rbs -l DEBUG sign_target -f -t 1.79.0-x86_64-pc-windows-gnu --template ./profiles/archiver.json  --provider IDA --target ./archiver.exe --no-std --signature_name archiver_signature

With archiver.json being:

{
    "profile": {
        "release": {
            "debug": 1,
            "strip": "none",
            "opt-level": "z",
            "panic": "abort"
    }
}

According to the logs, the program ran for about 30mn, and gave me a 16MB signature as an output.

Results

I applied both this signature and the 1.79.0-x86_64-pc-windows-gnu runtime signature in IDA. Here are the results.

Before applying the signatures:

without symbols

After:

with symbols

IDA states that 15 503 functions got recognized.

Issues / Edge cases

I wanted to test this method against BlackCat’s well known rust ransomware, but their ransomware was compiled to 32 bits, which defeats my MinGW version recognition. I didn’t want to do all the YARA rule MinGW version stuff all over again for 32 bits, so I decided that I would give it a shot another time.