diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..7ea76fa --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,70 @@ +name: Build and Publish Docker Image + +on: + push: + branches: [ "master", "release/*" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "master" ] + +env: + # Use ghcr.io for GitHub Container Registry + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + # Set up QEMU for multi-arch builds (arm64/amd64) + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + # Set up Docker Buildx + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Login against the registry except on PR + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + type=ref,event=pr + type=raw,value=latest,enable={{is_default_branch}} + + # Build and push Docker image with Buildx + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitmodules b/.gitmodules index 9d733da..1fcdc83 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "libbpf"] path = libsshlog/libbpf url = https://github.com/openkilt/libbpf.git -[submodule "libsshlog/bpftool"] - path = libsshlog/bpftool - url = https://github.com/libbpf/bpftool.git diff --git a/Dockerfile b/Dockerfile index 42c8d97..334e071 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,98 @@ -FROM sshlog/build:latest AS builder +# --- Stage 1: Builder --- +FROM debian:bookworm-slim AS builder -WORKDIR /build/ +ENV DEBIAN_FRONTEND=noninteractive +ENV INSTALL_TARGET_DIR=/tmp/sshlog-install +SHELL ["/bin/bash", "-c"] -COPY . ./ +# Install build dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + cmake \ + git \ + clang-19 \ + llvm-19 \ + libelf-dev \ + libbpf-dev \ + pkg-config \ + linux-libc-dev \ + flex \ + bison \ + python3-docutils \ + python3-virtualenv \ + bpftool \ + libcap-dev \ + && ln -s /usr/bin/clang-19 /usr/bin/clang \ + && ln -s /usr/bin/llvm-strip-19 /usr/bin/llvm-strip \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /source + +# Copy the library source code + +# Copy Source +COPY CMakeLists.txt . +COPY libsshlog/ ./libsshlog/ +COPY cmake/ ./cmake/ + +WORKDIR /source/build + +# Use RelWithDebInfo for optimized but debuggable binaries +RUN cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DCMAKE_INSTALL_SYSCONFDIR=/etc .. \ + && make -j$(nproc) + +# Install to a temporary directory +RUN make install DESTDIR=${INSTALL_TARGET_DIR} -RUN package/build_scripts/prep_repo.sh && \ - cd drone_src && \ - debuild -b -uc -us +# RUN mkdir -p ${INSTALL_TARGET_DIR}/usr/bin/ && \ +# cp libsshlog/sshlog_cli ${INSTALL_TARGET_DIR}/usr/bin/ +# Install Configs & Scripts +WORKDIR /source -# Deployable image -FROM ubuntu:20.04 +# Copy the daemon source code and prep the python build env +COPY daemon/ ./daemon/ +RUN rm -Rf /tmp/sshlog_venv 2>/dev/null && \ + virtualenv /tmp/sshlog_venv && \ + source /tmp/sshlog_venv/bin/activate && \ + pip3 install -r daemon/requirements.txt -COPY --from=builder /build/*.deb ./ +RUN daemon/build_binary.sh && \ + mkdir -p ${INSTALL_TARGET_DIR}/usr/bin/ && cp dist/* ${INSTALL_TARGET_DIR}/usr/bin/ && \ + mkdir -p ${INSTALL_TARGET_DIR}/var/log/sshlog && chmod 700 ${INSTALL_TARGET_DIR}/var/log/sshlog && \ + mkdir -p ${INSTALL_TARGET_DIR}/etc/sshlog/conf.d && \ + mkdir -p ${INSTALL_TARGET_DIR}/etc/sshlog/plugins && \ + mkdir -p ${INSTALL_TARGET_DIR}/etc/sshlog/samples && \ + cp daemon/config_samples/*.yaml ${INSTALL_TARGET_DIR}/etc/sshlog/samples/ && \ + # Copy the session and event log config to the conf.d folder + cp ${INSTALL_TARGET_DIR}/etc/sshlog/samples/log_all_sessions.yaml ${INSTALL_TARGET_DIR}/etc/sshlog/conf.d && \ + cp ${INSTALL_TARGET_DIR}/etc/sshlog/samples/log_events.yaml ${INSTALL_TARGET_DIR}/etc/sshlog/conf.d + +# --- Stage 2: Production --- +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +# Install Runtime Dependencies +# Added 'gdb' for debugging RUN apt-get update && apt-get install -y \ - libelf1 && \ - dpkg -i ./*.deb && \ - rm -rf /var/lib/apt/lists/* + libelf1 \ + libbpf1 \ + libcap2 \ + ca-certificates \ + gdb \ + && rm -rf /var/lib/apt/lists/* + +# Copy the compiled artifacts +COPY --from=builder /tmp/sshlog-install / + +# Ensure directories exist +RUN mkdir -p /var/log/sshlog /etc/sshlog + +# Daemon must run as root to access the Kernel BPF subsystem +USER root -CMD ["sshlogd"] +CMD ["/usr/bin/sshlogd"] diff --git a/assets/sshlog_header.png b/assets/sshlog_header.png new file mode 100644 index 0000000..3acb415 Binary files /dev/null and b/assets/sshlog_header.png differ diff --git a/assets/webserver_demo.gif b/assets/webserver_demo.gif new file mode 100644 index 0000000..0cbad47 Binary files /dev/null and b/assets/webserver_demo.gif differ diff --git a/daemon/build_binary.sh b/daemon/build_binary.sh old mode 100644 new mode 100755 index bb3aa7c..7bff6e7 --- a/daemon/build_binary.sh +++ b/daemon/build_binary.sh @@ -12,7 +12,7 @@ if [[ -f dist/sshlog && -f dist/sshlogd ]]; then exit 0 fi -rm -Rf /tmp/sshlog_venv 2>/dev/null +#rm -Rf /tmp/sshlog_venv 2>/dev/null virtualenv /tmp/sshlog_venv source /tmp/sshlog_venv/bin/activate pip3 install -r ${SCRIPT_DIR}/requirements.txt @@ -38,4 +38,4 @@ client_imports=$(findimports --ignore-stdlib ${SCRIPT_DIR}/cli/ | grep -v 'cli\ pyinstaller --onefile $CLIENT_IMPORTS ${SCRIPT_DIR}/client.py -n sshlog -echo "Python binaries built in dist/ folder" \ No newline at end of file +echo "Python binaries built in dist/ folder" diff --git a/daemon/comms/dtos.py b/daemon/comms/dtos.py index 49aeae6..bd6e0f1 100644 --- a/daemon/comms/dtos.py +++ b/daemon/comms/dtos.py @@ -5,7 +5,7 @@ from dataclasses import dataclass import dataclasses from dataclasses_json import dataclass_json -from typing import List +from typing import List, Dict, Any import json from uuid import uuid4 @@ -89,7 +89,7 @@ class KillSessionRequestDto: @dataclass(frozen=True) class EventWatchResponseDto: event_type: str - payload_json: str + payload_json: Dict[str, Any] payload_type: int = EVENT_WATCH_RESPONSE @@ -107,8 +107,8 @@ class SessionDto: pts_pid: int shell_pid: int tty_id: int - start_time: str - end_time: str + start_time: int + end_time: int last_activity_time: int last_command: str user_id: int diff --git a/daemon/comms/mq_server.py b/daemon/comms/mq_server.py index 2ce2efd..5499e43 100644 --- a/daemon/comms/mq_server.py +++ b/daemon/comms/mq_server.py @@ -75,7 +75,7 @@ class MQLocalServer(threading.Thread): Acts as a server to receive requests from client process (sshlog) responds with data that client can display to CLI ''' - def __init__(self, session_tracker: Tracker, + def __init__(self, session_tracker: Tracker, enable_injection=False, group=None, target=None, name=None, args=(), kwargs=None): super(MQLocalServer,self).__init__(group=group, target=target, @@ -85,6 +85,7 @@ def __init__(self, session_tracker: Tracker, self.response_queue = queue.Queue() self.active_streams = ActiveStreams() self._stay_alive = True + self.enable_injection = enable_injection @@ -165,10 +166,14 @@ def _launch_task(self, request_message: RequestMessage): logger.debug("Redrawing shell via SIGWINCH") os.kill(session['shell_pid'], SIGWINCH) - # Write the text char-by-char to the TTY output using ioctl - with open(f'/dev/pts/{tty_id}', 'w') as tty_out: - for key in request_message.dto_payload.keys: - fcntl.ioctl(tty_out, termios.TIOCSTI, key.encode('utf-8')) + if request_message.dto_payload.keys: + if self.enable_injection: + # Write the text char-by-char to the TTY output using ioctl + with open(f'/dev/pts/{tty_id}', 'w') as tty_out: + for key in request_message.dto_payload.keys: + fcntl.ioctl(tty_out, termios.TIOCSTI, key.encode('utf-8')) + else: + logger.warning(f"Injection disabled. Ignoring request for PTM PID {ptm_pid}") @@ -179,5 +184,3 @@ def shutdown(self): def stay_alive(self): return self._stay_alive - - diff --git a/daemon/daemon.py b/daemon/daemon.py index ae2d962..06d1f05 100644 --- a/daemon/daemon.py +++ b/daemon/daemon.py @@ -15,22 +15,43 @@ from comms.mq_base import PROC_LOCK_FILE from comms.pidlockfile import PIDLockFile, LockTimeout, AlreadyLocked import platform +from web_server import SSHLogWebServer def run_main(): parser = argparse.ArgumentParser(description="SSHLog Daemon") - parser.add_argument("-l", "--logfile", default=None, help='Path to log file') - - # parser.add_argument("-k", "--key", default=os.getenv('OPENREPO_APIKEY', ''), help='API key') - # parser.add_argument("-s", "--server", default=os.getenv('OPENREPO_SERVER', 'http://localhost:7376'), - # help="OpenRepo Server") + parser.add_argument("-l", "--logfile", default=os.environ.get('SSHLOG_LOGFILE', None), help='Path to log file') parser.add_argument( '--debug', action='store_true', + default=os.environ.get('SSHLOG_DEBUG', '').lower() in ('true', '1', 'yes'), help='Print debug info' ) + parser.add_argument( + '--enable-diagnostic-web', + action='store_true', + default=os.environ.get('SSHLOG_ENABLE_DIAGNOSTIC_WEB', '').lower() in ('true', '1', 'yes'), + help='Enable the diagnostic web interface' + ) + parser.add_argument( + '--diagnostic-web-ip', + default=os.environ.get('SSHLOG_DIAGNOSTIC_WEB_IP', '127.0.0.1'), + help='Binding IP for the diagnostic web interface (default: 127.0.0.1)' + ) + parser.add_argument( + '--diagnostic-web-port', + default=int(os.environ.get('SSHLOG_DIAGNOSTIC_WEB_PORT', 5000)), + type=int, + help='Port for the diagnostic web interface (default: 5000)' + ) + parser.add_argument( + '--enable-session-injection', + action='store_true', + default=os.environ.get('SSHLOG_ENABLE_SESSION_INJECTION', '').lower() in ('true', '1', 'yes'), + help='Enable command injection into active sessions (default: False)' + ) args = parser.parse_args() @@ -105,9 +126,16 @@ def run_main(): # Spin up local MQ server to start listening - server = MQLocalServer(session_tracker) + server = MQLocalServer(session_tracker, enable_injection=args.enable_session_injection) server.start() + # Start the Web Server + web_server = None + if args.enable_diagnostic_web: + web_server = SSHLogWebServer(session_tracker, host=args.diagnostic_web_ip, + port=args.diagnostic_web_port, enable_session_injection=args.enable_session_injection) + web_server.start() + with SSHLog(loglevel=0) as sshb: try: @@ -115,6 +143,8 @@ def run_main(): event_data = sshb.poll(timeout_ms=15) if event_data is not None: eventbus_sshtrace_push(event_data, session_tracker) + if web_server: + web_server.process_event(event_data) except KeyboardInterrupt: pass @@ -135,4 +165,3 @@ def run_main(): print(f"Error: sshlog daemon is already running. To force process to run, delete {PROC_LOCK_FILE}") except PermissionError: print(f"Permission denied accessing file {PROC_LOCK_FILE}") - diff --git a/daemon/requirements.txt b/daemon/requirements.txt index 60d6d7e..cfb939b 100644 --- a/daemon/requirements.txt +++ b/daemon/requirements.txt @@ -1,12 +1,15 @@ -blinker==1.5 -dataclasses-json==0.5.7 -datadog==0.45.0 -orjson==3.8.4 -prettytable==3.6.0 -PyYAML==6.0 -pyzmq==24.0.1 -requests==2.28.2 +blinker==1.7.0 +dataclasses-json==0.6.4 +datadog==0.49.1 +orjson==3.9.15 +prettytable==3.10.0 +PyYAML==6.0.1 +pyzmq==25.1.2 +requests==2.31.0 syslog-py==0.2.5 timeago==1.0.16 -pyinstaller==5.8.0 # For installer +pyinstaller==6.18.0 # For installer findimports==2.3.0 # For installer +Flask==3.0.2 +Flask-SocketIO==5.3.6 +simple-websocket==1.0.0 diff --git a/daemon/web_server.py b/daemon/web_server.py new file mode 100644 index 0000000..d3cfd05 --- /dev/null +++ b/daemon/web_server.py @@ -0,0 +1,288 @@ +import logging +import threading +import json +import os +from functools import wraps +from flask import Flask, render_template_string, jsonify, request, Response +import simple_websocket +# Explicitly import the threading driver to ensure PyInstaller bundles it +try: + import engineio.async_drivers.threading +except ImportError: + pass + +from flask_socketio import SocketIO, emit, join_room, leave_room +from comms.mq_client import MQClient +from comms.dtos import ShellSendKeysRequestDto +from comms.event_types import SSHTRACE_EVENT_TERMINAL_UPDATE, SSHTRACE_EVENT_CLOSE_CONNECTION + +logger = logging.getLogger('sshlog_web') + +HTML_TEMPLATE = """ + + + + SSHLog Dashboard + + + + + + + +

SSHLog Active Sessions

+ +
+
Loading sessions...
+
+ +
+
+ + +
+
+
+ + + + +""" + +def check_auth(username, password): + """Check if a username / password combination is valid.""" + env_user = os.environ.get('SSHLOG_WEB_USER', 'admin') + env_pass = os.environ.get('SSHLOG_WEB_PASS', 'admin') + return username == env_user and password == env_pass + +def authenticate(): + """Sends a 401 response that enables basic auth""" + return Response( + 'Could not verify your access level for that URL.\n' + 'You have to login with proper credentials', 401, + {'WWW-Authenticate': 'Basic realm="Login Required"'}) + +def requires_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + auth = request.authorization + if not auth or not check_auth(auth.username, auth.password): + return authenticate() + return f(*args, **kwargs) + return decorated + +class SSHLogWebServer: + def __init__(self, session_tracker, host='127.0.0.1', port=5000, enable_session_injection=False): + self.session_tracker = session_tracker + self.host = host + self.port = port + self.enable_session_injection = enable_session_injection + self.app = Flask(__name__) + self.socketio = SocketIO(self.app, cors_allowed_origins="*", async_mode='threading') + self.mq_client = MQClient() + + self.app.add_url_rule('/', 'index', requires_auth(self.index)) + self.app.add_url_rule('/api/sessions', 'get_sessions', requires_auth(self.get_sessions)) + self.socketio.on_event('term_input', self.on_term_input) + self.socketio.on_event('join_session', self.on_join_session) + self.socketio.on_event('leave_session', self.on_leave_session) + self.buffers = {} + + def start(self): + t = threading.Thread(target=self._run) + t.daemon = True + t.start() + logger.info(f"Web server started on port {self.port}") + + def _run(self): + self.socketio.run(self.app, host=self.host, port=self.port, use_reloader=False, log_output=False) + + def index(self): + return render_template_string(HTML_TEMPLATE, injection_enabled=self.enable_session_injection) + + def get_sessions(self): + sessions = [] + try: + # Iterate over the tracker's sessions + for s in self.session_tracker.get_sessions(): + sessions.append({ + 'user': s['username'], + 'ptm_pid': s['ptm_pid'], + 'tty_id': s['tty_id'], + 'client_ip': s['tcp_info']['client_ip'], + 'start_time': s.get('start_time') + }) + except Exception as e: + logger.error(f"Error listing sessions: {e}") + return jsonify(sessions) + + def on_join_session(self, data): + ptm_pid = data.get('ptm_pid') + if ptm_pid: + join_room(str(ptm_pid)) + + def on_leave_session(self, data): + ptm_pid = data.get('ptm_pid') + if ptm_pid: + leave_room(str(ptm_pid)) + + def on_term_input(self, data): + ptm_pid = data.get('ptm_pid') + keys = data.get('data') + force_redraw = data.get('force_redraw', False) + + if ptm_pid: + if force_redraw and ptm_pid in self.buffers: + emit('term_output', { + 'ptm_pid': ptm_pid, + 'data': self.buffers[ptm_pid] + }) + + # Block keystrokes if injection is disabled + if keys and not self.enable_session_injection: + logger.warning(f"Keystroke injection attempted for PID {ptm_pid} but is disabled.") + return + + # Use the internal MQ client to send keystrokes to the daemon's MQ server + dto = ShellSendKeysRequestDto(ptm_pid=ptm_pid, keys=keys, force_redraw=force_redraw) + self.mq_client.make_request(dto) + + def process_event(self, event_data): + event_type = event_data.get('event_type') + ptm_pid = event_data.get('ptm_pid') + + # Broadcast terminal updates to all connected web clients + if event_type == SSHTRACE_EVENT_TERMINAL_UPDATE: + data = event_data.get('terminal_data') + if data: + if ptm_pid not in self.buffers: + self.buffers[ptm_pid] = "" + self.buffers[ptm_pid] += data + if len(self.buffers[ptm_pid]) > 16384: + self.buffers[ptm_pid] = self.buffers[ptm_pid][-16384:] + + self.socketio.emit('term_output', { + 'ptm_pid': ptm_pid, + 'data': data + }, room=str(ptm_pid)) + elif event_type == SSHTRACE_EVENT_CLOSE_CONNECTION: + if ptm_pid in self.buffers: + del self.buffers[ptm_pid] \ No newline at end of file diff --git a/distros/prep_install.sh b/distros/prep_install.sh old mode 100644 new mode 100755 diff --git a/libsshlog/CMakeLists.txt b/libsshlog/CMakeLists.txt index ad97670..f3de1bf 100644 --- a/libsshlog/CMakeLists.txt +++ b/libsshlog/CMakeLists.txt @@ -4,8 +4,8 @@ option(BUILD_TESTS "Build test programs" OFF) option(USE_RINGBUF "Use more efficient Ringbuf for BPF comms, requires recent kernel" OFF) -CPMAddPackage("gh:ibireme/yyjson#0.6.0") -CPMAddPackage("gh:SergiusTheBest/plog#1.1.9") +CPMAddPackage("gh:ibireme/yyjson#0.12.0") +CPMAddPackage("gh:SergiusTheBest/plog#1.1.11") CPMAddPackage( NAME pfs @@ -31,7 +31,13 @@ ExternalProject_Add(libbpf PREFIX libbpf SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/libbpf/src CONFIGURE_COMMAND "" - BUILD_COMMAND make + # Step 1: Just Build + BUILD_COMMAND make -j$(nproc) + BUILD_STATIC_ONLY=1 + CPPFLAGS=-fPIC + OBJDIR=${CMAKE_CURRENT_BINARY_DIR}/libbpf/libbpf + # Step 2: Install + INSTALL_COMMAND make BUILD_STATIC_ONLY=1 CPPFLAGS=-fPIC OBJDIR=${CMAKE_CURRENT_BINARY_DIR}/libbpf/libbpf @@ -41,7 +47,6 @@ ExternalProject_Add(libbpf UAPIDIR= install install_uapi_headers BUILD_IN_SOURCE TRUE - INSTALL_COMMAND "" STEP_TARGETS build ) @@ -51,20 +56,8 @@ else () set (X86 FALSE) endif () -ExternalProject_Add(bpftool - PREFIX bpftool - SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/bpftool/src - CONFIGURE_COMMAND "" - BUILD_COMMAND make bootstrap - OUTPUT=${CMAKE_CURRENT_BINARY_DIR}/bpftool/ - BUILD_IN_SOURCE TRUE - INSTALL_COMMAND "" - STEP_TARGETS build -) - set(LIBBPF_INCLUDE_DIRS ${CMAKE_CURRENT_BINARY_DIR}/libbpf) set(LIBBPF_LIBRARIES ${CMAKE_CURRENT_BINARY_DIR}/libbpf/libbpf.a) -set(BPFOBJECT_BPFTOOL_EXE ${CMAKE_CURRENT_BINARY_DIR}/bpftool/bootstrap/bpftool) if (X86) message("-- BPF Using x86 vmlinux.h file") @@ -106,7 +99,7 @@ if(USE_RINGBUF) endif() bpf_object(sshtrace bpf/sshtrace.bpf.c) -add_dependencies(sshtrace_skel libbpf-build bpftool-build) +add_dependencies(sshtrace_skel libbpf-build ) set(sshlog_SRC sshlog.cpp @@ -121,6 +114,7 @@ add_library(sshlog SHARED ) target_link_libraries(sshlog + PRIVATE sshtrace_skel ${LIBBPF_LIBRARIES} yyjson @@ -147,4 +141,7 @@ if(USE_RINGBUF) endif() set_target_properties(sshlog PROPERTIES SOVERSION 1) -install (TARGETS sshlog DESTINATION ${CMAKE_INSTALL_PREFIX}/lib) \ No newline at end of file +install (TARGETS sshlog DESTINATION ${CMAKE_INSTALL_PREFIX}/lib) + + +add_subdirectory(tests) diff --git a/libsshlog/bpf/sshtrace.bpf.c b/libsshlog/bpf/sshtrace.bpf.c index 01db148..fda0792 100644 --- a/libsshlog/bpf/sshtrace.bpf.c +++ b/libsshlog/bpf/sshtrace.bpf.c @@ -19,14 +19,13 @@ * SPDX-License-Identifier: GPL-2.0 */ - +#include "sshtrace_events.h" +#include "sshtrace_heap.h" +#include "sshtrace_types.h" #include "vmlinux/vmlinux.h" +#include #include #include -#include -#include "sshtrace_events.h" -#include "sshtrace_types.h" -#include "sshtrace_heap.h" char LICENSE[] SEC("license") = "GPL"; @@ -37,7 +36,6 @@ char LICENSE[] SEC("license") = "GPL"; // 102 101 pt slave // 103 102 sh/bash or whatever - // Swap these defines out for debugging. Logs can be seen with: // sudo cat /sys/kernel/debug/tracing/trace_pipe #ifdef SSHTRACE_DEBUG @@ -46,978 +44,799 @@ char LICENSE[] SEC("license") = "GPL"; #define log_printk(fmt, args...) #endif +#define IS_CMD(buf, str) (__builtin_memcmp(buf, str, sizeof(str)) == 0) #ifdef SSHTRACE_USE_RINGBUF - // Ringbuf is more efficient but requires at least kernel version 5.8 - struct { - __uint(type, BPF_MAP_TYPE_RINGBUF); - __uint(max_entries, 4096 * 1024 /* 4 MB */); - } events SEC(".maps"); +// Ringbuf is more efficient but requires at least kernel version 5.8 +struct { + __uint(type, BPF_MAP_TYPE_RINGBUF); + __uint(max_entries, 4096 * 1024 /* 4 MB */); +} events SEC(".maps"); #else - struct { - __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); - __uint(key_size, sizeof(u32)); - __uint(value_size, sizeof(u32)); - } events SEC(".maps"); +struct { + __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); + __uint(key_size, sizeof(u32)); + __uint(value_size, sizeof(u32)); +} events SEC(".maps"); #endif -struct socket_map -{ - struct sockaddr* addr; - struct tcpinfo recent_tcpinfo; +struct socket_map { + struct sockaddr* addr; + struct tcpinfo recent_tcpinfo; }; // Just used to temporarily map a pointer between enter_accept and exit_accept struct { - __uint(type, BPF_MAP_TYPE_LRU_HASH); - __uint(max_entries, 100); - __type(key, u32); - __type(value, struct socket_map ); + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 100); + __type(key, u32); + __type(value, struct socket_map); } socket_mapping SEC(".maps"); - - // Provide a sane limit on buffer size for tracking concurrently running programs (hash size) // memory usage = MAX_CONCURRENT_PROGRAMS * (STDOUT_ACTUAL_MEM_USAGE_BYTES + sizeof(command2) + COMMAND_ARGS_ACTUAL_MEM_USAGE_BYTES) // so for 2000, about ~20MB #define MAX_CONCURRENT_PROGRAMS 2000 - struct { - __uint(type, BPF_MAP_TYPE_LRU_HASH); - __uint(max_entries, MAX_CONCURRENT_PROGRAMS); - __type(key, u32); - __type(value, struct command); + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, MAX_CONCURRENT_PROGRAMS); + __type(key, u32); + __type(value, struct command); } commands SEC(".maps"); - - #define MAX_CONNECTIONS 10000 struct { - __uint(type, BPF_MAP_TYPE_LRU_HASH); - __uint(max_entries, MAX_CONNECTIONS); - __type(key, u32); - __type(value, struct connection); + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, MAX_CONNECTIONS); + __type(key, u32); + __type(value, struct connection); } connections SEC(".maps"); - // Maps the data pointer between read enter and read exit -struct read_buffer_map -{ - int fd; - void* data_ptr; +struct read_buffer_map { + int fd; + void* data_ptr; }; struct { - __uint(type, BPF_MAP_TYPE_LRU_HASH); - __uint(max_entries, MAX_CONNECTIONS); - __type(key, u32); - __type(value, struct read_buffer_map); + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, MAX_CONNECTIONS); + __type(key, u32); + __type(value, struct read_buffer_map); } connections_read_mapping SEC(".maps"); - - // Local constants since we can't include std headers static const int AF_UNIX = 1; static const int AF_INET = 2; static const int AF_INET6 = 10; // TODO: Test and add support for ipv6 +// --- THE NEW CACHE MAP --- +// Key: TGID (Process ID) +// Value: 1 = IS_SSHD, 2 = NOT_SSHD +// LRU type automatically evicts old entries when full (4096 entries) +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 4096); + __type(key, u32); + __type(value, u8); +} proc_cache SEC(".maps"); static const int STDIN_FILENO = 0; static const int STDOUT_FILENO = 1; static const int STDERR_FILENO = 2; +#define O_WRONLY 01 -static void push_event(void* context, void* event, size_t event_size) -{ +static void push_event(void* context, void* event, size_t event_size) { #ifdef SSHTRACE_USE_RINGBUF -// TODO consider more efficient bpf_ringbuf_reserve()/bpf_ringbuf_commit() - int64_t success = bpf_ringbuf_output(&events, event, event_size, 0); - if (success < 0) - log_printk("event push error code: %d", success); + // TODO consider more efficient bpf_ringbuf_reserve()/bpf_ringbuf_commit() + int64_t success = bpf_ringbuf_output(&events, event, event_size, 0); + if (success < 0) + log_printk("event push error code: %d", success); #else - bpf_perf_event_output(context, &events, BPF_F_CURRENT_CPU, event, event_size); + bpf_perf_event_output(context, &events, BPF_F_CURRENT_CPU, event, event_size); #endif } -static int proc_is_sshd(void) -{ - // Just needs to be bigger than sshd\0 - char comm[6]; - bpf_get_current_comm(&comm, sizeof(comm)); +// 1 = Yes (SSHD or sshd-session), 0 = No +static int check_proc_name(void) { + char comm[14]; + bpf_get_current_comm(&comm, sizeof(comm)); - // Check that process name is "sshd" - if (comm[0] == 's' && comm[1] == 's' && comm[2] == 'h' && comm[3] == 'd' && comm[4] == '\0') - return 1; - return 0; + // Check the name of the command. Either of these indicate an sshd session process + if (IS_CMD(comm, "sshd") || IS_CMD(comm, "sshd-session")) { + return 1; + } + return 0; } -static u64 get_parent_pid(void) -{ - struct task_struct *curr = (struct task_struct *)bpf_get_current_task(); - struct task_struct *parent = (struct task_struct *)BPF_CORE_READ(curr, parent); - - if (parent == NULL) - return 0; - - u32 p_tgid = BPF_CORE_READ(parent, tgid); - u32 p_pid = BPF_CORE_READ(parent, pid); - return ((u64)p_tgid << 32) | p_pid; -} +// Returns: 1 if this is an SSHD process, 0 if not. +static int is_sshd_process(void) { + u32 tgid = bpf_get_current_pid_tgid() >> 32; -static u64 get_grandparent_pid(void) -{ - struct task_struct *curr = (struct task_struct *)bpf_get_current_task(); - struct task_struct *parent = (struct task_struct *)BPF_CORE_READ(curr, parent); - struct task_struct *gparent = (struct task_struct *)BPF_CORE_READ(parent, parent); + // 1. Check Cache + u8* cached_val = bpf_map_lookup_elem(&proc_cache, &tgid); + if (cached_val) { + // If 1, it's SSHD. If 2, it's NOT SSHD. + return (*cached_val == 1); + } - if (parent == NULL) - return 0; + // 2. Cache Miss: Perform Expensive String Check + int is_sshd = check_proc_name(); - u32 p_tgid = BPF_CORE_READ(gparent, tgid); - u32 p_pid = BPF_CORE_READ(gparent, pid); + // 3. Update Cache (Store 1 for Yes, 2 for No) + // We use 2 for "No" because 0 is often the default/null value + u8 new_val = is_sshd ? 1 : 2; + bpf_map_update_elem(&proc_cache, &tgid, &new_val, BPF_ANY); - return ((u64)p_tgid << 32) | p_pid; + return is_sshd; } -static struct connection *find_ancestor_connection(void) -{ - struct task_struct *tsk = (struct task_struct *)bpf_get_current_task(); - struct connection *conn; +// --- STANDARD HELPERS --- - //log_printk("ancestor search start"); +static u64 get_parent_pid(void) { + struct task_struct* curr = (struct task_struct*) bpf_get_current_task(); + struct task_struct* parent = (struct task_struct*) BPF_CORE_READ(curr, parent); - // put a max cap on looping back to parent - for (int i = 0; i < 20; i++) - { - u32 tgid = BPF_CORE_READ(tsk, tgid); - if (tgid <= 1) - { - //log_printk("Reached root tgid %u\n", tgid); - break; - } + if (parent == NULL) + return 0; - //log_printk("my tgid %u\n", tgid); - - conn = bpf_map_lookup_elem(&connections, &tgid); - if (conn != NULL) - return conn; - tsk = (struct task_struct *)BPF_CORE_READ(tsk, parent); - - } - - - return NULL; + u32 p_tgid = BPF_CORE_READ(parent, tgid); + u32 p_pid = BPF_CORE_READ(parent, pid); + return ((u64) p_tgid << 32) | p_pid; } +static u64 get_grandparent_pid(void) { + struct task_struct* curr = (struct task_struct*) bpf_get_current_task(); + struct task_struct* parent = (struct task_struct*) BPF_CORE_READ(curr, parent); + struct task_struct* gparent = (struct task_struct*) BPF_CORE_READ(parent, parent); -SEC("tracepoint/syscalls/sys_enter_accept") -int sys_enter_accept(struct trace_event_raw_sys_enter* ctx) -{ - // field:int fd; offset:16; size:8; signed:0; - // field:struct sockaddr * upeer_sockaddr; offset:24; size:8; signed:0; - // field:int * upeer_addrlen; offset:32; size:8; signed:0; - - if (!proc_is_sshd()) - return 1; - - u64 pid_tgid = bpf_get_current_pid_tgid(); - - struct socket_map sockmap = {0}; - sockmap.recent_tcpinfo.client_ip = 0; - sockmap.recent_tcpinfo.server_ip = 0; - sockmap.recent_tcpinfo.client_port = 0; - sockmap.recent_tcpinfo.server_port = 0; + if (parent == NULL) + return 0; - uint32_t socket_id = (uint32_t) BPF_CORE_READ(ctx, args[0]); - sockmap.addr = (struct sockaddr*) BPF_CORE_READ(ctx, args[1]); - //int* addrlen = (int*) BPF_CORE_READ(ctx, args[2]); + u32 p_tgid = BPF_CORE_READ(gparent, tgid); + u32 p_pid = BPF_CORE_READ(gparent, pid); - - bpf_map_update_elem(&socket_mapping, &pid_tgid, &sockmap, BPF_ANY); - - return 0; + return ((u64) p_tgid << 32) | p_pid; } -SEC("tracepoint/syscalls/sys_exit_accept") -int sys_exit_accept(struct trace_event_raw_sys_exit* ctx) -{ - if (!proc_is_sshd()) - return 1; - - u64 pid_tgid = bpf_get_current_pid_tgid(); +static struct connection* find_ancestor_connection(void) { + struct task_struct* tsk = (struct task_struct*) bpf_get_current_task(); + struct connection* conn; + //log_printk("ancestor search start"); + // put a max cap on looping back to parent + for (int i = 0; i < 20; i++) { + u32 tgid = BPF_CORE_READ(tsk, tgid); + if (tgid <= 1) { + //log_printk("Reached root tgid %u\n", tgid); + break; + } - int32_t ret = (int32_t) BPF_CORE_READ(ctx, ret); + //log_printk("my tgid %u\n", tgid); - struct socket_map* sockmap = bpf_map_lookup_elem(&socket_mapping, &pid_tgid); + conn = bpf_map_lookup_elem(&connections, &tgid); + if (conn != NULL) + return conn; + tsk = (struct task_struct*) BPF_CORE_READ(tsk, parent); + } - struct sockaddr* addr_peer = sockmap->addr; + return NULL; +} - if (sockmap == NULL || addr_peer == NULL) - return 1; +// --- HOOKS --- - u32 sock_family = BPF_CORE_READ_USER(addr_peer, sa_family); +SEC("tracepoint/syscalls/sys_enter_accept") +int sys_enter_accept(struct trace_event_raw_sys_enter* ctx) { + // field:int fd; offset:16; size:8; signed:0; + // field:struct sockaddr * upeer_sockaddr; offset:24; size:8; signed:0; + // field:int * upeer_addrlen; offset:32; size:8; signed:0; - if (sock_family == AF_INET) - { - struct sockaddr_in* inet_socket = (struct sockaddr_in*) addr_peer; - - u16 port = BPF_CORE_READ_USER(inet_socket, sin_port);// & 0xffff; - port = __builtin_bswap16(port); + if (!is_sshd_process()) + return 1; - u32 ip_address = BPF_CORE_READ_USER(inet_socket, sin_addr.s_addr); - //ip_address = __builtin_bswap32(ip_address); + u64 pid_tgid = bpf_get_current_pid_tgid(); - sockmap->recent_tcpinfo.client_ip = ip_address; - sockmap->recent_tcpinfo.server_ip = 0; // Unknown - sockmap->recent_tcpinfo.client_port = port; - sockmap->recent_tcpinfo.server_port = 0; // Unknown + struct socket_map sockmap = {0}; + sockmap.recent_tcpinfo.client_ip = 0; + sockmap.recent_tcpinfo.server_ip = 0; + sockmap.recent_tcpinfo.client_port = 0; + sockmap.recent_tcpinfo.server_port = 0; - } - else if (sock_family == AF_INET6) - { - // TODO: support IPv6 - // struct sockaddr_in6 *s = (struct sockaddr_in6 *)&addr; - // port = ntohs(s->sin6_port); - // inet_ntop(AF_INET6, &s->sin6_addr, ipstr, sizeof ipstr); - } + sockmap.addr = (struct sockaddr*) BPF_CORE_READ(ctx, args[1]); + //int* addrlen = (int*) BPF_CORE_READ(ctx, args[2]); - - log_printk("sys_exit_accept tgid: %d fd: %d", pid_tgid, ret); - + bpf_map_update_elem(&socket_mapping, &pid_tgid, &sockmap, BPF_ANY); - return 0; + return 0; } +SEC("tracepoint/syscalls/sys_exit_accept") +int sys_exit_accept(struct trace_event_raw_sys_exit* ctx) { + if (!is_sshd_process()) + return 1; + u64 pid_tgid = bpf_get_current_pid_tgid(); + int32_t ret = (int32_t) BPF_CORE_READ(ctx, ret); -// TODO: This also works, and provides more information (server port and IP address) -// But I do not like that it is less maintainable (i.e., it's a kernel probe instead of tracepoint) -// SEC("kretprobe/inet_csk_accept") -// int BPF_KRETPROBE(inet_csk_accept_ret, struct sock *newsk) -// { - -// if (!proc_is_sshd()) -// return 1; - -// if (newsk == NULL) -// return 0; - -// u64 pid_tgid = bpf_get_current_pid_tgid(); -// struct sshd_listener *listener; - -// if ((listener = get_sshd_listener()) == NULL) -// return 1; - -// u16 family = 0; -// bpf_core_read(&family, sizeof(family), &newsk->__sk_common.skc_family); - - -// if (family == AF_INET) -// { - - -// u16 client_port = 0, server_port = 0; -// u32 client_ip = 0, server_ip = 0; -// bpf_core_read(&client_port, sizeof(client_port), &newsk->__sk_common.skc_dport); -// bpf_core_read(&server_port, sizeof(server_port), &newsk->__sk_common.skc_num); -// bpf_core_read(&client_ip, sizeof(client_ip), &newsk->__sk_common.skc_daddr); -// bpf_core_read(&server_ip, sizeof(server_ip), &newsk->__sk_common.skc_rcv_saddr); + struct socket_map* sockmap = bpf_map_lookup_elem(&socket_mapping, &pid_tgid); -// client_port = __builtin_bswap16(client_port); -// client_ip = __builtin_bswap32(client_ip); -// server_ip = __builtin_bswap32(server_ip); + // 1. Check if map lookup succeeded + if (sockmap == NULL) + return 1; -// // TODO, is there a race condition here? -// // e.g., two SSH sessions happen around the same time and the connections get crossed... -// // If so, recent_tcpinfo needs to be a global hash with some unique ID to differentiate. -// listener->pid_tgid = pid_tgid; -// listener->recent_tcpinfo.client_ip = client_ip; -// listener->recent_tcpinfo.server_ip = server_ip; -// listener->recent_tcpinfo.client_port = client_port; -// listener->recent_tcpinfo.server_port = server_port; - -// log_printk("inet_csk_accept fd: %d %d %u", pid_tgid, client_port, server_port); -// } + // 2. Now it is safe to read sockmap->addr + struct sockaddr* addr_peer = sockmap->addr; + // 3. Check if the internal pointer is valid + if (addr_peer == NULL) + return 1; -// return 0; + u32 sock_family = BPF_CORE_READ_USER(addr_peer, sa_family); -// } + if (sock_family == AF_INET) { + struct sockaddr_in* inet_socket = (struct sockaddr_in*) addr_peer; + u16 port = BPF_CORE_READ_USER(inet_socket, sin_port); // & 0xffff; + port = __builtin_bswap16(port); + u32 ip_address = BPF_CORE_READ_USER(inet_socket, sin_addr.s_addr); + //ip_address = __builtin_bswap32(ip_address); + sockmap->recent_tcpinfo.client_ip = ip_address; + sockmap->recent_tcpinfo.server_ip = 0; // Unknown + sockmap->recent_tcpinfo.client_port = port; + sockmap->recent_tcpinfo.server_port = 0; // Unknown + } else if (sock_family == AF_INET6) { + // TODO: support IPv6 + // struct sockaddr_in6 *s = (struct sockaddr_in6 *)&addr; + // port = ntohs(s->sin6_port); + // inet_ntop(AF_INET6, &s->sin6_addr, ipstr, sizeof ipstr); + } + log_printk("sys_exit_accept tgid: %d fd: %d", pid_tgid, ret); + return 0; +} -static bool is_ptm_clone(u64 pid_tgid) -{ - struct socket_map *sockmap = bpf_map_lookup_elem(&socket_mapping, &pid_tgid); +static bool is_ptm_clone(u64 pid_tgid) { + struct socket_map* sockmap = bpf_map_lookup_elem(&socket_mapping, &pid_tgid); - if (sockmap == NULL) - return false; + if (sockmap == NULL) + return false; - return true; + return true; } +static void handle_new_connection(void* context, u32 sshd_tgid, u32 conn_tgid) { -static void handle_new_connection(void* context, u32 sshd_tgid, u32 conn_tgid) -{ + struct socket_map* sockmap = bpf_map_lookup_elem(&socket_mapping, &sshd_tgid); + struct connection conn = {}; - struct socket_map *sockmap = bpf_map_lookup_elem(&socket_mapping, &sshd_tgid); - struct connection conn = {}; + log_printk("conn_tgid %d parent %d\n", conn_tgid, sshd_tgid); + if (sockmap == NULL) + return; - log_printk("conn_tgid %d parent %d\n", conn_tgid, sshd_tgid); - if (sockmap == NULL) - return; + conn.ptm_tgid = conn_tgid; + conn.user_id = -1; + conn.pts_tgid = -1; + conn.shell_tgid = -1; + conn.tty_id = -1; + conn.tcp_info = sockmap->recent_tcpinfo; - conn.ptm_tgid = conn_tgid; - conn.user_id = -1; - conn.pts_tgid = -1; - conn.shell_tgid = -1; - conn.tty_id = -1; - conn.tcp_info = sockmap->recent_tcpinfo; + conn.start_time = bpf_ktime_get_ns(); + conn.end_time = 0; + conn.rate_limit_epoch_second = 0; + conn.rate_limit_hit = false; + conn.rate_limit_total_bytes_this_second = 0; - conn.start_time = bpf_ktime_get_ns(); - conn.end_time = 0; - conn.rate_limit_epoch_second = 0; - conn.rate_limit_hit = false; - conn.rate_limit_total_bytes_this_second = 0; + // Initialize the IOPS counter + conn.rate_limit_events_this_second = 0; + bpf_map_delete_elem(&socket_mapping, &sshd_tgid); + bpf_map_update_elem(&connections, &conn_tgid, &conn, BPF_ANY); - // cleanup sockmap - bpf_map_delete_elem(&socket_mapping, &sshd_tgid); - - bpf_map_update_elem(&connections, &conn_tgid, &conn, BPF_ANY); + // The PTM has been created now, go ahead and send the event + int zero = 0; + struct connection_event* e = bpf_map_lookup_elem(&connectionevent_heap, &zero); + if (!e) + return; - // The PTM has been created now, go ahead and send the event - int zero = 0; - struct connection_event* e = bpf_map_lookup_elem(&connectionevent_heap, &zero); - if (!e) - return; + e->event_type = SSHTRACE_EVENT_NEW_CONNECTION; + e->ptm_pid = conn.ptm_tgid; + e->conn = conn; + push_event(context, e, sizeof(struct connection_event)); - e->event_type = SSHTRACE_EVENT_NEW_CONNECTION; - e->ptm_pid = conn.ptm_tgid; - e->conn = conn; - push_event(context, e, sizeof(struct connection_event)); - - log_printk("conn ptm_tgid %u\n", conn_tgid); + log_printk("conn ptm_tgid %u\n", conn_tgid); } -static bool is_pts_clone(u32 tgid) -{ - struct connection *conn; - - conn = bpf_map_lookup_elem(&connections, &tgid); - if (conn == NULL) - return false; +static bool is_pts_clone(u32 tgid) { + struct connection* conn = bpf_map_lookup_elem(&connections, &tgid); + if (conn == NULL) + return false; - return tgid == conn->ptm_tgid; + return tgid == conn->ptm_tgid; } +static bool is_bash_clone() { + u32 parent_tgid = get_parent_pid() >> 32; -static bool is_bash_clone() -{ - u32 parent_tgid = get_parent_pid() >> 32; + struct connection* conn = bpf_map_lookup_elem(&connections, &parent_tgid); + if (conn == NULL) + return false; - struct connection* conn = bpf_map_lookup_elem(&connections, &parent_tgid); - if (conn == NULL) - return false; - - return true; + return true; } - SEC("tracepoint/syscalls/sys_exit_clone") -int sys_exit_clone(struct trace_event_raw_sys_exit* ctx) -{ - u64 pid_tgid = bpf_get_current_pid_tgid(); - - // mhill add - if (!proc_is_sshd()) - return 1; - - log_printk("sys_exit_clone\n"); +int sys_exit_clone(struct trace_event_raw_sys_exit* ctx) { + u64 pid_tgid = bpf_get_current_pid_tgid(); - int32_t ret = (int32_t) BPF_CORE_READ(ctx, ret); + if (!is_sshd_process()) + return 1; - u32 child_tgid = ret; + log_printk("sys_exit_clone\n"); + int32_t ret = (int32_t) BPF_CORE_READ(ctx, ret); - if (is_pts_clone(pid_tgid >> 32)) { - log_printk("CLONE CONNECTION parent pid %d child pid %d", pid_tgid, child_tgid); - - struct connection *conn = bpf_map_lookup_elem(&connections, &pid_tgid); + u32 child_tgid = ret; - if (conn != NULL) - { + if (is_pts_clone(pid_tgid >> 32)) { + log_printk("CLONE CONNECTION parent pid %d child pid %d", pid_tgid, child_tgid); - conn->pts_tgid = child_tgid; + struct connection* conn = bpf_map_lookup_elem(&connections, &pid_tgid); - } - - return 0; - } + if (conn != NULL) { + conn->pts_tgid = child_tgid; + } - if (is_ptm_clone(pid_tgid)) { - log_printk("NEW CONNECTION parent pid %d child pid %d", pid_tgid, child_tgid); - handle_new_connection(ctx, pid_tgid, child_tgid); + return 0; + } + if (is_ptm_clone(pid_tgid)) { + log_printk("NEW CONNECTION parent pid %d child pid %d", pid_tgid, child_tgid); + handle_new_connection(ctx, pid_tgid, child_tgid); - return 0; - } + return 0; + } + if (is_bash_clone() && child_tgid != 0) { - if (is_bash_clone() && child_tgid != 0) { + log_printk("NEW BASH CONNECTION parent pid %d child pid %d", pid_tgid, child_tgid); - log_printk("NEW BASH CONNECTION parent pid %d child pid %d", pid_tgid, child_tgid); + // The FD mapping for PTS is too squirrelly. Instead of chasing the ioctl -> dup calls + // (which could change between openssh versions) a more reliable method seems to be, + // Wait for the pts proc to fork a terminal, and when it does, pop a message out to user-space to + // go lookup the FDs for the pts sessions in /proc/[pid]/fd/. Then update the map back here + // It's possible we miss a little bit of initial terminal reads due to the poll interval - // The FD mapping for PTS is too squirrelly. Instead of chasing the ioctl -> dup calls - // (which could change between openssh versions) a more reliable method seems to be, - // Wait for the pts proc to fork a terminal, and when it does, pop a message out to user-space to - // go lookup the FDs for the pts sessions in /proc/[pid]/fd/. Then update the map back here - // It's possible we miss a little bit of initial terminal reads due to the poll interval + // We'll need to have this logic in userspace anyway, for when our proc is started while + // sessions already exist monitor existing sessions - // We'll need to have this logic in userspace anyway, for when our proc is started while - // sessions already exist monitor existing sessions + struct bash_clone_event e; + e.event_type = SSHTRACE_EVENT_BASH_CLONED; + e.pts_pid = pid_tgid; + e.bash_pid = child_tgid; + e.ptm_pid = get_parent_pid() >> 32; - struct bash_clone_event e; - e.event_type = SSHTRACE_EVENT_BASH_CLONED; - e.pts_pid = pid_tgid; - e.bash_pid = child_tgid; - e.ptm_pid = get_parent_pid() >> 32; - - push_event(ctx, &e, sizeof(e)); - } + push_event(ctx, &e, sizeof(e)); + } - return 0; + return 0; } -#define O_WRONLY 01 SEC("tracepoint/syscalls/sys_enter_openat") -int sys_enter_openat(struct trace_event_raw_sys_enter* ctx) -{ - - // field:int dfd; offset:16; size:8; signed:0; - // field:const char * filename; offset:24; size:8; signed:0; - // field:int flags; offset:32; size:8; signed:0; - // field:umode_t mode; offset:40; size:8; signed:0; - - // Just needs to be bigger than scp\0 - char pname[5]; - - bpf_get_current_comm(&pname, sizeof(pname)); - - // Check that process name is "scp" - if (pname[0] != 's' || pname[1] != 'c' || pname[2] != 'p' || pname[3] != '\0') - { - return 0; - } - - // This needs to be a direct descendent of PTS (i.e., no bash or shell in between) - // Otherwise, this is not an scp upload - u32 gparent_tgid = get_grandparent_pid() >> 32; - struct connection* conn = bpf_map_lookup_elem(&connections, &gparent_tgid); - if (conn != NULL) - { - - - u32 flags = (size_t) BPF_CORE_READ(ctx, args[2]); - - if (flags & O_WRONLY) - { - u32 mode = (size_t) BPF_CORE_READ(ctx, args[3]); - - - int zero = 0; - struct file_upload_event* e = bpf_map_lookup_elem(&fileuploadevent_heap, &zero); - if (!e) - return 0; - - e->event_type = SSHTRACE_EVENT_FILE_UPLOAD; - e->ptm_pid = conn->ptm_tgid; - e->file_mode = mode; - - const char* filename_ptr = (const char*) BPF_CORE_READ(ctx, args[1]); - //static char filename[255]; - bpf_core_read_user_str(e->target_path, sizeof(e->target_path), ctx->args[1]); - - u32 current_tgid = bpf_get_current_pid_tgid() >> 32; - log_printk("scp open event: pid: %d ptm_pid %d - %s", current_tgid, e->ptm_pid, e->target_path); - - - push_event(ctx, e, sizeof(struct file_upload_event)); - - // u32 dfd = (size_t) BPF_CORE_READ(ctx, args[0]); - // log_printk("open: tgid: %d - %s", current_tgid, filename);) - // log_printk("open: dfd: %d flags: %d mode: %d", dfd, flags, mode; - } - - - } - - return 0; +int sys_enter_openat(struct trace_event_raw_sys_enter* ctx) { + + // field:int dfd; offset:16; size:8; signed:0; + // field:const char * filename; offset:24; size:8; signed:0; + // field:int flags; offset:32; size:8; signed:0; + // field:umode_t mode; offset:40; size:8; signed:0; + + // Buffer for process name (16 is the kernel max for comms) + char pname[16]; + bpf_get_current_comm(&pname, sizeof(pname)); + + // Check the name of the command. Either of these indicate a file upload + if (!IS_CMD(pname, "scp") && !IS_CMD(pname, "sftp-server")) { + return 0; + } + + // This needs to be a direct descendent of PTS (i.e., no bash or shell in between) + // Otherwise, this is not an scp upload + u32 gparent_tgid = get_grandparent_pid() >> 32; + struct connection* conn = bpf_map_lookup_elem(&connections, &gparent_tgid); + if (conn != NULL) { + u32 flags = (size_t) BPF_CORE_READ(ctx, args[2]); + + if (flags & O_WRONLY) { + u32 mode = (size_t) BPF_CORE_READ(ctx, args[3]); + + int zero = 0; + struct file_upload_event* e = bpf_map_lookup_elem(&fileuploadevent_heap, &zero); + if (!e) + return 0; + + e->event_type = SSHTRACE_EVENT_FILE_UPLOAD; + e->ptm_pid = conn->ptm_tgid; + e->file_mode = mode; + bpf_core_read_user_str(e->target_path, sizeof(e->target_path), ctx->args[1]); + + u32 current_tgid = bpf_get_current_pid_tgid() >> 32; + log_printk("scp open event: pid: %d ptm_pid %d - %s", current_tgid, e->ptm_pid, e->target_path); + + push_event(ctx, e, sizeof(struct file_upload_event)); + } + } + + return 0; } - - -static int sys_enter_exec_common(struct trace_event_raw_sys_enter* ctx) -{ - // field:const char * filename; offset:16; size:8; signed:0; - // field:const char *const * argv; offset:24; size:8; signed:0; - // field:const char *const * envp; offset:32; size:8; signed:0; - - - u32 parent_tgid = get_parent_pid() >> 32; - u32 current_tgid = bpf_get_current_pid_tgid() >> 32; - - const char* filename = (const char*) BPF_CORE_READ(ctx, args[0]); - const char* const* argv = (const char* const*) BPF_CORE_READ(ctx, args[1]); - const char* const* envp = (const char* const*) BPF_CORE_READ(ctx, args[2]); - - // FOR TROUBLESHOOTING - // static char fn[255] = {0}; - // bpf_core_read_user_str(fn, sizeof(fn), filename); - // log_printk("sys_enter_execve: parent_tgid: %d, current_tgid: %d - %s", parent_tgid, current_tgid, fn); - - - struct connection *conn; - - - int zero = 0; - struct command* cmd = bpf_map_lookup_elem(&command_heap, &zero); - if (!cmd) - return 0; - - if ((conn = find_ancestor_connection()) == NULL) - return 1; - - cmd->filename[0] = '\0'; - cmd->start_time = bpf_ktime_get_ns(); - cmd->end_time = 0; - cmd->exit_code = -1; - cmd->parent_tgid = parent_tgid; - cmd->current_tgid = current_tgid; - cmd->stdout_offset = 0; - cmd->conn_tgid = conn->ptm_tgid; - cmd->stdout[0] = '\0'; - cmd->args[0] = '\0'; - - // Copy the "Command" from args[0] rather than filename - // because filename without path is bounded at 255 bytes - - char* arg0_ptr = NULL; - bpf_core_read_user(&arg0_ptr, sizeof(arg0_ptr), argv); - bpf_core_read_user_str(cmd->filename, sizeof(cmd->filename), arg0_ptr); - - - log_printk("sys_enter_execve: conn tgid: %d, tgid: %d - %s", conn->ptm_tgid, current_tgid, cmd->filename); - - - //bpf_core_read_str(cmd.filename, sizeof(cmd.filename), args->filename); - - - - - - // Read full filename and args data into map - u32 argoffset = 0; - - // Copy the filename first, this is the full path not just the filename from args - int bytes_read = argoffset = bpf_core_read_user_str(cmd->args, COMMAND_ARGS_MAX_BYTES, filename); - log_printk("args copied bytes %d - %s", bytes_read, filename); - argoffset = argoffset & COMMAND_ARGS_MAX_BYTES-1; - cmd->args[(argoffset -1) & COMMAND_ARGS_MAX_BYTES-1 ] = ' '; - - for (u32 i = 1; i < sizeof(argv); i++) - { - char *argv_p = NULL; - bpf_core_read_user(&argv_p, sizeof(argv_p), argv + i); - - if (!argv_p) - break; - - bytes_read = bpf_core_read_user_str(cmd->args+argoffset, COMMAND_ARGS_MAX_BYTES - argoffset, argv_p); - //int bytes_read = bpf_core_read_user_str(cmd.args, COMMAND_ARGS_MAX_BYTES , argv_p); - log_printk("args copied bytes %d - %s", bytes_read, argv_p); - - if (bytes_read > 0) - { - argoffset += bytes_read; - // Replace the '\0' with spaces between the args - cmd->args[(argoffset -1) & COMMAND_ARGS_MAX_BYTES-1 ] = ' '; - } - - // Prevent this value from being zero'd out when it is exactly max size - if (argoffset != COMMAND_ARGS_MAX_BYTES) - argoffset = argoffset & COMMAND_ARGS_MAX_BYTES-1; - - - } - // Finalize string with '\0' - cmd->args[(argoffset -1) & COMMAND_ARGS_MAX_BYTES-1 ] = '\0'; - log_printk("args full %d - %s", argoffset, cmd->args); - - - bpf_map_update_elem(&commands, ¤t_tgid, cmd, BPF_ANY); - - struct command_event* e = bpf_map_lookup_elem(&commandevent_heap, &zero); - if (!e) - return 0; - - // Command just started. Send event - e->event_type = SSHTRACE_EVENT_COMMAND_START; - e->ptm_pid = conn->ptm_tgid; - bpf_core_read(&e->cmd, sizeof(struct command), cmd); - - push_event(ctx, e, sizeof(struct command_event)); - - - return 0; +static int sys_enter_exec_common(struct trace_event_raw_sys_enter* ctx) { + // field:const char * filename; offset:16; size:8; signed:0; + // field:const char *const * argv; offset:24; size:8; signed:0; + // field:const char *const * envp; offset:32; size:8; signed:0; + + u32 parent_tgid = get_parent_pid() >> 32; + u32 current_tgid = bpf_get_current_pid_tgid() >> 32; + + struct connection* conn; + int zero = 0; + struct command* cmd = bpf_map_lookup_elem(&command_heap, &zero); + if (!cmd) + return 0; + + if ((conn = find_ancestor_connection()) == NULL) + return 1; + + cmd->filename[0] = '\0'; + cmd->start_time = bpf_ktime_get_ns(); + cmd->end_time = 0; + cmd->exit_code = -1; + cmd->parent_tgid = parent_tgid; + cmd->current_tgid = current_tgid; + cmd->stdout_offset = 0; + cmd->conn_tgid = conn->ptm_tgid; + cmd->stdout[0] = '\0'; + cmd->args[0] = '\0'; + + // Copy the "Command" from args[0] rather than filename + // because filename without path is bounded at 255 bytes + + const char* const* argv = (const char* const*) BPF_CORE_READ(ctx, args[1]); + char* arg0_ptr = NULL; + bpf_core_read_user(&arg0_ptr, sizeof(arg0_ptr), argv); + bpf_core_read_user_str(cmd->filename, sizeof(cmd->filename), arg0_ptr); + + const char* filename = (const char*) BPF_CORE_READ(ctx, args[0]); + int bytes_read = 0; + u32 argoffset = bpf_core_read_user_str(cmd->args, COMMAND_ARGS_MAX_BYTES, filename); + argoffset = argoffset & (COMMAND_ARGS_MAX_BYTES - 1); + cmd->args[(argoffset - 1) & (COMMAND_ARGS_MAX_BYTES - 1)] = ' '; + + for (u32 i = 1; i < sizeof(argv); i++) { + char* argv_p = NULL; + bpf_core_read_user(&argv_p, sizeof(argv_p), argv + i); + + if (!argv_p) + break; + + bytes_read = bpf_core_read_user_str(cmd->args + argoffset, COMMAND_ARGS_MAX_BYTES - argoffset, argv_p); + log_printk("args copied bytes %d - %s", bytes_read, argv_p); + + if (bytes_read > 0) { + argoffset += bytes_read; + // Replace the '\0' with spaces between the args + cmd->args[(argoffset - 1) & (COMMAND_ARGS_MAX_BYTES - 1)] = ' '; + } + + // Prevent this value from being zero'd out when it is exactly max size + if (argoffset != COMMAND_ARGS_MAX_BYTES) + argoffset = argoffset & (COMMAND_ARGS_MAX_BYTES - 1); + } + // Finalize string with '\0' + log_printk("args full %d - %s", argoffset, cmd->args); + cmd->args[(argoffset - 1) & (COMMAND_ARGS_MAX_BYTES - 1)] = '\0'; + + bpf_map_update_elem(&commands, ¤t_tgid, cmd, BPF_ANY); + + struct command_event* e = bpf_map_lookup_elem(&commandevent_heap, &zero); + if (!e) + return 0; + + // Command just started. Send event + e->event_type = SSHTRACE_EVENT_COMMAND_START; + e->ptm_pid = conn->ptm_tgid; + bpf_core_read(&e->cmd, sizeof(struct command), cmd); + + push_event(ctx, e, sizeof(struct command_event)); + + return 0; } SEC("tracepoint/syscalls/sys_enter_execveat") -int sys_enter_execveat(struct trace_event_raw_sys_enter* ctx) -{ - return sys_enter_exec_common(ctx); -} +int sys_enter_execveat(struct trace_event_raw_sys_enter* ctx) { return sys_enter_exec_common(ctx); } SEC("tracepoint/syscalls/sys_enter_execve") -int sys_enter_execve(struct trace_event_raw_sys_enter* ctx) -{ - return sys_enter_exec_common(ctx); -} - - +int sys_enter_execve(struct trace_event_raw_sys_enter* ctx) { return sys_enter_exec_common(ctx); } SEC("tracepoint/syscalls/sys_enter_exit_group") -int sys_enter_exit_group(struct trace_event_raw_sys_enter* ctx) -{ -// field:int error_code; offset:16; size:8; signed:0; +int sys_enter_exit_group(struct trace_event_raw_sys_enter* ctx) { + // field:int error_code; offset:16; size:8; signed:0; + + u32 error_code = (u32) BPF_CORE_READ(ctx, args[0]); + u32 current_tgid = bpf_get_current_pid_tgid() >> 32; - u32 error_code = (u32) BPF_CORE_READ(ctx, args[0]); - u32 current_tgid = bpf_get_current_pid_tgid() >> 32; - struct command *cmd; - struct connection *conn; + // Cleanup cache entry (auto-eviction handles LRU, but specific cleanup is nice) + bpf_map_delete_elem(&proc_cache, ¤t_tgid); - conn = bpf_map_lookup_elem(&connections, ¤t_tgid); - if (conn != NULL) { - conn->end_time= bpf_ktime_get_ns(); + struct command* cmd; + struct connection* conn; - log_printk("CONNECTION EVENT!!!!"); + conn = bpf_map_lookup_elem(&connections, ¤t_tgid); + if (conn != NULL) { + conn->end_time = bpf_ktime_get_ns(); + log_printk("CONNECTION EVENT!!!!"); - bpf_map_delete_elem(&connections, ¤t_tgid); - - // Connection was terminated, send event - int zero = 0; - struct connection_event* e = bpf_map_lookup_elem(&connectionevent_heap, &zero); - if (!e) - return 0; - e->event_type = SSHTRACE_EVENT_CLOSE_CONNECTION; - e->ptm_pid = conn->ptm_tgid; - e->conn = *conn; - push_event(ctx, e, sizeof(struct connection_event)); + bpf_map_delete_elem(&connections, ¤t_tgid); + // Connection was terminated, send event + int zero = 0; + struct connection_event* e = bpf_map_lookup_elem(&connectionevent_heap, &zero); + if (!e) + return 0; + e->event_type = SSHTRACE_EVENT_CLOSE_CONNECTION; + e->ptm_pid = conn->ptm_tgid; + e->conn = *conn; + push_event(ctx, e, sizeof(struct connection_event)); - return 0; - } + return 0; + } - cmd = bpf_map_lookup_elem(&commands, ¤t_tgid); - if (cmd != NULL) { - cmd->end_time = bpf_ktime_get_ns(); - cmd->exit_code = error_code; - log_printk("COMMAND EVENT!!!! conn tgid: %d, tgid: %d - %s", cmd->conn_tgid, current_tgid, cmd->filename); - log_printk("COMMAND EVENT!!!! %d %s", cmd->stdout_offset, cmd->stdout); + cmd = bpf_map_lookup_elem(&commands, ¤t_tgid); + if (cmd != NULL) { + cmd->end_time = bpf_ktime_get_ns(); + cmd->exit_code = error_code; + log_printk("COMMAND EVENT!!!! conn tgid: %d, tgid: %d - %s", cmd->conn_tgid, current_tgid, cmd->filename); + log_printk("COMMAND EVENT!!!! %d %s", cmd->stdout_offset, cmd->stdout); - bpf_map_delete_elem(&commands, ¤t_tgid); - - // Command just completed. Send event - int zero = 0; - struct command_event* e = bpf_map_lookup_elem(&commandevent_heap, &zero); - if (!e) - return 0; + bpf_map_delete_elem(&commands, ¤t_tgid); - e->event_type = SSHTRACE_EVENT_COMMAND_END; - e->ptm_pid = cmd->conn_tgid; - - bpf_core_read(&e->cmd, sizeof(struct command), cmd); + // Command just completed. Send event + int zero = 0; + struct command_event* e = bpf_map_lookup_elem(&commandevent_heap, &zero); + if (!e) + return 0; - push_event(ctx, e, sizeof(struct command_event)); + e->event_type = SSHTRACE_EVENT_COMMAND_END; + e->ptm_pid = cmd->conn_tgid; + bpf_core_read(&e->cmd, sizeof(struct command), cmd); - return 0; - } + push_event(ctx, e, sizeof(struct command_event)); + return 0; + } - return 1; + return 1; } - #define __max(a,b) \ - ({ __typeof__ (a) _a = (a); \ - __typeof__ (b) _b = (b); \ - _a > _b ? _a : _b; }) - -#define __min(a,b) \ -({ \ - __typeof__ (a) _a = (a); \ - __typeof__ (b) _b = (b); \ - _a < _b ? _a : _b; \ -}) +#define __max(a, b) \ + ({ \ + __typeof__(a) _a = (a); \ + __typeof__(b) _b = (b); \ + _a > _b ? _a : _b; \ + }) + +#define __min(a, b) \ + ({ \ + __typeof__(a) _a = (a); \ + __typeof__(b) _b = (b); \ + _a < _b ? _a : _b; \ + }) SEC("tracepoint/syscalls/sys_enter_write") -int sys_enter_write(struct trace_event_raw_sys_enter* ctx) -{ - // args documented here: https://mozillazg.com/2022/05/ebpf-libbpf-tracepoint-common-questions-en.html - // field:unsigned int fd; offset:16; size:8; signed:0; - // field:const char * buf; offset:24; size:8; signed:0; - // field:size_t count; offset:32; size:8; signed:0; - - - unsigned int fd = (uint32_t) BPF_CORE_READ(ctx, args[0]); +int sys_enter_write(struct trace_event_raw_sys_enter* ctx) { + // args documented here: https://mozillazg.com/2022/05/ebpf-libbpf-tracepoint-common-questions-en.html + // field:unsigned int fd; offset:16; size:8; signed:0; + // field:const char * buf; offset:24; size:8; signed:0; + // field:size_t count; offset:32; size:8; signed:0; + unsigned int fd = (uint32_t) BPF_CORE_READ(ctx, args[0]); - if (fd != STDOUT_FILENO && fd != STDERR_FILENO) - return 1; + if (fd != STDOUT_FILENO && fd != STDERR_FILENO) + return 1; + u32 current_tgid = bpf_get_current_pid_tgid() >> 32; + struct command* cmd; + cmd = bpf_map_lookup_elem(&commands, ¤t_tgid); - u32 current_tgid = bpf_get_current_pid_tgid() >> 32; - struct command *cmd; - cmd = bpf_map_lookup_elem(&commands, ¤t_tgid); - - if (cmd == NULL) - return 1; - - // We have a command. Let's see if we need to copy the buffer - if (cmd->stdout_offset >= STDOUT_MAX_BYTES) - { - // already collected max amount of bytes for this process - return 1; - } + if (cmd == NULL) + return 1; - const char* buf = (const char*) BPF_CORE_READ(ctx, args[1]); - size_t size = (size_t) BPF_CORE_READ(ctx, args[2]); + // We have a command. Let's see if we need to copy the buffer + if (cmd->stdout_offset >= STDOUT_MAX_BYTES) { + // already collected max amount of bytes for this process + return 1; + } + const char* buf = (const char*) BPF_CORE_READ(ctx, args[1]); + size_t size = (size_t) BPF_CORE_READ(ctx, args[2]); - int offset = cmd->stdout_offset; - offset = __max(offset, 0); - // Subtract one so that the final '\0' is not copied, since these buffers must append - int amount_to_write = __min(size, STDOUT_MAX_BYTES - offset); - amount_to_write = __max(amount_to_write, 0); + int offset = cmd->stdout_offset; + offset = __max(offset, 0); + // Subtract one so that the final '\0' is not copied, since these buffers must append + int amount_to_write = __min(size, STDOUT_MAX_BYTES - offset); + amount_to_write = __max(amount_to_write, 0); - if (amount_to_write == 0) - return 1; - - // Need this check to get pass the bpf verifier (otherwise it complains about unbounded memory access) - // Caveat is that STDOUT_MAX_BYTES MUST be a power of 2 - //if (proc_with_offset > proc_output && proc_with_offset < proc_output+amount_to_write) + if (amount_to_write == 0) + return 1; - offset = offset & STDOUT_MAX_BYTES-1; - char* proc_with_offset = cmd->stdout + offset; - - amount_to_write = amount_to_write & STDOUT_MAX_BYTES-1; + // Need this check to get pass the bpf verifier (otherwise it complains about unbounded memory access) + // Caveat is that STDOUT_MAX_BYTES MUST be a power of 2 + //if (proc_with_offset > proc_output && proc_with_offset < proc_output+amount_to_write) + offset = offset & (STDOUT_MAX_BYTES - 1); + char* proc_with_offset = cmd->stdout + offset; + amount_to_write = amount_to_write & (STDOUT_MAX_BYTES - 1); - //if (offset + amount_to_write < STDOUT_MAX_BYTES - 1 ) - bpf_core_read_user(proc_with_offset, amount_to_write, buf); + //if (offset + amount_to_write < STDOUT_MAX_BYTES - 1 ) + bpf_core_read_user(proc_with_offset, amount_to_write, buf); - // Apply a null termination at the end. If another write appends, this will get overwritten - cmd->stdout[offset+amount_to_write] = '\0'; + // Apply a null termination at the end. If another write appends, this will get overwritten + cmd->stdout[offset + amount_to_write] = '\0'; + log_printk("sys_enter_writex pid %d fd %d %s", current_tgid, fd, cmd->stdout + offset); + log_printk("sys_enter_write wrote %d bytes at offset %d to pid %d", amount_to_write, offset, current_tgid); - log_printk("sys_enter_writex pid %d fd %d %s", current_tgid, fd, cmd->stdout+offset); - log_printk("sys_enter_write wrote %d bytes at offset %d to pid %d", amount_to_write, offset, current_tgid); + cmd->stdout_offset += amount_to_write; - cmd->stdout_offset += amount_to_write; + //log_printk("sys_enter_write id: %d, fd: %d: %s", conn->ptm_tgid, fd, buf); - //log_printk("sys_enter_write id: %d, fd: %d: %s", conn->ptm_tgid, fd, buf); - - - return 0; + return 0; } -// Rate limit prevents a huge data spike of terminal data from one or more sessions -// (e.g., local ssh running find /) from blowing out the perf buffer. -static int is_rate_limited(void* ctx, struct connection* conn, int32_t new_bytes, u32 parent_tgid) -{ - // Break the rate limit down into 250ms increments so that it doesn't feel as jittery - // when rate limits hit - const int TIME_INTERVALS_PER_SECOND = 4; - - const int64_t NANOSECONDS_IN_A_SECOND = 1000000000; - int64_t cur_epoch_sec = bpf_ktime_get_ns() / (NANOSECONDS_IN_A_SECOND/TIME_INTERVALS_PER_SECOND); - if (cur_epoch_sec != conn->rate_limit_epoch_second ) - { - // We've entered a new second. Reset all the counters - conn->rate_limit_epoch_second = cur_epoch_sec; - conn->rate_limit_hit = false; - conn->rate_limit_total_bytes_this_second = 0; - } - - conn->rate_limit_total_bytes_this_second += new_bytes; - //log_printk("rate limit sec %d bytes %d", conn->rate_limit_epoch_second, conn->rate_limit_total_bytes_this_second); - if (conn->rate_limit_total_bytes_this_second > (RATE_LIMIT_MAX_BYTES_PER_SECOND/TIME_INTERVALS_PER_SECOND)) - { - // Rate limit. If limit has already been hit this second, just exit - // if not, send an event message back with the rate limit message - if (!conn->rate_limit_hit) - { - conn->rate_limit_hit = true; - log_printk("rate limit hit for conn %d", conn->ptm_tgid); - - int zero = 0; - struct terminal_update_event* e = bpf_map_lookup_elem(&terminalupdateevent_heap, &zero); - if (!e) - return 0; - - e->event_type = SSHTRACE_EVENT_TERMINAL_UPDATE; - e->ptm_pid = parent_tgid; - // Got to love the BPF verifier. Simple strcpy is not so easy - e->terminal_data[0] = '['; - e->terminal_data[1] = '['; - e->terminal_data[2] = 'S'; - e->terminal_data[3] = 'S'; - e->terminal_data[4] = 'H'; - e->terminal_data[5] = 'B'; - e->terminal_data[6] = 'o'; - e->terminal_data[7] = 'u'; - e->terminal_data[8] = 'n'; - e->terminal_data[9] = 'c'; - e->terminal_data[10] = 'e'; - e->terminal_data[11] = 'r'; - e->terminal_data[12] = ' '; - e->terminal_data[13] = 'R'; - e->terminal_data[14] = 'a'; - e->terminal_data[15] = 't'; - e->terminal_data[16] = 'e'; - e->terminal_data[17] = '/'; - e->terminal_data[18] = 's'; - e->terminal_data[19] = 'e'; - e->terminal_data[20] = 'c'; - e->terminal_data[21] = ' '; - e->terminal_data[22] = 'R'; - e->terminal_data[23] = 'e'; - e->terminal_data[24] = 'a'; - e->terminal_data[25] = 'c'; - e->terminal_data[26] = 'h'; - e->terminal_data[27] = 'e'; - e->terminal_data[28] = 'd'; - e->terminal_data[29] = ']'; - e->terminal_data[30] = ']'; - e->terminal_data[31] = '\r'; - e->terminal_data[32] = '\n'; - e->terminal_data[33] = '\0'; - e->data_len = 34; - - push_event(ctx, e, sizeof(struct terminal_update_event)); - } - return 1; - } - return 0; +// Rate limit prevents a huge data spike of terminal data from one or more sessions +// (e.g., local ssh running find /) from blowing out the perf buffer. +static int is_rate_limited(void* ctx, struct connection* conn, int32_t new_bytes, u32 parent_tgid) { + // Break the rate limit down into 250ms increments so that it doesn't feel as jittery + // when rate limits hit + const int TIME_INTERVALS_PER_SECOND = 4; + + const int64_t NANOSECONDS_IN_A_SECOND = 1000000000; + int64_t cur_epoch_sec = bpf_ktime_get_ns() / (NANOSECONDS_IN_A_SECOND / TIME_INTERVALS_PER_SECOND); + if (cur_epoch_sec != conn->rate_limit_epoch_second) { + // We've entered a new second. Reset all the counters + conn->rate_limit_epoch_second = cur_epoch_sec; + conn->rate_limit_hit = false; + conn->rate_limit_total_bytes_this_second = 0; + // Reset IOPS counter + conn->rate_limit_events_this_second = 0; + } + + conn->rate_limit_total_bytes_this_second += new_bytes; + conn->rate_limit_events_this_second++; + + // 1. Check Bandwidth + if (conn->rate_limit_total_bytes_this_second > (RATE_LIMIT_MAX_BYTES_PER_SECOND / TIME_INTERVALS_PER_SECOND)) { + // Rate limit. If limit has already been hit this second, just exit + // if not, send an event message back with the rate limit message + if (!conn->rate_limit_hit) { + conn->rate_limit_hit = true; + log_printk("rate limit hit for conn %d", conn->ptm_tgid); + + int zero = 0; + struct terminal_update_event* e = bpf_map_lookup_elem(&terminalupdateevent_heap, &zero); + if (!e) + return 0; + + e->event_type = SSHTRACE_EVENT_TERMINAL_UPDATE; + e->ptm_pid = parent_tgid; + char msg[] = "[[SSHLog Rate/sec Reached]]\r\n"; + __builtin_memcpy(e->terminal_data, msg, sizeof(msg)); + e->data_len = sizeof(msg); + push_event(ctx, e, sizeof(struct terminal_update_event)); + } + return 1; + } + + // 2. IOPS Limiter (Stops the Event Flood CPU Spike) + if (conn->rate_limit_events_this_second > RATE_LIMIT_MAX_EVENTS_PER_SECOND) { + if (!conn->rate_limit_hit) { + conn->rate_limit_hit = true; + } + return 1; + } + + return 0; } SEC("tracepoint/syscalls/sys_enter_read") -int sys_enter_read(struct trace_event_raw_sys_enter* ctx) -{ - - // field:unsigned int fd; offset:16; size:8; signed:0; - // field:char * buf; offset:24; size:8; signed:0; - // field:size_t count; offset:32; size:8; signed:0; - - u32 fd = (uint32_t) BPF_CORE_READ(ctx, args[0]); - - // Quick/efficient check to bounce out early without having to lookup connection ID - // We will never be reading non SSHD process nor stdin/stdout/stderr - if (fd == STDERR_FILENO || fd == STDOUT_FILENO || fd == STDIN_FILENO || !proc_is_sshd()) - return 1; +int sys_enter_read(struct trace_event_raw_sys_enter* ctx) { - // We want to catch the pts process which pipes all reads out to the /dev/ptsx fd - u32 parent_tgid = get_parent_pid() >> 32; + // field:unsigned int fd; offset:16; size:8; signed:0; + // field:char * buf; offset:24; size:8; signed:0; + // field:size_t count; offset:32; size:8; signed:0; - - struct connection* conn = bpf_map_lookup_elem(&connections, &parent_tgid); + u32 fd = (uint32_t) BPF_CORE_READ(ctx, args[0]); - if (conn != NULL) - { - if (conn->pts_fd == fd || conn->pts_fd2 == fd || conn->pts_fd3 == fd) - { - // Check if this should be rate limited - if (is_rate_limited(ctx, conn, 0, parent_tgid)) { - return 0; - } - //const char* buf = (const char*) BPF_CORE_READ(ctx, args[1]); - size_t size = (size_t) BPF_CORE_READ(ctx, args[2]); + // Fast cheap integer checks first + if (fd == STDERR_FILENO || fd == STDOUT_FILENO || fd == STDIN_FILENO) + return 1; - struct read_buffer_map readmap = {0}; - readmap.fd = fd; - readmap.data_ptr = (void*) ctx->args[1]; + // If it's NOT SSHD (based on cache), we exit immediately. + if (!is_sshd_process()) + return 1; - bpf_map_update_elem(&connections_read_mapping, &parent_tgid, &readmap, BPF_ANY); + // If we are here, we are an SSHD process. Safe to do the heavy logic. + u32 parent_tgid = get_parent_pid() >> 32; + struct connection* conn = bpf_map_lookup_elem(&connections, &parent_tgid); + if (conn != NULL) { + if (conn->pts_fd == fd || conn->pts_fd2 == fd || conn->pts_fd3 == fd) { + // Check if this should be rate limited + if (is_rate_limited(ctx, conn, 0, parent_tgid)) { + return 0; + } + struct read_buffer_map readmap = {0}; + readmap.fd = fd; + readmap.data_ptr = (void*) ctx->args[1]; - } - } + bpf_map_update_elem(&connections_read_mapping, &parent_tgid, &readmap, BPF_ANY); + } + } - - return 0; + return 0; } - SEC("tracepoint/syscalls/sys_exit_read") -int sys_exit_read(struct trace_event_raw_sys_exit* ctx) -{ - - // Quick/efficient check to bounce out early without having to lookup connection ID - // We will never be reading non SSHD process nor stdin/stdout/stderr - if (!proc_is_sshd()) - return 1; - - int32_t ret = (int32_t) BPF_CORE_READ(ctx, ret); - - - // We want to catch the pts process which pipes all reads out to the /dev/ptsx fd - u32 parent_tgid = get_parent_pid() >> 32; - - struct read_buffer_map* readmap = bpf_map_lookup_elem(&connections_read_mapping, &parent_tgid); - - if (readmap != NULL && readmap->data_ptr != NULL && ret > 0) - { - - struct connection* conn = bpf_map_lookup_elem(&connections, &parent_tgid); - if (conn == NULL) - return 0; - // Check if this should be rate limited - if (is_rate_limited(ctx, conn, ret, parent_tgid)) { - return 0; - } - - int zero = 0; - struct terminal_update_event* e = bpf_map_lookup_elem(&terminalupdateevent_heap, &zero); - if (!e) - return 0; - - e->event_type = SSHTRACE_EVENT_TERMINAL_UPDATE; - e->data_len = ret; - e->ptm_pid = parent_tgid; - - - //static char read_buffer[CONNECTION_READ_BUFFER_BYTES] = {0}; - // Not a CORE read, but I think it is ok because readmap struct is defined in this code - bpf_probe_read_user(e->terminal_data, ret & (CONNECTION_READ_BUFFER_BYTES-1), readmap->data_ptr); - - // Set the last char of the string to 0 in case it wasn't set. - e->terminal_data[ret & (CONNECTION_READ_BUFFER_BYTES-1)] = '\0'; - log_printk("sys_enter_readz exit ret %d %s", ret, e->terminal_data); - - bpf_map_delete_elem(&connections_read_mapping, &parent_tgid); - // e.pts_pid = pid_tgid; - // e.bash_pid = child_tgid; - // e.ptm_pid = get_parent_pid() >> 32; - push_event(ctx, e, sizeof(struct terminal_update_event)); - - } - return 0; +int sys_exit_read(struct trace_event_raw_sys_exit* ctx) { + // Fast Cache Check + if (!is_sshd_process()) + return 1; + + int32_t ret = (int32_t) BPF_CORE_READ(ctx, ret); + + // We want to catch the pts process which pipes all reads out to the /dev/ptsx fd + u32 parent_tgid = get_parent_pid() >> 32; + + struct read_buffer_map* readmap = bpf_map_lookup_elem(&connections_read_mapping, &parent_tgid); + + if (readmap != NULL && readmap->data_ptr != NULL && ret > 0) { + + struct connection* conn = bpf_map_lookup_elem(&connections, &parent_tgid); + if (conn == NULL) + return 0; + // Check if this should be rate limited + if (is_rate_limited(ctx, conn, ret, parent_tgid)) { + return 0; + } + + int zero = 0; + struct terminal_update_event* e = bpf_map_lookup_elem(&terminalupdateevent_heap, &zero); + if (!e) + return 0; + + e->event_type = SSHTRACE_EVENT_TERMINAL_UPDATE; + e->data_len = ret; + e->ptm_pid = parent_tgid; + + // Not a CORE read, but I think it is ok because readmap struct is defined in this code + bpf_probe_read_user(e->terminal_data, ret & (CONNECTION_READ_BUFFER_BYTES - 1), readmap->data_ptr); + + // Set the last char of the string to 0 in case it wasn't set. + e->terminal_data[ret & (CONNECTION_READ_BUFFER_BYTES - 1)] = '\0'; + log_printk("sys_enter_readz exit ret %d %s", ret, e->terminal_data); + + bpf_map_delete_elem(&connections_read_mapping, &parent_tgid); + // e.pts_pid = pid_tgid; + // e.bash_pid = child_tgid; + // e.ptm_pid = get_parent_pid() >> 32; + push_event(ctx, e, sizeof(struct terminal_update_event)); + } + return 0; } \ No newline at end of file diff --git a/libsshlog/bpf/sshtrace_types.h b/libsshlog/bpf/sshtrace_types.h index f96f8a3..b31ecbc 100644 --- a/libsshlog/bpf/sshtrace_types.h +++ b/libsshlog/bpf/sshtrace_types.h @@ -25,6 +25,16 @@ #ifndef __clang__ // Only include this for outside BPF code. BPF code compiles w/ clang #include +#else +// FIX: BPF (Clang) doesn't have stdint.h included, so we must define these manually. +// Assuming vmlinux.h or similar provides basic C types, or standard primitives: +typedef unsigned int uint32_t; +typedef int int32_t; +typedef unsigned short uint16_t; +typedef short int16_t; +typedef unsigned long long uint64_t; +typedef long long int64_t; +typedef _Bool bool; #endif // Read buffer bytes must be a power of 2 @@ -35,6 +45,7 @@ #define USERNAME_MAX_LENGTH 32 #define RATE_LIMIT_MAX_BYTES_PER_SECOND 1024000 +#define RATE_LIMIT_MAX_EVENTS_PER_SECOND 1000 struct tcpinfo { uint32_t server_ip; @@ -70,6 +81,7 @@ struct connection { int64_t rate_limit_epoch_second; bool rate_limit_hit; int64_t rate_limit_total_bytes_this_second; + uint64_t rate_limit_events_this_second; }; // Must be a power of 2 diff --git a/libsshlog/bpftool b/libsshlog/bpftool deleted file mode 160000 index 259f40f..0000000 --- a/libsshlog/bpftool +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 259f40fd5bde67025beb1371d269c91bbe5d42ed diff --git a/libsshlog/libbpf b/libsshlog/libbpf index f8cd00f..45e8934 160000 --- a/libsshlog/libbpf +++ b/libsshlog/libbpf @@ -1 +1 @@ -Subproject commit f8cd00f61302ca4d8645e007acf7f546dc323b33 +Subproject commit 45e89348ec74617c11cd5241ccd0ffc91dfd03c4 diff --git a/libsshlog/proc_parsers/existing_connections.cpp b/libsshlog/proc_parsers/existing_connections.cpp index ca44fca..a9124f6 100644 --- a/libsshlog/proc_parsers/existing_connections.cpp +++ b/libsshlog/proc_parsers/existing_connections.cpp @@ -13,8 +13,8 @@ namespace sshlog { #define SSHD_DEFAULT_PORT 22 -#define SSHD_PROCESS_NAME "sshd" +static bool is_sshd_process(const std::string& comm); static bool is_pts(pfs::task& process, std::unordered_map processes); static int discover_sshd_listen_port(pfs::procfs& pfs, pfs::task& sshd_process); @@ -24,104 +24,121 @@ ExistingConnections::ExistingConnections() { // Build a list of all processes in a hashmap indexed by process ID std::unordered_map processes; - for (const auto& process : pfs.get_processes()) { - processes.insert({process.id(), process}); + + try { + for (const auto& process : pfs.get_processes()) { + processes.insert({process.id(), process}); + } + } catch (const std::exception& e) { + PLOG_ERROR << "Failed to list processes: " << e.what(); + return; } int sshd_port = SSHD_DEFAULT_PORT; + // Find all "sshd" processes for (const std::pair& proc_data : processes) { pfs::task process = proc_data.second; - if (process.get_comm() != SSHD_PROCESS_NAME) - continue; + // Processes (especially short-lived ones from exec_stress) can exit + // after we list them but before we inspect them. + // We must catch exceptions here to prevent the agent from crashing. + try { + if (!is_sshd_process(process.get_comm())) + continue; - bool is_pts_target = is_pts(process, processes); - int ppid = process.get_stat().ppid; + bool is_pts_target = is_pts(process, processes); + int ppid = process.get_stat().ppid; - if (ppid == 1) { - // This is our sshd process, try and determine the port - sshd_port = discover_sshd_listen_port(pfs, process); - } + if (ppid == 1) { + // This is our sshd process, try and determine the port + sshd_port = discover_sshd_listen_port(pfs, process); + } - if (is_pts_target) { - struct ssh_session session; - - // I am the pts process, so find the parent (ptm) and child (bash) - // process - session.pts_pid = process.id(); - session.ptm_pid = ppid; - session.bash_pid = SSH_SESSION_UNKNOWN; - session.client_ip = ""; - session.client_port = SSH_SESSION_UNKNOWN; - session.server_ip = ""; - session.server_port = SSH_SESSION_UNKNOWN; - - // Convert process start time to CLOCK_MONOTONIC time - // to match what comes out of BPF - // Take process start time (measured in "jiffies" since bootup, and convert to nanoseconds) - // BPF is using nanos since boot - const int64_t NANOS_IN_A_SEC = 1000000000; - // https://unix.stackexchange.com/questions/62154/when-was-a-process-started - static int64_t JIFFIES_PER_SECOND = sysconf(_SC_CLK_TCK); - - int64_t proc_start_seconds_after_boot = process.get_stat().starttime / JIFFIES_PER_SECOND; - int64_t proc_start_nanos_after_boot = proc_start_seconds_after_boot * NANOS_IN_A_SEC; - - session.start_time = proc_start_nanos_after_boot; - // Requires an adjustment to convert from BOOTTIME to MONOTONIC time so that it matches what ebpf spits out - // ebpf spits out monotonic time (which does not include sleep/suspend time) whereas stat is using boottime - - const int64_t MILLIS_IN_A_SEC = 1000; - const int64_t NANOS_IN_A_MILLIS = 1000000; - struct timespec ts_bt, ts_mt; - clock_gettime(CLOCK_MONOTONIC, &ts_mt); - clock_gettime(CLOCK_BOOTTIME, &ts_bt); - - int64_t boottime_diff = - (ts_bt.tv_sec - ts_mt.tv_sec) * MILLIS_IN_A_SEC + (ts_bt.tv_nsec - ts_mt.tv_nsec) / NANOS_IN_A_MILLIS; - PLOG_DEBUG << "existing connection millisecond adjustment: " << boottime_diff; - - session.start_time -= (boottime_diff * NANOS_IN_A_MILLIS); - - session.user_id = process.get_status().uid.real; - - // Iterate the processes and stop at the first process that lists - // ptm_pid as its parent - for (const std::pair& child_proc_data : processes) { - pfs::task child_proc = child_proc_data.second; - try { - int child_parent = child_proc.get_stat().ppid; - if (child_parent == session.pts_pid) { - session.bash_pid = child_proc.id(); - break; + if (is_pts_target) { + struct ssh_session session; + + // I am the pts process, so find the parent (ptm) and child (bash) + // process + session.pts_pid = process.id(); + session.ptm_pid = ppid; + session.bash_pid = SSH_SESSION_UNKNOWN; + session.client_ip = ""; + session.client_port = SSH_SESSION_UNKNOWN; + session.server_ip = ""; + session.server_port = SSH_SESSION_UNKNOWN; + + // Convert process start time to CLOCK_MONOTONIC time + const int64_t NANOS_IN_A_SEC = 1000000000; + static int64_t JIFFIES_PER_SECOND = sysconf(_SC_CLK_TCK); + + int64_t proc_start_seconds_after_boot = process.get_stat().starttime / JIFFIES_PER_SECOND; + int64_t proc_start_nanos_after_boot = proc_start_seconds_after_boot * NANOS_IN_A_SEC; + + session.start_time = proc_start_nanos_after_boot; + + const int64_t MILLIS_IN_A_SEC = 1000; + const int64_t NANOS_IN_A_MILLIS = 1000000; + struct timespec ts_bt, ts_mt; + clock_gettime(CLOCK_MONOTONIC, &ts_mt); + clock_gettime(CLOCK_BOOTTIME, &ts_bt); + + int64_t boottime_diff = + (ts_bt.tv_sec - ts_mt.tv_sec) * MILLIS_IN_A_SEC + (ts_bt.tv_nsec - ts_mt.tv_nsec) / NANOS_IN_A_MILLIS; + // PLOG_DEBUG << "existing connection millisecond adjustment: " << + // boottime_diff; + + session.start_time -= (boottime_diff * NANOS_IN_A_MILLIS); + + session.user_id = process.get_status().uid.real; + + // Iterate the processes and stop at the first process that lists + // ptm_pid as its parent + for (const std::pair& child_proc_data : processes) { + pfs::task child_proc = child_proc_data.second; + try { + int child_parent = child_proc.get_stat().ppid; + if (child_parent == session.pts_pid) { + session.bash_pid = child_proc.id(); + break; + } + } catch (const std::exception& e) { + // Child process gone? ignore. } - } catch (const std::runtime_error& e) { } - } - // Find the socket/IP information for this process - // First, find the sockets (inode) IDs from /proc/[pid]/fd/ - - std::unordered_map proc_sockets_by_inode; - for (const std::pair& fd_data : process.get_fds()) { - struct stat st = fd_data.second.get_target_stat(); - if (S_ISSOCK(st.st_mode)) { - proc_sockets_by_inode.insert({st.st_ino, process}); + // Find the socket/IP information for this process + std::unordered_map proc_sockets_by_inode; + + // get_fds() can throw if process is gone + for (const std::pair& fd_data : process.get_fds()) { + try { + struct stat st = fd_data.second.get_target_stat(); + if (S_ISSOCK(st.st_mode)) { + proc_sockets_by_inode.insert({st.st_ino, process}); + } + } catch (...) { + continue; + } } - } - for (auto& net : pfs.get_net().get_tcp()) { - if (net.local_port == sshd_port && proc_sockets_by_inode.find(net.inode) != proc_sockets_by_inode.end()) { + for (auto& net : pfs.get_net().get_tcp()) { + if (net.local_port == sshd_port && proc_sockets_by_inode.find(net.inode) != proc_sockets_by_inode.end()) { - session.client_ip = net.remote_ip.to_string(); - session.server_ip = net.local_ip.to_string(); - session.client_port = net.remote_port; - session.server_port = net.local_port; + session.client_ip = net.remote_ip.to_string(); + session.server_ip = net.local_ip.to_string(); + session.client_port = net.remote_port; + session.server_port = net.local_port; + } } - } - this->sessions.push_back(session); + this->sessions.push_back(session); + } + } catch (const std::exception& e) { + // Process likely exited during inspection. Use DEBUG log so we don't spam + // errors during stress tests. + PLOG_DEBUG << "Process vanished during inspection (race condition): " << e.what(); + continue; } } @@ -137,55 +154,78 @@ ExistingConnections::~ExistingConnections() {} std::vector ExistingConnections::get_sessions() { return this->sessions; } static bool is_pts(pfs::task& process, std::unordered_map processes) { - // Given an sshd process, check to see if it's the pts side, ptm side, or the - // root sshd proc The 3 processes are generally decendents of each other + try { + // Make sure target proc is sshd + if (!is_sshd_process(process.get_comm())) + return false; - // Make sure target proc is sshd - if (process.get_comm() != SSHD_PROCESS_NAME) - return false; + // Make sure he has a parent + int ppid = process.get_stat().ppid; + if (ppid == 1) + return false; + if (processes.find(ppid) == processes.end()) + return false; - // Make sure he has a parent - int ppid = process.get_stat().ppid; - if (ppid == 1) - return false; - if (processes.find(ppid) == processes.end()) - return false; + // Parent should also be "sshd" or "sshd-session" + pfs::task parent = processes.at(ppid); - // Parent should also be "sshd" - pfs::task parent = processes.at(ppid); - int parent_ppid = parent.get_stat().ppid; - if (parent.get_comm() != SSHD_PROCESS_NAME || parent_ppid == 1) - return false; + // Check parent details (can throw if parent died) + int parent_ppid = parent.get_stat().ppid; + if (!is_sshd_process(parent.get_comm()) || parent_ppid == 1) + return false; - if (processes.find(parent_ppid) == processes.end()) - return false; + if (processes.find(parent_ppid) == processes.end()) + return false; - // Grandparent should exist, and it's PPID should be 1 - pfs::task grandparent = processes.at(parent_ppid); - int grandparent_ppid = grandparent.get_stat().ppid; - if (grandparent.get_comm() != SSHD_PROCESS_NAME || grandparent_ppid != 1) - return false; + // Grandparent should exist + pfs::task grandparent = processes.at(parent_ppid); + int grandparent_ppid = grandparent.get_stat().ppid; - return true; -} + if (!is_sshd_process(grandparent.get_comm())) + return false; -static int discover_sshd_listen_port(pfs::procfs& pfs, pfs::task& sshd_process) { - int test = 1; + if (grandparent_ppid == 1) + return true; - std::unordered_map proc_sockets_by_inode; - for (const std::pair& fd_data : sshd_process.get_fds()) { - struct stat st = fd_data.second.get_target_stat(); - if (S_ISSOCK(st.st_mode)) { - proc_sockets_by_inode.insert({st.st_ino, sshd_process}); + if (processes.find(grandparent_ppid) != processes.end()) { + pfs::task great_grandparent = processes.at(grandparent_ppid); + if (!is_sshd_process(great_grandparent.get_comm())) + return true; } + + return false; + } catch (...) { + // Any failure to read proc files means this isn't a stable SSH session we + // can inspect + return false; } +} - for (auto& net : pfs.get_net().get_tcp()) { - if (proc_sockets_by_inode.find(net.inode) != proc_sockets_by_inode.end() && - (net.socket_net_state == pfs::net_socket::net_state::listen)) { +static bool is_sshd_process(const std::string& comm) { return comm == "sshd" || comm == "sshd-session"; } - return net.local_port; +static int discover_sshd_listen_port(pfs::procfs& pfs, pfs::task& sshd_process) { + try { + std::unordered_map proc_sockets_by_inode; + for (const std::pair& fd_data : sshd_process.get_fds()) { + try { + struct stat st = fd_data.second.get_target_stat(); + if (S_ISSOCK(st.st_mode)) { + proc_sockets_by_inode.insert({st.st_ino, sshd_process}); + } + } catch (...) { + continue; + } + } + + for (auto& net : pfs.get_net().get_tcp()) { + if (proc_sockets_by_inode.find(net.inode) != proc_sockets_by_inode.end() && + (net.socket_net_state == pfs::net_socket::net_state::listen)) { + + return net.local_port; + } } + } catch (...) { + // Return default if inspection fails } // If we couldn't determine it, return default port (22) diff --git a/libsshlog/proc_parsers/pts_parser.cpp b/libsshlog/proc_parsers/pts_parser.cpp index edf3c5f..f8d6aed 100644 --- a/libsshlog/proc_parsers/pts_parser.cpp +++ b/libsshlog/proc_parsers/pts_parser.cpp @@ -137,8 +137,10 @@ void PtsParser::find_user_id(int32_t pid) { } static std::string getUser(uid_t uid) { - struct passwd* pws; - pws = getpwuid(uid); + struct passwd* pws = getpwuid(uid); + if (pws == nullptr) + return std::to_string(uid); + return pws->pw_name; } diff --git a/libsshlog/sshtrace_wrapper.cpp b/libsshlog/sshtrace_wrapper.cpp index eb540dd..2ed47e3 100644 --- a/libsshlog/sshtrace_wrapper.cpp +++ b/libsshlog/sshtrace_wrapper.cpp @@ -166,18 +166,19 @@ char* SSHTraceWrapper::poll(int timeout_ms) { q.enqueue(json_data); } - const int MICROSEC_IN_A_MILLISEC = 1000; char* obj; - bool success = q.wait_dequeue_timed(obj, timeout_ms * MICROSEC_IN_A_MILLISEC); + + // Check queue once. Non-blocking. + bool success = q.try_dequeue(obj); if (!success) { - PLOG_VERBOSE << "Polling for data..."; + // If the queue is empty, we MUST sleep to yield the CPU. + // 10ms is a good balance for a log agent (unnoticeable lag, near 0% CPU). + std::this_thread::sleep_for(std::chrono::milliseconds(10)); return nullptr; } return obj; - // char *themem = (char *)malloc(100); - // return themem; } void SSHTraceWrapper::queue_event(void* event_struct) { diff --git a/libsshlog/tests/CMakeLists.txt b/libsshlog/tests/CMakeLists.txt new file mode 100644 index 0000000..e1796ab --- /dev/null +++ b/libsshlog/tests/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.14) + +# 1. Fetch Catch2 (Standard Modern Approach) + +CPMAddPackage("gh:catchorg/Catch2#v3.12.0") + +# 2. Define the Test Executable +add_executable(integration_tests integration_tests.cpp) + +# 3. Link dependencies +target_link_libraries(integration_tests + PRIVATE + sshlog + Catch2::Catch2WithMain + yyjson +) +target_include_directories(integration_tests PRIVATE ../) + +# 4. Register with CTest +include(CTest) +add_test(NAME IntegrationTests COMMAND integration_tests) \ No newline at end of file diff --git a/libsshlog/tests/integration_tests.cpp b/libsshlog/tests/integration_tests.cpp new file mode 100644 index 0000000..c94e629 --- /dev/null +++ b/libsshlog/tests/integration_tests.cpp @@ -0,0 +1,425 @@ +#define CATCH_CONFIG_MAIN +#include + +#include "sshlog.h" +#include "yyjson.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --- CONFIGURATION --- +const std::string SSH_HOST = "127.0.0.1"; +const std::string SSH_USER = "mhill"; +const std::string SSH_KEY = "/home/mhill/.ssh/id_rsa"; +const std::string SSH_ARGS = "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; + +// --- HELPER: UUID GENERATOR --- +std::string generate_uuid() { + static int i = 0; + return "token_" + std::to_string(time(NULL)) + "_" + std::to_string(i++); +} + +struct JsonView { + yyjson_val* node; + JsonView(yyjson_val* v) : node(v) {} + + // --- Navigation --- + JsonView operator[](const char* key) const { return JsonView(yyjson_obj_get(node, key)); } + + // --- String Comparison --- + bool operator==(const std::string& expected) const { + return node && yyjson_is_str(node) && std::string(yyjson_get_str(node)) == expected; + } + + bool operator!=(const std::string& expected) const { return !(*this == expected); } + + bool contains(const std::string& substring) const { + if (!node || !yyjson_is_str(node)) + return false; + std::string val = yyjson_get_str(node); + return val.find(substring) != std::string::npos; + } + + // --- Integer Comparison Logic (The Core Fix) --- + // We strictly compare int64_t vs uint64_t to avoid wrap-around bugs. + + bool operator==(int64_t expected) const { + if (!node) + return false; + if (yyjson_is_int(node)) + return yyjson_get_int(node) == expected; + if (yyjson_is_uint(node)) { + if (expected < 0) + return false; // Unsigned JSON can't equal negative C++ int + return yyjson_get_uint(node) == (uint64_t) expected; + } + return false; + } + + bool operator!=(int64_t expected) const { return !(*this == expected); } + + bool operator<(int64_t expected) const { + if (!node) + return false; + if (yyjson_is_int(node)) + return yyjson_get_int(node) < expected; + if (yyjson_is_uint(node)) { + if (expected < 0) + return false; // Unsigned (pos) can never be less than negative + return yyjson_get_uint(node) < (uint64_t) expected; + } + return false; + } + + bool operator>(int64_t expected) const { + if (!node) + return false; + if (yyjson_is_int(node)) + return yyjson_get_int(node) > expected; + if (yyjson_is_uint(node)) { + if (expected < 0) + return true; // Unsigned (pos) is always greater than negative + return yyjson_get_uint(node) > (uint64_t) expected; + } + return false; + } + + bool operator<=(int64_t expected) const { return !(*this > expected); } + bool operator>=(int64_t expected) const { return !(*this < expected); } + + // Overloads for plain 'int' to resolve ambiguity + bool operator==(int v) const { return *this == (int64_t) v; } + bool operator!=(int v) const { return *this != (int64_t) v; } + bool operator<(int v) const { return *this < (int64_t) v; } + bool operator>(int v) const { return *this > (int64_t) v; } + bool operator<=(int v) const { return *this <= (int64_t) v; } + bool operator>=(int v) const { return *this >= (int64_t) v; } + + // --- Existence Check --- + operator bool() const { return node != nullptr; } + // Helper to extract string value + std::string get_string() const { + if (node && yyjson_is_str(node)) + return yyjson_get_str(node); + return ""; + } + + // Helper to extract int value + int64_t get_int() const { + if (node && yyjson_is_int(node)) + return yyjson_get_int(node); + return 0; + } +}; + +// --- LOG BUFFER --- +class LogBuffer { + private: + std::vector logs; + std::mutex mtx; + + public: + void add(const char* json) { + std::lock_guard lock(mtx); + logs.emplace_back(json); + } + + size_t size() { + std::lock_guard lock(mtx); + return logs.size(); + } + + void clear() { + std::lock_guard lock(mtx); + logs.clear(); + } + + std::vector get_snapshot() { + std::lock_guard lock(mtx); + return logs; + } +}; + +// --- DAEMON WRAPPER --- +class SSHLogDaemon { + private: + SSHLOG* ctx = nullptr; + std::thread worker; + std::atomic stop_flag{false}; + + public: + LogBuffer captured_logs; + + SSHLogDaemon() { + if (geteuid() != 0) { + std::cerr << "[-] Must run as root for BPF." << std::endl; + exit(1); + } + + sshlog_options opts = sshlog_get_default_options(); + opts.log_level = SSHLOG_LOG_LEVEL::LOG_WARNING; + + ctx = sshlog_init(&opts); + if (!ctx) { + std::cerr << "[-] Failed to init sshlog!" << std::endl; + exit(1); + } + + worker = std::thread([this]() { + while (!stop_flag && sshlog_is_ok(ctx) == 0) { + char* json = sshlog_event_poll(ctx, 50); + if (json) { + captured_logs.add(json); + sshlog_event_release(json); + } + } + }); + + // Reduced timeout: 1 second should be enough for BPF to load on most systems + std::this_thread::sleep_for(std::chrono::seconds(1)); + + // 2. DRAIN: Wipe the buffer clean. + // This ensures tests don't accidentally match stale events from startup. + std::cout << "[Setup] Draining " << captured_logs.size() << " initial startup events..." << std::endl; + captured_logs.clear(); + } + + ~SSHLogDaemon() { + stop_flag = true; + if (worker.joinable()) + worker.join(); + sshlog_release(ctx); + } +}; + +static SSHLogDaemon* daemon_ptr = nullptr; + +// --- WAIT HELPER --- +using JsonMatcher = std::function; + +bool wait_for_event(JsonMatcher matcher, int timeout_sec = 5) { + auto start = std::chrono::steady_clock::now(); + size_t processed_count = 0; + + while (true) { + auto logs = daemon_ptr->captured_logs.get_snapshot(); + + for (size_t i = processed_count; i < logs.size(); ++i) { + const std::string& log_line = logs[i]; + + yyjson_doc* doc = yyjson_read(log_line.c_str(), log_line.size(), 0); + if (doc) { + JsonView root(yyjson_doc_get_root(doc)); + bool matched = matcher(root); + yyjson_doc_free(doc); + + if (matched) { + // REQUIREMENT: Print the matching JSON + std::cout << "\n[MATCH FOUND]: " << log_line << std::endl; + return true; + } + } + } + processed_count = logs.size(); + + auto now = std::chrono::steady_clock::now(); + if (std::chrono::duration_cast(now - start).count() > timeout_sec) { + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + return false; +} + +// --- TESTS --- + +TEST_CASE("SSHLog Library Integration (Strict JSON)", "[lib]") { + static SSHLogDaemon daemon; + daemon_ptr = &daemon; + + SECTION("New Connection Detection") { + INFO("Connecting to " << SSH_HOST); + std::string cmd = "ssh " + SSH_ARGS + " -i " + SSH_KEY + " " + SSH_USER + "@" + SSH_HOST + " 'exit'"; + std::system(cmd.c_str()); + + INFO("Parsing JSON for event_type: 'connection_new'"); + + bool found = wait_for_event([](JsonView json) { return json["event_type"] == "connection_new"; }); + CHECK(found == true); + + found = wait_for_event([](JsonView json) { return json["event_type"] == "connection_established"; }); + CHECK(found == true); + + found = wait_for_event([](JsonView json) { return json["event_type"] == "connection_close"; }); + CHECK(found == true); + } + + SECTION("Command Execution Tracking") { + std::string token = generate_uuid(); + std::string cmd_str = "ls " + token; + INFO("Running: " << cmd_str); + + std::string cmd = "ssh " + SSH_ARGS + " -i " + SSH_KEY + " " + SSH_USER + "@" + SSH_HOST + " '" + cmd_str + "'"; + std::system(cmd.c_str()); + + INFO("Parsing JSON for 'command_start' with token: " << token); + + bool found = false; + int64_t pid = -1; + + found = wait_for_event([&](JsonView json) { + // Check for string "command_start" and token inside args + if ((json["event_type"] == "command_start") && (json["filename"] == "ls") && (json["args"].contains(token)) && + (json["exit_code"] == -1)) { + pid = json["pid"].get_int(); + return true; + } + return false; + }); + + CHECK(found == true); + + INFO("Looking for PID " << pid); + + INFO("Parsing JSON for 'command_end' with token: " << token); + found = wait_for_event([&](JsonView json) { + // Check for string "command_end" and token inside args + return (json["event_type"] == "command_finish") && (json["filename"] == "ls") && (json["pid"] == pid) && + (json["args"].contains(token)) && (json["exit_code"] != 0) && (json["stdout_size"] > 0); + }); + + CHECK(found == true); + } + + SECTION("File Upload (SCP) Detection") { + std::string token = generate_uuid(); + std::string remote_path = "/tmp/sshlog_test_" + token; + std::system("echo 'payload' > /tmp/sshlog_dummy"); + + INFO("Uploading to " << remote_path); + std::string cmd = + "scp " + SSH_ARGS + " -i " + SSH_KEY + " /tmp/sshlog_dummy " + SSH_USER + "@" + SSH_HOST + ":" + remote_path; + std::system(cmd.c_str()); + + INFO("Parsing JSON for 'file_upload' with path: " << remote_path); + + bool found = wait_for_event([&](JsonView json) { + // Check for string "file_upload" + // Checks both 'filename' and 'target_path' just in case schema varies + bool name_match = (json["filename"] == remote_path) || (json["target_path"] == remote_path); + + return (json["event_type"] == "file_upload") && name_match; + }); + + CHECK(found == true); + std::remove("/tmp/sshlog_dummy"); + } +} + +// ... (Previous includes and setup code remain the same) ... + +// --- NEW TEST CASES --- + +TEST_CASE("SSHLog Advanced Features", "[lib]") { + static SSHLogDaemon daemon; + daemon_ptr = &daemon; + + SECTION("Authentication Failure Detection") { + INFO("Attempting failed login for user 'baduser'"); + + // Use -o BatchMode=yes -o PasswordAuthentication=no to fail immediately without prompt + // We attempt to log in as a non-existent user or just fail auth + std::string cmd = "ssh -o BatchMode=yes -o " + SSH_ARGS + " -o PasswordAuthentication=no baduser@" + SSH_HOST + + " 'exit' 2>/dev/null"; + + // We expect the command to fail (ret != 0) + int ret = std::system(cmd.c_str()); + CHECK(ret != 0); + + INFO("Waiting for 'connection_auth_failed' event"); + + bool found = wait_for_event([](JsonView json) { + return (json["event_type"] == "connection_auth_failed") && (json["username"] == "baduser"); + }); + + CHECK(found == true); + } + + SECTION("Command Output Capture (Stdout)") { + std::string token = generate_uuid(); + INFO("Running command that generates specific output: " << token); + + // We run a command that prints the token to stdout + std::string cmd = "ssh " + SSH_ARGS + " -i " + SSH_KEY + " " + SSH_USER + "@" + SSH_HOST + " 'ls " + token + "'"; + std::system(cmd.c_str()); + + INFO("Waiting for 'command_finish' with stdout containing token"); + + bool found = wait_for_event([&](JsonView json) { + // Check that we captured the stdout + // Log entry example: {"event_type":"command_finish", ..., "stdout":"token_...\n"} + return (json["event_type"] == "command_finish") && (json["stdout"].contains(token)); + }); + + CHECK(found == true); + } + + SECTION("Terminal Keystroke Logging") { + // This is harder to simulate with 'ssh command' because that doesn't allocate a PTY. + // We use Python/Expect logic via 'script' or just assume 'ssh -tt' works. + // 'ssh -tt' forces TTY allocation. + + INFO("Simulating interactive terminal session"); + + // We send a command that sleeps so the session stays open, then we send input? + // Actually, just running a command with -tt might trigger terminal_update events + // for the output, even if we don't send keystrokes manually. + std::string unique_output = "TERM_DATA_" + generate_uuid(); + + std::string cmd = "ssh -tt " + SSH_ARGS + " -i " + SSH_KEY + " " + SSH_USER + "@" + SSH_HOST + " 'echo " + + unique_output + "; exit'"; + std::system(cmd.c_str()); + + INFO("Waiting for 'terminal_update' containing output"); + + bool found = wait_for_event([&](JsonView json) { + // Look for the echoed text in the terminal stream + return (json["event_type"] == "terminal_update") && (json["terminal_data"].contains(unique_output)); + }); + + CHECK(found == true); + } + + SECTION("Recursive Command Execution (Shell Chains)") { + // Your logs showed: sh -c ... -> run-parts -> uname + // Let's verify we catch a child process spawned by a shell script. + + std::string token = generate_uuid(); + INFO("Running nested command: sh -c 'ls " << token << "'"); + + // We run 'sh -c ls' to create a parent-child relationship (sshd -> sh -> ls) + std::string cmd = + "ssh " + SSH_ARGS + " -i " + SSH_KEY + " " + SSH_USER + "@" + SSH_HOST + " 'sh -c \"ls " + token + "\"'"; + std::system(cmd.c_str()); + + INFO("Waiting for the child 'ls' command event"); + + bool found = wait_for_event([&](JsonView json) { + // We want to make sure we caught the 'ls', not just the 'sh' + // The 'filename' should be 'ls' (or contain it) and args should have token + bool is_ls = (json["filename"] == "ls") || (json["filename"] == "/usr/bin/ls"); + + return (json["event_type"] == "command_start") && is_ls && (json["args"].contains(token)); + }); + + CHECK(found == true); + } +} \ No newline at end of file diff --git a/readme.md b/readme.md index 95ffe85..d29f343 100644 --- a/readme.md +++ b/readme.md @@ -12,31 +12,47 @@ SSHLog is a free, source-available Linux daemon written in C++ and Python that p SSHLog is configurable, any combination of features may be enabled, disabled, or customized. It works with your existing OpenSSH server process, no alternative SSH daemon is required. Just install the sshlog package to begin monitoring SSH. -![SSHLog header](http://www.sshlog.com/assets/img/hero/hero-img.png) +![SSHLog header](assets/sshlog_header.png) -## Quick Start +## Quick Start (Docker) -Install the daemon using the instructions for your OS (located below). The default installation will: - 1. Install and enable the "sshlogd" daemon on startup - 2. Install the "sshlog" CLI application - 3. Enable a number of default configuration files in /etc/sshlog/conf.d/ +SSHLog is designed to run as a privileged Docker container. - - /var/log/sshlog/event.log contains all SSH events: - - /var/log/sshlog/sessions/ log files contains all individual session activity (commands and output) +### 1. Try it out (Diagnostic Mode) -### After installation: +To quickly test SSHLog with the web interface and session injection enabled, run: - - SSH into your server to generate some activity (e.g., ssh localhost). - - Check log files in /var/log/sshlog/ - - /var/log/sshlog/event.log will contain a list of all actions (e.g., logins/logouts, commands run, files uploaded, etc) - - /var/log/sshlog/sessions/ will contain the full shell terminal output, one file for each SSH session - - Tip: Optionally add admin users to the "sshlog" group so that they can interact with SSHLog daemon without requiring sudo - -Interact with the daemon via the CLI app: +```bash +docker run --privileged \ + -e SSHLOG_ENABLE_DIAGNOSTIC_WEB=1 \ + -e SSHLOG_ENABLE_SESSION_INJECTION=1 \ + -v /usr/src:/usr/src:ro \ + -v /lib/modules:/lib/modules:ro \ + -v /var/log/btmp:/var/log/btmp:ro \ + -v /etc/passwd:/etc/passwd:ro \ + -v /etc/group:/etc/group:ro \ + -v /dev/pts:/dev/pts:rw \ + -v /sys/kernel/debug:/sys/kernel/debug:rw \ + -v /etc/sshlog:/etc/sshlog \ + --net=host --pid=host \ + --rm -it ghcr.io/sshlog/agent:v1.1.0 +``` + +**Note:** This mode enables the diagnostic web server on port 5000 and allows writing to SSH sessions. + +### 2. Web Interface + +Once running, access the dashboard at `http://:5000`. + +![Web Interface Demo](assets/webserver_demo.gif) + +### 3. CLI Usage + +You can interact with the daemon via the CLI app inside the container: #### Show current logged in sessions: - mhill@devlaptop:~$ sshlog sessions + docker exec -it sshlog sshlog sessions User Last Activity Last Command Session Start Client IP TTY mhill just now /usr/bin/gcc 2023-04-10 16:16:18 127.0.0.1:58668 17 @@ -45,73 +61,44 @@ Interact with the daemon via the CLI app: #### Monitor real-time SSH activity - mhill@devlaptop:~$ sshlog watch + docker exec -it sshlog sshlog watch 16:16:45 connection_established (970236) billy from ip 15.12.5.8:59120 tty 33 16:16:45 command_start (970236) billy executed /bin/bash - 16:16:49 command_start (970236) billy executed /usr/bin/whoami - 16:16:49 command_finish (970236) billy execute complete (exit code: 0) /usr/bin/whoami - 16:16:51 command_start (970236) billy executed /usr/bin/sudo ls - 16:16:54 command_start (970236) billy executed /usr/bin/ls - 16:16:54 command_finish (970236) billy execute complete (exit code: 0) /usr/bin/ls - 16:16:54 command_finish (970236) billy execute complete (exit code: 0) /usr/bin/sudo ls - 16:16:56 command_finish (970236) billy execute complete (exit code: 0) /bin/bash - 16:16:56 connection_close (970236) billy from ip 15.12.5.8:59120 + ... #### Attach to another user's shell session (either read-only or interactive) - mhill@devlaptop:~$ sshlog attach [TTY ID] - - Attached to TTY 32. Press CTRL+Q to exit - - billy@devlaptop:~$ - - - - -### Debian/Ubuntu Install (arm64 and x86_64) - - apt update && apt install -y curl gnupg - curl https://repo.sshlog.com/sshlog-ubuntu/public.gpg | gpg --yes --dearmor -o /usr/share/keyrings/repo-sshlog-ubuntu.gpg - echo "deb [arch=any signed-by=/usr/share/keyrings/repo-sshlog-ubuntu.gpg] https://repo.sshlog.com/sshlog-ubuntu/ stable main" > /etc/apt/sources.list.d/repo-sshlog-ubuntu.list - apt update && apt install -y sshlog - + docker exec -it sshlog sshlog attach [TTY ID] +## Production Deployment -### RedHat/Fedora Install (arm64 and x86_64) +For production use, we recommend locking down the container: +* Disable the web server (remove `SSHLOG_ENABLE_DIAGNOSTIC_WEB`) +* Disable session injection (remove `SSHLOG_ENABLE_SESSION_INJECTION`) +* Mount `/dev/pts` as read-only - echo """ - [sshlog-redhat] - name=sshlog-redhat - baseurl=https://repo.sshlog.com/sshlog-redhat - enabled=1 - repo_gpgcheck=1 - gpgkey=https://repo.sshlog.com/sshlog-redhat/public.gpg - """ > /etc/yum.repos.d/sshlog-redhat.repo - yum update && yum install sshlog +```bash +docker run -d --restart=always --name sshlog \ + --privileged \ + -v /usr/src:/usr/src:ro \ + -v /lib/modules:/lib/modules:ro \ + -v /var/log/btmp:/var/log/btmp:ro \ + -v /etc/passwd:/etc/passwd:ro \ + -v /etc/group:/etc/group:ro \ + -v /dev/pts:/dev/pts:ro \ + -v /sys/kernel/debug:/sys/kernel/debug:rw \ + -v /var/log/sshlog:/var/log/sshlog \ + -v /etc/sshlog:/etc/sshlog \ + --net=host --pid=host \ + ghcr.io/sshlog/agent:v1.1.0 +``` -### Docker Install (x86_64) +## Security Implications - # First, copy the default configuration files to the host /etc/sshlog directory - # This step is unnecessary if you're using your own custom configuration - id=$(docker create sshlog/agent:latest) - docker cp $id:/etc/sshlog - > /tmp/sshlog_default_config.tar - tar xvf /tmp/sshlog_default_config.tar -C /etc/ - docker rm -v $id +**Warning:** This container requires `--privileged` mode and `--pid=host` to monitor SSH processes via eBPF. This grants the container significant access to the host system. - # Next create a detached container and volume mount - # the config files (/etc/sshlog) and output log files (/var/log/sshlog) - # you could place the config files and log file volume mounts elsewhere if you prefer - docker run -d --restart=always --name sshlog \ - --privileged \ - -v /sys:/sys:ro \ - -v /dev:/dev \ - -v /proc:/proc \ - -v /etc/passwd:/etc/passwd:ro \ - -v /var/log/sshlog:/var/log/sshlog \ - -v /etc/sshlog:/etc/sshlog \ - --pid=host \ - sshlog/agent:latest +When `SSHLOG_ENABLE_SESSION_INJECTION` is enabled, the container has the ability to inject keystrokes into any active SSH session on the host. Ensure access to this container and the Docker socket is strictly controlled.