Some simple code I wrote based on some other investigative work I was doing around understanding eBPF (I keep wanting to write ebgp!) and what this means and allows us to leverage as this becomes more prevalent.
Inside the Kernel of the operating system we find components that are responsible for all aspects of the systems operation from networking to filesystems. eBPF acts like a small, self contained and secure environment within this core of the system allowing users to add new capabilities to the kernel or tap into existing information that each component is processing. So with this in place we can do kernel level interaction without having to change the kernel, compile modules etc. The BCC library we use below allows abstration and simplification of interacting with the Kernel. I've written the code below to be even simpler and just attach to the code to the "conenct" system call.
By running directly in the kernel we gain the following benefits
Historically when trying to get information from a information out of device as to how it was performing we would need to log into a server and collect performance metrics or rely on user-land tools peridically monitoring the system. Now with eBPF we can tap into hooks in the system directly in the syscalls and events as they happen. So with this we can know exactly when a new connection is made, who made it right down to process level information.
by tapping into the kernel and being able to extend this it is possible to extend the system to add networking functions that we would see running in userland now residing direclty inside the kernel. So, from a networking and security perspective we already see tools leveraging this capability for things like load-balancing, stateful firewall inspection and encryption.
With the ability to hook directly into the kernel, all sorts of new possibilities present themselves for security. Instead of having a firewall that allows connections on port 443 for example we could be ensuring that connections are from a specific process, executed by a specific user to known hosts. This would quickly detect suspicious activity or processes attempting to masquarate as other services.
My testing so far has been on Linux and the code I've been writing (mostly in GO) has shown to be portable between a bunch of Ubuntu servers, my Bluefin laptop (Fedora derivitave) and even my Proxmox server (Debian). Microsoft has developed an open-source, compatible eBPF implementation that will be supported in Windows 11 and Windows Server 2022.
In order to get this working we need to create a C-Based eBPF program that hooks into the parts of the kernel we are interested in. This code will reside inside the kernel and monitor the specific area of the kernel and then make this available to userland code. Typically the userland code can be written in Go, Rust or C. I've attempted to provide this example in Python as that is more common amonst us Networking folk, but time to start looking at Go and Rust people.
We define a structure that will form the based of the messages passed from the kernel to userland for intrpretation by our Python code. When we create the Python script it will be expecting to see the data in this format.
struct event_data {
u32 pid; // Process ID
u32 uid; // User ID
char comm[16]; // Process name
};
We create a channel in the kernel to send output to the userland Python script. This will be attached to the event data.
BPF_PERF_OUTPUT(events);
Connect to the system call (sys_enter_connect) for all outgoing TCP connections from the host. Gather the relevant process ID, user ID and Process name that is then passed as event data up to userland for processing.
TRACEPOINT_PROBE(syscalls, sys_enter_connect) {
struct event_data data = {};
// Get process ID from current task
u64 pid_tgid = bpf_get_current_pid_tgid();
data.pid = pid_tgid >> 32;
// Get user ID
u64 uid_gid = bpf_get_current_uid_gid();
data.uid = uid_gid & 0xFFFFFFFF;
// Get process name
bpf_get_current_comm(&data.comm, sizeof(data.comm));
// Send event to userspace
events.perf_submit(args, &data, sizeof(data));
return 0;
}
Connect to the system call (sys_enter_accept) for all incoming TCP connections from the host. Gather the relevant process ID, user ID and Process name that is then passed as event data up to userland for processing.
TRACEPOINT_PROBE(syscalls, sys_enter_accept) {
struct event_data data = {};
u64 pid_tgid = bpf_get_current_pid_tgid();
data.pid = pid_tgid >> 32;
u64 uid_gid = bpf_get_current_uid_gid();
data.uid = uid_gid & 0xFFFFFFFF;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
events.perf_submit(args, &data, sizeof(data));
return 0;
}
The python code is leveraging BPF Compiler Collection which has a dedicated library for Python use. This will need to be installed on the system already if not there using PIP or other tools. BCC allows us to write the kernel instrumentation in C with a front end using Python.
#!/usr/bin/env python3
from bcc import BPF
import ctypes
The code will now load the C code which is saved in the trace_connect.c file. This could also be embedded directly into the Python also but I've left this as an external file for readability. The code is read into "bpf_code" for later use.
print("[1] Reading eBPF C code from trace_connect.c...")
with open("trace_connect.c", "r") as f:
bpf_code = f.read()
print(" --> C code loaded")
The BPF Library now compiles the loaded C code and creates a Python object called "bpf". This object acts as an interface directly into the eBPF program and the mappings we have mede into the Kernel. Now with the C Code compiled we can leverage Python to do the rest for us.
print("[2] Compiling C code to eBPF bytecode...")
bpf = BPF(text=bpf_code)
print(" --> eBPF bytecode compiled")
print(" --> eBPF program loaded into kernel")
print(" --> Attached our code to syscall tracepoints")
Now we define a structure to match the data that is being passed up from the Kernel. This is our Process ID, User ID and Process Name.
class EventData(ctypes.Structure):
_fields_ = [
("pid", ctypes.c_uint32),
("uid", ctypes.c_uint32),
("comm", ctypes.c_char * 16),
]
# Counter for events, always need a counter :)
connect_count = 0
accept_count = 0
Now build a function to handle events that are passed to us from the Kernel.
def handle_event(cpu, data, size):
"""This function is called for each event from the kernel that we receive"""
global connect_count, accept_count
event = ctypes.cast(data, ctypes.POINTER(EventData)).contents
# Decode the data
process_name = event.comm.decode('utf-8', 'replace').rstrip('\x00')
event_type = "CONNECT"
connect_count += 1
# Print the connection details
print(f"[{event_type:7}] Process: {process_name:12} PID: {event.pid:6} UID: {event.uid:5}")
Use the BPF library to open a connection to our embedded eBPF code sitting in the kernel and calling the function we have defined above.
print("[3] Opening communication channel with kernel...")
bpf["events"].open_perf_buffer(handle_event)
print(" --> Channel to the kernel is open and ready to listen for events!")
We have now built the eBPF code and loaded this into the Kernel and attached our Python function to the code. Connection attempts as they are made will now be passed up to the Python script and we can output this as needed.
print("\n" + "="*60)
print("MONITORING TCP CONNECTIONS")
print("-"*60)
print("This eBPF program runs in the kernel and reports:")
print(" - Every TCP connect() syscall (outgoing connections)")
print(" - Every TCP accept() syscall (incoming connections)")
print("-"*60)
print("Try: curl google.com, ssh somewhere, browse the web")
print("="*60 + "\n")
# Main loop - poll for events from kernel
try:
while True:
bpf.perf_buffer_poll() # Check for new events
except KeyboardInterrupt:
print(f"\n{'-'*60}")
print(f"Statistics: {connect_count} connects, {accept_count} accepts")
print("Stopping...")
print("eBPF program unloaded from kernel")