From de7b13125bb2ed0d47666cef9ef182998a365823 Mon Sep 17 00:00:00 2001 From: mahesh bhatiya Date: Fri, 11 Jul 2025 00:25:59 +0530 Subject: [PATCH] feat(ssh-fail-monitor): add kprobe-based sshd detection with uid+pid ringbuf eventing Implements a new SSH fail monitor using a kprobe on all processes. - Hooks every `kprobe` entry point and filters based on `comm == "sshd"` - Emits timestamped events via RingBuf including PID, UID, comm, and time - Tracks per-UID failure count in a HashMap (UID -> attempt count) - Uses `bpf_get_current_comm`, `bpf_get_current_pid_tgid`, `bpf_get_current_uid_gid`, and `bpf_ktime_get_ns` - Adds bpf_printk debug output for each triggered event - Includes panic handler and `GPL` license section for kernel validation Note: This method is generic and does not attach directly to `pam_authenticate`. Future enhancement: Combine with uretprobe on PAM to capture return code and failure reason. --- .gitignore | 1 + bin/ssh_monitor.o | Bin 1880 -> 1880 bytes cmd/ssh_fail.go | 21 +++ core/ssh_fail_monitor.go | 155 ++++++++++++++++++ ebpf-programs/ssh_fail_monitor/Cargo.toml | 18 ++ .../ssh_fail_monitor/rust-toolchain.toml | 4 + ebpf-programs/ssh_fail_monitor/src/lib.rs | 96 +++++++++++ ebpf-programs/ssh_monitor/Cargo.toml | 4 +- ebpf-programs/ssh_monitor/src/lib.rs | 80 ++++----- scripts/test_ssh_fail.sh | 48 ++++++ 10 files changed, 379 insertions(+), 48 deletions(-) create mode 100644 cmd/ssh_fail.go create mode 100644 core/ssh_fail_monitor.go create mode 100644 ebpf-programs/ssh_fail_monitor/Cargo.toml create mode 100644 ebpf-programs/ssh_fail_monitor/rust-toolchain.toml create mode 100644 ebpf-programs/ssh_fail_monitor/src/lib.rs create mode 100755 scripts/test_ssh_fail.sh diff --git a/.gitignore b/.gitignore index eacd482..4163a1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Binaries and object files +bin/ *.o *.elf diff --git a/bin/ssh_monitor.o b/bin/ssh_monitor.o index 8361a8c973fe9b281ce0a11dd5954edd81fb5bdb..b2dc1d61a57a37688172cfaf2a0a551ce11d7c65 100644 GIT binary patch delta 39 tcmcb?cY|-kFBY*R3rn+Pb0g!_S&-Fzb2uv_695av3&;Qf delta 39 ucmcb?cY|-kFBUO#!_>sY)HDkN^R!em6XP`9 count +}{ + data: make(map[uint32]int), +} + +func RunSSHFailMonitor() { + spec, err := ebpf.LoadCollectionSpec("bin/ssh_fail_monitor.o") + if err != nil { + log.Fatalf("Failed to load eBPF spec: %v", err) + } + + objs := struct { + SshFailMonitor *ebpf.Program `ebpf:"ssh_fail_monitor"` + SshFailRingbuf *ebpf.Map `ebpf:"SSH_FAIL_RINGBUF"` + }{} + + if err := spec.LoadAndAssign(&objs, nil); err != nil { + log.Fatalf("Failed to load eBPF objects: %v", err) + } + defer func() { + _ = objs.SshFailMonitor.Close() + _ = objs.SshFailRingbuf.Close() + }() + + libpamPath := findLibPam() + if libpamPath == "" { + log.Fatal("libpam.so not found") + } + log.Printf("Using libpam at: %s", libpamPath) + + up, err := link.OpenExecutable(libpamPath) + if err != nil { + log.Fatalf("OpenExecutable failed: %v", err) + } + + attached, err := up.Uretprobe("pam_authenticate", objs.SshFailMonitor, nil) + if err != nil { + log.Fatalf("Failed to attach uretprobe: %v", err) + } + defer attached.Close() + + log.Println("Successfully attached uretprobe to pam_authenticate") + + rd, err := ringbuf.NewReader(objs.SshFailRingbuf) + if err != nil { + log.Fatalf("Failed to open ringbuf reader: %v", err) + } + defer rd.Close() + + log.Println("SSH fail monitor started. Press Ctrl+C to stop.") + sig := make(chan os.Signal, 1) + done := make(chan struct{}) + signal.Notify(sig, os.Interrupt) + + go func() { + for { + select { + case <-done: + return + default: + record, err := rd.Read() + if err != nil { + if !strings.Contains(err.Error(), "closed") { + log.Printf("ringbuf read failed: %v", err) + } + continue + } + + var event sshFailEvent + if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil { + log.Printf("binary read failed: %v", err) + continue + } + + username := lookupUsername(event.Uid) + processName := strings.TrimRight(string(event.Comm[:]), "\x00") + reason := "unknown" + switch event.FailReason { + case 1: + reason = "invalid password" + case 2: + reason = "invalid user" + } + + attemptCount.Lock() + attemptCount.data[event.Pid]++ + count := attemptCount.data[event.Pid] + attemptCount.Unlock() + + fmt.Printf( + "%s (PID %d, UID %d, user: %s) → Failed SSH login: %s [Attempt #%d]\n", + processName, event.Pid, event.Uid, username, reason, count, + ) + } + } + }() + + <-sig + fmt.Println("\nExiting SSH monitor.") + close(done) +} + +func lookupUsername(uid uint32) string { + userObj, err := user.LookupId(fmt.Sprintf("%d", uid)) + if err != nil { + return "unknown" + } + return userObj.Username +} + +func findLibPam() string { + possiblePaths := []string{ + "/lib/x86_64-linux-gnu/libpam.so.0", + "/lib64/libpam.so.0", + "/usr/lib/libpam.so.0", + "/usr/lib64/libpam.so.0", + "/lib/libpam.so.0", + } + + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} diff --git a/ebpf-programs/ssh_fail_monitor/Cargo.toml b/ebpf-programs/ssh_fail_monitor/Cargo.toml new file mode 100644 index 0000000..38e4926 --- /dev/null +++ b/ebpf-programs/ssh_fail_monitor/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ssh_fail_monitor" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib"] +name = "ssh_fail_monitor" + +[dependencies] +aya-ebpf = { version = "0.1.1", default-features = false } + +[profile.release] +opt-level = "z" +lto = true +panic = "abort" +codegen-units = 1 +strip = "debuginfo" \ No newline at end of file diff --git a/ebpf-programs/ssh_fail_monitor/rust-toolchain.toml b/ebpf-programs/ssh_fail_monitor/rust-toolchain.toml new file mode 100644 index 0000000..78474db --- /dev/null +++ b/ebpf-programs/ssh_fail_monitor/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly" +components = ["rust-src"] +targets = ["bpfel-unknown-none"] diff --git a/ebpf-programs/ssh_fail_monitor/src/lib.rs b/ebpf-programs/ssh_fail_monitor/src/lib.rs new file mode 100644 index 0000000..7887fbc --- /dev/null +++ b/ebpf-programs/ssh_fail_monitor/src/lib.rs @@ -0,0 +1,96 @@ +#![no_std] +#![no_main] + +use aya_ebpf::{ + macros::map, + maps::{HashMap, RingBuf}, + programs::ProbeContext, + helpers::{ + bpf_get_current_pid_tgid, + bpf_get_current_uid_gid, + bpf_get_current_comm, + bpf_ktime_get_ns, + }, + EbpfContext, + cty::c_int, +}; +use aya_ebpf::bpf_printk; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct SshFailEvent { + pub pid: u32, + pub uid: u32, + pub timestamp_ns: u64, + pub comm: [u8; 16], + pub ret_code: i32, + _padding: [u8; 3], +} + +#[map(name = "SSH_FAIL_RINGBUF")] +static mut SSH_FAIL_RINGBUF: RingBuf = RingBuf::with_byte_size(4096, 0); + +#[map(name = "SSH_FAIL_COUNTS")] +static mut SSH_FAIL_COUNTS: HashMap = + HashMap::::with_max_entries(1024, 0); + +#[no_mangle] +#[link_section = "uretprobe/pam_authenticate"] +pub fn ssh_fail_monitor(ctx: ProbeContext) -> u32 { + let _ = try_monitor(ctx); + 0 +} + +fn try_monitor(ctx: ProbeContext) -> Result<(), ()> { + // SAFELY extract return value from `ctx` + + let ret_val = unsafe { *(ctx.as_ptr() as *const c_int) }; + unsafe { + bpf_printk!(b"ssh_fail_monitor: ret_val seen\n"); + } + if ret_val == 0 { + return Ok(()); + } + + let pid = (bpf_get_current_pid_tgid() >> 32) as u32; + let uid = (bpf_get_current_uid_gid() >> 32) as u32; + + + unsafe { + bpf_printk!(b"[ssh_fail] processing event\n"); + } + + let mut comm = [0u8; 16]; + if let Ok(val) = bpf_get_current_comm() { + comm.copy_from_slice(&val); + } + + let ts = unsafe { bpf_ktime_get_ns() }; + + let event = SshFailEvent { + pid, + uid, + timestamp_ns: ts, + comm, + ret_code: ret_val, + _padding: [0; 3], + }; + + unsafe { + let count = SSH_FAIL_COUNTS.get(&pid).copied().unwrap_or(0); + SSH_FAIL_COUNTS.insert(&pid, &(count + 1), 0).ok(); + SSH_FAIL_RINGBUF.output(&event, 0); + bpf_printk!(b"[ssh_fail] event emitted\n"); + } + + Ok(()) +} + +#[panic_handler] +fn panic(_: &core::panic::PanicInfo) -> ! { + loop {} +} + +#[no_mangle] +#[link_section = "license"] +pub static LICENSE: [u8; 4] = *b"GPL\0"; diff --git a/ebpf-programs/ssh_monitor/Cargo.toml b/ebpf-programs/ssh_monitor/Cargo.toml index 17e8a94..ad43fd0 100644 --- a/ebpf-programs/ssh_monitor/Cargo.toml +++ b/ebpf-programs/ssh_monitor/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "ssh_monitor_ebpf" +name = "ssh_monitor" version = "0.1.0" edition = "2021" [lib] crate-type = ["staticlib"] -name = "ssh_monitor_ebpf" +name = "ssh_monitor" [dependencies] aya-ebpf = { version = "0.1.1", default-features = false } diff --git a/ebpf-programs/ssh_monitor/src/lib.rs b/ebpf-programs/ssh_monitor/src/lib.rs index f327a15..686b1ce 100644 --- a/ebpf-programs/ssh_monitor/src/lib.rs +++ b/ebpf-programs/ssh_monitor/src/lib.rs @@ -1,72 +1,60 @@ #![no_std] #![no_main] -#![allow(static_mut_refs)] -#![allow(unused_unsafe)] use aya_ebpf::{ - helpers::{bpf_get_current_pid_tgid, bpf_probe_read}, - macros::{kprobe, map}, - maps::HashMap, + macros::map, + maps::{HashMap, RingBuf}, programs::ProbeContext, + helpers::{bpf_get_current_comm, bpf_get_current_pid_tgid, bpf_get_current_uid_gid, bpf_ktime_get_ns}, + bpf_printk, }; #[repr(C)] -#[derive(Copy, Clone)] -pub struct SshKey { +#[derive(Clone, Copy)] +pub struct SshFailEvent { pub pid: u32, - pub ip: u32, + pub uid: u32, + pub ts: u64, + pub comm: [u8; 16], } -#[map(name = "ssh_attempts")] -static mut SSH_ATTEMPTS: HashMap = HashMap::::with_max_entries(1024, 0); +#[map(name = "SSH_FAIL_RINGBUF")] +static mut SSH_FAIL_RINGBUF: RingBuf = RingBuf::with_byte_size(4096, 0); -#[kprobe] -pub fn trace_ssh(ctx: ProbeContext) -> u32 { - match try_trace_ssh(ctx) { - Ok(_) => 0, - Err(_) => 1, - } -} +#[map(name = "SSH_FAIL_COUNTS")] +static mut SSH_FAIL_COUNTS: HashMap = HashMap::with_max_entries(1024, 0); -fn try_trace_ssh(ctx: ProbeContext) -> Result<(), ()> { +#[no_mangle] +#[link_section = "kprobe/tty_write"] +pub fn ssh_fail_monitor(ctx: ProbeContext) -> u32 { let pid = (bpf_get_current_pid_tgid() >> 32) as u32; - - let sockaddr_ptr: *const u8 = ctx.arg(1).ok_or(())?; - - let sa_family: u16 = unsafe { bpf_probe_read(sockaddr_ptr as *const u16) }.map_err(|_| ())?; - if sa_family != 2 { - return Ok(()); // Only AF_INET - } - - let port_be: u16 = unsafe { - bpf_probe_read(sockaddr_ptr.add(2) as *const u16) - }.map_err(|_| ())?; - let port = u16::from_be(port_be); - if port != 22 { - return Ok(()); // Only SSH + let uid = (bpf_get_current_uid_gid() >> 32) as u32; + let ts = unsafe { bpf_ktime_get_ns() }; + + let mut comm = [0u8; 16]; + if let Ok(c) = bpf_get_current_comm() { + comm.copy_from_slice(&c); + if !comm.starts_with(b"sshd") { + return 0; + } + } else { + return 0; } - let ip_bytes: [u8; 4] = unsafe { - bpf_probe_read(sockaddr_ptr.add(4) as *const [u8; 4]) - }.map_err(|_| ())?; - - let ip = u32::from_le_bytes(ip_bytes); - let key = SshKey { pid, ip }; + let event = SshFailEvent { pid, uid, ts, comm }; unsafe { - match SSH_ATTEMPTS.get_ptr_mut(&key) { - Some(val) => *val += 1, - None => { - let _ = SSH_ATTEMPTS.insert(&key, &1, 0); - } - } + let count = SSH_FAIL_COUNTS.get(&uid).copied().unwrap_or(0); + SSH_FAIL_COUNTS.insert(&uid, &(count + 1), 0).ok(); + let _ = SSH_FAIL_RINGBUF.output(&event, 0); + bpf_printk!(b"[ssh_fail] UID=%d PID=%d\n", uid, pid); } - Ok(()) + 0 } #[panic_handler] -fn panic(_info: &core::panic::PanicInfo) -> ! { +fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } diff --git a/scripts/test_ssh_fail.sh b/scripts/test_ssh_fail.sh new file mode 100755 index 0000000..a0c8852 --- /dev/null +++ b/scripts/test_ssh_fail.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# Description: Simulate failed SSH login attempts and verify preconditions. +# Author: Mahesh Testing Suite for NetBarrier + +TARGET_USER="fakeuser" +TARGET_HOST="localhost" +WRONG_PASS="wrongpassword" +ATTEMPTS=5 + +echo "[INFO] Checking sshd status..." +if ! pgrep -x sshd >/dev/null; then + echo "[ERROR] sshd is not running. Please start SSH server." + exit 1 +fi + +if ! getent passwd "$TARGET_USER" >/dev/null; then + echo "[INFO] User '$TARGET_USER' does not exist. That's expected for this test." +else + echo "[WARN] User '$TARGET_USER' exists. Delete it or use a different non-existent user." +fi + +echo "[INFO] Verifying sshd is listening on localhost (127.0.0.1:22)..." +if ! ss -tnlp | grep -q '127.0.0.1:22'; then + echo "[WARN] sshd is not explicitly listening on 127.0.0.1. Trying localhost anyway..." +fi + +echo "[INFO] Verifying PAM library path for uretprobe attachment..." +if [ ! -f /lib/x86_64-linux-gnu/libpam.so.0 ]; then + echo "[ERROR] libpam.so.0 not found in standard location. Update your probe loader." + exit 1 +fi + +echo "-------------------------------------------" +echo "[INFO] Simulating $ATTEMPTS failed SSH logins to $TARGET_USER@$TARGET_HOST" +echo "-------------------------------------------" + +for i in $(seq 1 $ATTEMPTS); do + echo "[Attempt $i] at $(date +%T)" + logger "[SSH-TEST] Attempt $i to $TARGET_USER@$TARGET_HOST (should fail)" + + sshpass -p "$WRONG_PASS" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=2 \ + "$TARGET_USER@$TARGET_HOST" "exit" 2>/dev/null + + sleep 1 +done + +echo "[INFO] Done. Check your eBPF logs via RingBuf or /sys/kernel/debug/tracing/trace_pipe."