merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
190
decnet/templates/_shared/auth-helper/auth-helper.c
Normal file
190
decnet/templates/_shared/auth-helper/auth-helper.c
Normal file
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* auth-helper — RFC 5424 cred-capture helper invoked via pam_exec.so.
|
||||
*
|
||||
* Wired into /etc/pam.d/sshd as:
|
||||
* auth optional pam_exec.so expose_authtok stdout /usr/sbin/auth-helper
|
||||
*
|
||||
* Behaviour:
|
||||
* - Reads $PAM_USER and $PAM_RHOST from environ (set by pam_exec).
|
||||
* - Reads PAM_AUTHTOK from stdin (NUL-terminated, written by pam_exec
|
||||
* when invoked with `expose_authtok`).
|
||||
* - Emits a single RFC 5424 line on /proc/1/fd/1 in the same shape as
|
||||
* templates/syslog_bridge.py:syslog_line() — facility local0, PEN
|
||||
* 55555, MSGID `auth_attempt` (matches FTP's existing event type so
|
||||
* the parser + dashboard pick it up with zero changes).
|
||||
*
|
||||
* SD-block carries the standardized credential shape (matches
|
||||
* decnet/web/db/models/logs.py:Credential). Universal keys consumed
|
||||
* directly by the ingester's native-shape branch:
|
||||
* principal the human-meaningful identity the attacker sent
|
||||
* (username for SSH/Telnet; would be a domain for
|
||||
* SMTP, a DN for LDAP, etc.)
|
||||
* secret_printable RFC 5424-escaped ASCII-printable, '?' for non-
|
||||
* printables. Best-effort display form; may be
|
||||
* lossy on non-UTF8 bytes.
|
||||
* secret_b64 base64 of the exact PAM_AUTHTOK bytes. Lossless.
|
||||
* Preserves NUL/0xff/control bytes that the plain
|
||||
* field would silently drop — useful fingerprinting
|
||||
* signal that survives display sanitization.
|
||||
*
|
||||
* `username` rides alongside as a service-specific identity field for
|
||||
* SSH/Telnet (mirrors `principal`); future emitters (SMTP, LDAP, …)
|
||||
* drop `username` in favor of their service-native identity field.
|
||||
*
|
||||
* Fail-open: every error path silently exits 0. The PAM line is `optional`
|
||||
* so a malfunctioning helper must never break sshd auth.
|
||||
*
|
||||
* PII discipline: the password value is attacker-supplied bytes. Decky
|
||||
* services are not for admin SSH; throwaway creds (root:admin) are the
|
||||
* convention. Limitations tracked in development/DEBT.md (DEBT-038).
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define MAX_USER 256
|
||||
#define MAX_HOST 256
|
||||
#define MAX_PW 1024
|
||||
#define LINE_BUF 8192
|
||||
|
||||
static const char B64[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/* Standard base64 with '=' padding. NUL-terminates *out*. Returns bytes
|
||||
* written (excluding the NUL). On overflow returns 0 and NUL-terminates. */
|
||||
static size_t b64_encode(const unsigned char *in, size_t inlen,
|
||||
char *out, size_t outcap) {
|
||||
size_t i = 0, o = 0;
|
||||
while (i + 3 <= inlen) {
|
||||
if (o + 4 >= outcap) { out[0] = '\0'; return 0; }
|
||||
unsigned x = ((unsigned)in[i] << 16) |
|
||||
((unsigned)in[i+1] << 8) |
|
||||
(unsigned)in[i+2];
|
||||
out[o++] = B64[(x >> 18) & 0x3f];
|
||||
out[o++] = B64[(x >> 12) & 0x3f];
|
||||
out[o++] = B64[(x >> 6) & 0x3f];
|
||||
out[o++] = B64[ x & 0x3f];
|
||||
i += 3;
|
||||
}
|
||||
if (i < inlen) {
|
||||
if (o + 4 >= outcap) { out[0] = '\0'; return 0; }
|
||||
unsigned x = (unsigned)in[i] << 16;
|
||||
if (i + 1 < inlen) x |= (unsigned)in[i+1] << 8;
|
||||
out[o++] = B64[(x >> 18) & 0x3f];
|
||||
out[o++] = B64[(x >> 12) & 0x3f];
|
||||
out[o++] = (i + 1 < inlen) ? B64[(x >> 6) & 0x3f] : '=';
|
||||
out[o++] = '=';
|
||||
}
|
||||
out[o] = '\0';
|
||||
return o;
|
||||
}
|
||||
|
||||
/* RFC 5424 §6.3.3: in SD-PARAM-VALUE, escape \\ → \\\\, " → \", ] → \].
|
||||
* Non-printables become '?' so the line stays parser-safe. */
|
||||
static size_t sd_escape(const unsigned char *in, size_t inlen,
|
||||
char *out, size_t outcap) {
|
||||
size_t o = 0;
|
||||
for (size_t i = 0; i < inlen; i++) {
|
||||
unsigned char c = in[i];
|
||||
if (c == '\\' || c == '"' || c == ']') {
|
||||
if (o + 3 >= outcap) break;
|
||||
out[o++] = '\\';
|
||||
out[o++] = c;
|
||||
} else if (c >= 0x20 && c < 0x7f) {
|
||||
if (o + 2 >= outcap) break;
|
||||
out[o++] = c;
|
||||
} else {
|
||||
if (o + 2 >= outcap) break;
|
||||
out[o++] = '?';
|
||||
}
|
||||
}
|
||||
out[o] = '\0';
|
||||
return o;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
const char *user = getenv("PAM_USER");
|
||||
const char *rhost = getenv("PAM_RHOST");
|
||||
if (!user) user = "";
|
||||
if (!rhost) rhost = "";
|
||||
|
||||
/* Read password until NUL (pam_exec's expose_authtok contract) or EOF. */
|
||||
unsigned char pw_raw[MAX_PW];
|
||||
size_t pw_len = 0;
|
||||
while (pw_len < sizeof(pw_raw)) {
|
||||
ssize_t n = read(0, pw_raw + pw_len, sizeof(pw_raw) - pw_len);
|
||||
if (n <= 0) break;
|
||||
for (ssize_t i = 0; i < n; i++) {
|
||||
if (pw_raw[pw_len + i] == 0) {
|
||||
pw_len += (size_t)i;
|
||||
goto pw_done;
|
||||
}
|
||||
}
|
||||
pw_len += (size_t)n;
|
||||
}
|
||||
pw_done:;
|
||||
|
||||
/* Timestamp: YYYY-MM-DDThh:mm:ss.uuuuuu+00:00 — matches the shape
|
||||
* datetime.now(timezone.utc).isoformat() emits in syslog_bridge.py. */
|
||||
struct timespec ts;
|
||||
if (clock_gettime(CLOCK_REALTIME, &ts) != 0) return 0;
|
||||
struct tm tm;
|
||||
if (gmtime_r(&ts.tv_sec, &tm) == NULL) return 0;
|
||||
char tsbuf[40];
|
||||
snprintf(tsbuf, sizeof(tsbuf),
|
||||
"%04d-%02d-%02dT%02d:%02d:%02d.%06ld+00:00",
|
||||
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
|
||||
tm.tm_hour, tm.tm_min, tm.tm_sec,
|
||||
(long)(ts.tv_nsec / 1000));
|
||||
|
||||
char host[MAX_HOST];
|
||||
if (gethostname(host, sizeof(host) - 1) != 0) {
|
||||
host[0] = '-'; host[1] = '\0';
|
||||
} else {
|
||||
host[sizeof(host) - 1] = '\0';
|
||||
}
|
||||
|
||||
/* Escape / encode the dynamic fields. Buffers sized 2x source to
|
||||
* survive worst-case escape expansion. */
|
||||
char user_esc [MAX_USER * 2];
|
||||
char rhost_esc[MAX_HOST * 2];
|
||||
char pw_esc [MAX_PW * 2];
|
||||
char pw_b64 [MAX_PW * 2];
|
||||
|
||||
sd_escape((const unsigned char *)user, strlen(user), user_esc, sizeof(user_esc));
|
||||
sd_escape((const unsigned char *)rhost, strlen(rhost), rhost_esc, sizeof(rhost_esc));
|
||||
sd_escape(pw_raw, pw_len, pw_esc, sizeof(pw_esc));
|
||||
b64_encode(pw_raw, pw_len, pw_b64, sizeof(pw_b64));
|
||||
|
||||
/* Priority: facility=local0(16), severity=INFO(6) → <16*8+6> = <134>.
|
||||
* Matches the syslog_bridge.py default exactly.
|
||||
*
|
||||
* SD-block keys match the Credential storage model: principal +
|
||||
* secret_printable + secret_b64 are the universal keys the ingester
|
||||
* keys off; username is emitted alongside principal so existing
|
||||
* dashboards that read SSH/Telnet `username=` keep working until
|
||||
* the cred-reuse UI lands. */
|
||||
char line[LINE_BUF];
|
||||
int n = snprintf(line, sizeof(line),
|
||||
"<134>1 %s %s auth-helper - auth_attempt "
|
||||
"[relay@55555 username=\"%s\" principal=\"%s\" "
|
||||
"secret_printable=\"%s\" secret_b64=\"%s\" src_ip=\"%s\"]\n",
|
||||
tsbuf, host, user_esc, user_esc, pw_esc, pw_b64, rhost_esc);
|
||||
if (n <= 0 || (size_t)n >= sizeof(line)) return 0;
|
||||
|
||||
/* /proc/1/fd/1 is the entrypoint's stdout — the fd Docker captures
|
||||
* for `docker logs`. Same channel rsyslog forwards auth.* into via
|
||||
* the existing template; we bypass rsyslog entirely so behaviour is
|
||||
* deterministic across rsyslog config drift. */
|
||||
int fd = open("/proc/1/fd/1", O_WRONLY | O_APPEND);
|
||||
if (fd < 0) return 0;
|
||||
ssize_t w = write(fd, line, (size_t)n);
|
||||
(void)w;
|
||||
close(fd);
|
||||
|
||||
return 0;
|
||||
}
|
||||
132
decnet/templates/_shared/ntlmssp.py
Normal file
132
decnet/templates/_shared/ntlmssp.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""NTLMSSP Type 3 (Authenticate) message parser.
|
||||
|
||||
Standalone module shared between any honeypot template that wants to
|
||||
land NTLM credentials in the universal :class:`Credential` table.
|
||||
Currently consumed by the SMB and RDP-NLA templates.
|
||||
|
||||
The parser is intentionally narrow: only :func:`parse_type3` is public,
|
||||
and it reads a single Type 3 buffer (the bytes starting with the
|
||||
``NTLMSSP\\0`` signature). Callers handle SPNEGO unwrapping, SMB
|
||||
SessionSetup framing, RDP/CredSSP TSRequest parsing, etc.
|
||||
|
||||
Reference: MS-NLMP §2.2.1.3 (AUTHENTICATE_MESSAGE).
|
||||
|
||||
Cred-shape mapping for the universal Credential model:
|
||||
- ``principal`` = ``"DOMAIN\\username"`` when domain present, else
|
||||
bare username. Both decoded UTF-16-LE when NEGOTIATE_UNICODE is set
|
||||
in the message flags (it always is in modern clients).
|
||||
- ``secret_kind`` = ``"ntlmssp_v2"`` when the NtChallengeResponse is
|
||||
≥ 24 bytes (NTLMv2 carries variable-length blob ≥ 16+8 bytes),
|
||||
``"ntlmssp_v1"`` for the legacy 24-byte fixed response.
|
||||
- ``secret_b64`` = base64 of the entire NtChallengeResponse bytes.
|
||||
This is the canonical "hashcat -m 5600" (NTLMv2) or "-m 5500"
|
||||
(NTLMv1) input.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import struct
|
||||
from typing import Optional
|
||||
|
||||
NTLMSSP_SIG = b"NTLMSSP\x00"
|
||||
NEGOTIATE_UNICODE = 0x00000001
|
||||
|
||||
|
||||
def find_ntlmssp(buf: bytes) -> int:
|
||||
"""Return the offset of the NTLMSSP signature in ``buf`` or -1.
|
||||
|
||||
Useful for callers that have a SPNEGO-wrapped or SMB-embedded blob
|
||||
and want to skip straight to the inner Type 1/2/3 message without
|
||||
walking the outer ASN.1.
|
||||
"""
|
||||
return buf.find(NTLMSSP_SIG)
|
||||
|
||||
|
||||
def _read_field(buf: bytes, off: int) -> tuple[int, int, int]:
|
||||
"""Read an NTLMSSP field record: (Len, MaxLen, BufferOffset)."""
|
||||
if off + 8 > len(buf):
|
||||
return 0, 0, 0
|
||||
f_len, f_max, f_off = struct.unpack_from("<HHI", buf, off)
|
||||
return f_len, f_max, f_off
|
||||
|
||||
|
||||
def _slice(buf: bytes, off: int, length: int) -> bytes:
|
||||
end = off + length
|
||||
if off < 0 or end > len(buf) or length < 0:
|
||||
return b""
|
||||
return buf[off:end]
|
||||
|
||||
|
||||
def _decode_str(raw: bytes, unicode: bool) -> str:
|
||||
if unicode:
|
||||
return raw.decode("utf-16-le", errors="replace")
|
||||
return raw.decode("ascii", errors="replace")
|
||||
|
||||
|
||||
def parse_type3(blob: bytes) -> Optional[dict]:
|
||||
"""Parse an NTLMSSP Type 3 (AUTHENTICATE_MESSAGE) buffer.
|
||||
|
||||
Returns a dict with the universal credential SD shape ready to
|
||||
spread into a ``_log(...)`` call::
|
||||
|
||||
{
|
||||
"username": "alice", # service-specific identity
|
||||
"domain": "ACME", # domain (may be empty)
|
||||
"principal": "ACME\\\\alice", # hoisted column
|
||||
"secret_kind": "ntlmssp_v2", # or _v1
|
||||
"secret_printable": "<hex>", # NT response in hex
|
||||
"secret_b64": "<base64>", # NT response, lossless
|
||||
}
|
||||
|
||||
Returns ``None`` when ``blob`` is malformed or not a Type 3.
|
||||
"""
|
||||
if len(blob) < 32 or not blob.startswith(NTLMSSP_SIG):
|
||||
return None
|
||||
msg_type = struct.unpack_from("<I", blob, 8)[0]
|
||||
if msg_type != 3:
|
||||
return None
|
||||
|
||||
# Field record layout (all from MS-NLMP §2.2.1.3):
|
||||
# 12 LmChallengeResponseFields
|
||||
# 20 NtChallengeResponseFields
|
||||
# 28 DomainNameFields
|
||||
# 36 UserNameFields
|
||||
# 44 WorkstationFields
|
||||
# 52 EncryptedRandomSessionKeyFields
|
||||
# 60 NegotiateFlags
|
||||
nt_len, _, nt_off = _read_field(blob, 20)
|
||||
dom_len, _, dom_off = _read_field(blob, 28)
|
||||
user_len, _, user_off = _read_field(blob, 36)
|
||||
if len(blob) < 64:
|
||||
return None
|
||||
flags = struct.unpack_from("<I", blob, 60)[0]
|
||||
unicode = bool(flags & NEGOTIATE_UNICODE)
|
||||
|
||||
nt_response = _slice(blob, nt_off, nt_len)
|
||||
domain = _decode_str(_slice(blob, dom_off, dom_len), unicode)
|
||||
username = _decode_str(_slice(blob, user_off, user_len), unicode)
|
||||
|
||||
if not nt_response:
|
||||
# No NT response → anonymous bind or malformed; nothing to
|
||||
# treat as a credential.
|
||||
return None
|
||||
|
||||
# NTLMv2 NTChallengeResponseV2 has a 16-byte HMAC followed by a
|
||||
# variable-length blob (≥ 28 bytes total in practice). NTLMv1 is
|
||||
# exactly 24 bytes. Use length to discriminate; close enough for
|
||||
# cred-classification purposes (the bytes go on hashcat regardless).
|
||||
secret_kind = "ntlmssp_v1" if len(nt_response) == 24 else "ntlmssp_v2"
|
||||
|
||||
if domain:
|
||||
principal = f"{domain}\\{username}"
|
||||
else:
|
||||
principal = username or None
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"domain": domain,
|
||||
"principal": principal,
|
||||
"secret_kind": secret_kind,
|
||||
"secret_printable": nt_response.hex(),
|
||||
"secret_b64": base64.b64encode(nt_response).decode("ascii"),
|
||||
}
|
||||
28
decnet/templates/_shared/sessrec/Makefile
Normal file
28
decnet/templates/_shared/sessrec/Makefile
Normal file
@@ -0,0 +1,28 @@
|
||||
# Build sessrec, a tiny pty relay + transcript recorder installed as the
|
||||
# login shell inside SSH / Telnet decky containers. Built per-image during
|
||||
# the template Dockerfile's build stage; gcc + libc6-dev are installed only
|
||||
# for this step and purged in the same layer.
|
||||
#
|
||||
# Output: /usr/libexec/login-session (plausible login-machinery name)
|
||||
|
||||
CC ?= gcc
|
||||
CFLAGS ?= -O2 -Wall -Wextra -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE
|
||||
LDFLAGS ?= -pie -Wl,-z,relro,-z,now
|
||||
LIBS := -lutil
|
||||
|
||||
PREFIX ?= /usr/libexec
|
||||
TARGET := login-session
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): sessrec.c
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< $(LIBS)
|
||||
strip --strip-unneeded $@
|
||||
|
||||
install: $(TARGET)
|
||||
install -D -m 0755 $(TARGET) $(DESTDIR)$(PREFIX)/$(TARGET)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
|
||||
.PHONY: all install clean
|
||||
564
decnet/templates/_shared/sessrec/sessrec.c
Normal file
564
decnet/templates/_shared/sessrec/sessrec.c
Normal file
@@ -0,0 +1,564 @@
|
||||
/*
|
||||
* sessrec — interactive session recorder for SSH / Telnet deckies.
|
||||
*
|
||||
* Invoked as the login shell (via /etc/passwd shell swap). On interactive tty
|
||||
* sessions it:
|
||||
* 1. forkpty()'s /bin/bash -l and relays stdin/stdout/SIGWINCH bidirectionally;
|
||||
* 2. records each chunk as an asciinema v2 event in a *shared* JSONL day-shard
|
||||
* (/var/lib/systemd/coredump/transcripts/sessions-YYYY-MM-DD.jsonl) with
|
||||
* the session's UUID as a sid tag on every line;
|
||||
* 3. on exit emits one RFC 5424 syslog line (event_type=session_recorded)
|
||||
* direct to PID 1's stdout — bypasses rsyslog the same way syslog_bridge.py
|
||||
* does in the Python service templates.
|
||||
*
|
||||
* Storage shape is one JSONL shard per (decky, UTC day). Concurrent sessions
|
||||
* append the shard lock-free: each write() is < PIPE_BUF (4096) and O_APPEND
|
||||
* guarantees atomic interleave on Linux regular files. Events larger than one
|
||||
* atomic write are chunked. Per-session cap: 10 MB; overflow writes one sentinel
|
||||
* line and stops emitting (session itself continues). Disk-free precheck on the
|
||||
* shard mount; below 200 MB free we emit session_skipped and exec bash directly.
|
||||
*
|
||||
* Non-tty invocation (e.g. `ssh host cmd`) short-circuits to execvp(bash) so
|
||||
* non-interactive command execution still surfaces via the existing
|
||||
* PROMPT_COMMAND logger hook rather than this path.
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netdb.h>
|
||||
#include <pty.h>
|
||||
#include <signal.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#include <sys/time.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <termios.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <utmp.h>
|
||||
#include <poll.h>
|
||||
|
||||
#define TRANSCRIPTS_DIR "/var/lib/systemd/coredump/transcripts"
|
||||
#define PID1_STDOUT "/proc/1/fd/1"
|
||||
#define MIN_FREE_BYTES ((uint64_t)200 * 1024 * 1024) /* 200 MB disk precheck */
|
||||
#define SESSION_CAP_BYTES ((uint64_t) 10 * 1024 * 1024) /* 10 MB per-session cap */
|
||||
#define ATOMIC_CHUNK 3900 /* < PIPE_BUF (4096) */
|
||||
#define BUF_SIZE 4096
|
||||
#define LINE_SCRATCH (ATOMIC_CHUNK * 2 + 512)
|
||||
#define DEFAULT_SHELL "/bin/bash"
|
||||
#define COMM_DISGUISE "kworker/u8:2-ev" /* fits 15-char comm cap */
|
||||
|
||||
/* ─── tiny utilities ──────────────────────────────────────────────────────── */
|
||||
|
||||
static volatile sig_atomic_t sigwinch_pending = 0;
|
||||
|
||||
static void sigwinch_handler(int sig) { (void)sig; sigwinch_pending = 1; }
|
||||
|
||||
static double monotonic_since(const struct timespec *t0) {
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
double dt = (double)(now.tv_sec - t0->tv_sec)
|
||||
+ (double)(now.tv_nsec - t0->tv_nsec) / 1e9;
|
||||
return dt < 0.0 ? 0.0 : dt;
|
||||
}
|
||||
|
||||
/* Write all bytes, retrying on EINTR. Returns 0 on success. */
|
||||
static int write_all(int fd, const void *buf, size_t n) {
|
||||
const uint8_t *p = buf;
|
||||
while (n > 0) {
|
||||
ssize_t w = write(fd, p, n);
|
||||
if (w < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
return -1;
|
||||
}
|
||||
p += w; n -= (size_t)w;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Pick 16 bytes of entropy, format as UUIDv4 (8-4-4-4-12 hex, 36 chars + NUL). */
|
||||
static int mint_uuid(char out[37]) {
|
||||
int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) return -1;
|
||||
uint8_t b[16];
|
||||
ssize_t n = read(fd, b, sizeof b);
|
||||
close(fd);
|
||||
if (n != (ssize_t)sizeof b) return -1;
|
||||
b[6] = (b[6] & 0x0f) | 0x40; /* v4 */
|
||||
b[8] = (b[8] & 0x3f) | 0x80; /* variant */
|
||||
snprintf(out, 37,
|
||||
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
|
||||
b[0],b[1],b[2],b[3], b[4],b[5], b[6],b[7], b[8],b[9],
|
||||
b[10],b[11],b[12],b[13],b[14],b[15]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* JSON-escape raw bytes into dst. Returns written length (excluding NUL),
|
||||
* or -1 on overflow. Handles control chars, quote, backslash, and non-UTF8
|
||||
* bytes (emitted as \u00XX so the output stays valid JSON regardless of
|
||||
* terminal payload encoding). */
|
||||
static ssize_t json_escape(char *dst, size_t cap, const uint8_t *src, size_t n) {
|
||||
size_t o = 0;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
uint8_t c = src[i];
|
||||
const char *esc = NULL;
|
||||
char buf[8];
|
||||
size_t add;
|
||||
switch (c) {
|
||||
case '"': esc = "\\\""; add = 2; break;
|
||||
case '\\': esc = "\\\\"; add = 2; break;
|
||||
case '\b': esc = "\\b"; add = 2; break;
|
||||
case '\f': esc = "\\f"; add = 2; break;
|
||||
case '\n': esc = "\\n"; add = 2; break;
|
||||
case '\r': esc = "\\r"; add = 2; break;
|
||||
case '\t': esc = "\\t"; add = 2; break;
|
||||
default:
|
||||
if (c < 0x20 || c == 0x7f) {
|
||||
snprintf(buf, sizeof buf, "\\u%04x", c);
|
||||
esc = buf; add = 6;
|
||||
} else {
|
||||
esc = NULL; add = 1;
|
||||
}
|
||||
}
|
||||
if (o + add + 1 >= cap) return -1;
|
||||
if (esc) { memcpy(dst + o, esc, add); o += add; }
|
||||
else { dst[o++] = (char)c; }
|
||||
}
|
||||
dst[o] = '\0';
|
||||
return (ssize_t)o;
|
||||
}
|
||||
|
||||
/* ─── disk precheck + shard resolution ────────────────────────────────────── */
|
||||
|
||||
static uint64_t free_bytes(const char *path) {
|
||||
struct statvfs s;
|
||||
if (statvfs(path, &s) != 0) return 0;
|
||||
return (uint64_t)s.f_bavail * (uint64_t)s.f_frsize;
|
||||
}
|
||||
|
||||
static void today_utc(char out[11]) {
|
||||
time_t t = time(NULL);
|
||||
struct tm tm;
|
||||
gmtime_r(&t, &tm);
|
||||
strftime(out, 11, "%Y-%m-%d", &tm);
|
||||
}
|
||||
|
||||
/* Build /var/lib/systemd/coredump/transcripts/sessions-YYYY-MM-DD.jsonl */
|
||||
static void shard_path(char out[512]) {
|
||||
char day[11];
|
||||
today_utc(day);
|
||||
snprintf(out, 512, "%s/sessions-%s.jsonl", TRANSCRIPTS_DIR, day);
|
||||
}
|
||||
|
||||
/* ─── src_ip resolution ───────────────────────────────────────────────────── */
|
||||
|
||||
static void resolve_src_ip(char out[NI_MAXHOST]) {
|
||||
out[0] = '\0';
|
||||
|
||||
/* SSH: $SSH_CONNECTION = "<client_ip> <client_port> <server_ip> <server_port>" */
|
||||
const char *sc = getenv("SSH_CONNECTION");
|
||||
if (sc && *sc) {
|
||||
size_t i = 0;
|
||||
while (sc[i] && sc[i] != ' ' && i < NI_MAXHOST - 1) {
|
||||
out[i] = sc[i]; i++;
|
||||
}
|
||||
out[i] = '\0';
|
||||
if (out[0]) return;
|
||||
}
|
||||
|
||||
/* Telnet: busybox telnetd -l /bin/login leaves the client socket as fd 0. */
|
||||
struct sockaddr_storage ss;
|
||||
socklen_t sl = sizeof ss;
|
||||
if (getpeername(STDIN_FILENO, (struct sockaddr *)&ss, &sl) == 0) {
|
||||
if (getnameinfo((struct sockaddr *)&ss, sl, out, NI_MAXHOST,
|
||||
NULL, 0, NI_NUMERICHOST) == 0 && out[0]) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* Last-resort: utmp host field for the current tty. */
|
||||
char ttybuf[64];
|
||||
if (ttyname_r(STDIN_FILENO, ttybuf, sizeof ttybuf) == 0) {
|
||||
const char *short_tty = ttybuf;
|
||||
if (strncmp(short_tty, "/dev/", 5) == 0) short_tty += 5;
|
||||
setutent();
|
||||
struct utmp *u;
|
||||
while ((u = getutent()) != NULL) {
|
||||
if (u->ut_type == USER_PROCESS &&
|
||||
strncmp(u->ut_line, short_tty, sizeof u->ut_line) == 0) {
|
||||
size_t cap = sizeof u->ut_host;
|
||||
if (cap > NI_MAXHOST - 1) cap = NI_MAXHOST - 1;
|
||||
memcpy(out, u->ut_host, cap);
|
||||
out[cap] = '\0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
endutent();
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── shard emitters ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* Emit a single line via O_APPEND on the shard. Line must include trailing \n
|
||||
* and be < ATOMIC_CHUNK for atomic-append guarantees. */
|
||||
static int shard_emit(int fd, const char *line, size_t n) {
|
||||
if (n == 0) return 0;
|
||||
/* Single write() < PIPE_BUF is atomic under O_APPEND (POSIX.1-2017 §7.1.1,
|
||||
* Linux write(2) NOTES). Don't loop — partial writes don't happen for
|
||||
* regular files under this size and a retry would break atomicity. */
|
||||
ssize_t w = write(fd, line, n);
|
||||
return (w == (ssize_t)n) ? 0 : -1;
|
||||
}
|
||||
|
||||
static void emit_header(int fd, const char *sid, unsigned short cols,
|
||||
unsigned short rows, time_t unix_ts) {
|
||||
/* Sanitize $TERM — attacker-controlled via the ssh client. */
|
||||
const char *raw_term = getenv("TERM");
|
||||
if (!raw_term || !*raw_term) raw_term = "xterm-256color";
|
||||
char term[64];
|
||||
if (json_escape(term, sizeof term,
|
||||
(const uint8_t *)raw_term, strnlen(raw_term, 63)) < 0) {
|
||||
term[0] = '-'; term[1] = '\0';
|
||||
}
|
||||
|
||||
char line[LINE_SCRATCH];
|
||||
int n = snprintf(line, sizeof line,
|
||||
"{\"sid\":\"%s\",\"hdr\":{\"version\":2,\"width\":%u,\"height\":%u,"
|
||||
"\"timestamp\":%lld,\"env\":{\"SHELL\":\"/bin/bash\",\"TERM\":\"%s\"}}}\n",
|
||||
sid, (unsigned)cols, (unsigned)rows, (long long)unix_ts, term);
|
||||
if (n > 0 && n < (int)sizeof line) shard_emit(fd, line, (size_t)n);
|
||||
}
|
||||
|
||||
/* Emit a single ≤ATOMIC_CHUNK event line. Caller is responsible for chunking. */
|
||||
static int emit_event_chunk(int fd, const char *sid, double t,
|
||||
char ch, const uint8_t *data, size_t n) {
|
||||
static char scratch[LINE_SCRATCH];
|
||||
char escaped[LINE_SCRATCH];
|
||||
if (json_escape(escaped, sizeof escaped, data, n) < 0) return -1;
|
||||
int w = snprintf(scratch, sizeof scratch,
|
||||
"{\"sid\":\"%s\",\"t\":%.6f,\"ch\":\"%c\",\"d\":\"%s\"}\n",
|
||||
sid, t, ch, escaped);
|
||||
if (w <= 0 || w >= (int)sizeof scratch) return -1;
|
||||
return shard_emit(fd, scratch, (size_t)w);
|
||||
}
|
||||
|
||||
static void emit_resize(int fd, const char *sid, double t,
|
||||
unsigned short cols, unsigned short rows) {
|
||||
char line[256];
|
||||
int n = snprintf(line, sizeof line,
|
||||
"{\"sid\":\"%s\",\"t\":%.6f,\"ch\":\"r\",\"d\":\"%ux%u\"}\n",
|
||||
sid, t, (unsigned)cols, (unsigned)rows);
|
||||
if (n > 0 && n < (int)sizeof line) shard_emit(fd, line, (size_t)n);
|
||||
}
|
||||
|
||||
static void emit_trunc_sentinel(int fd, const char *sid) {
|
||||
char line[128];
|
||||
int n = snprintf(line, sizeof line, "{\"sid\":\"%s\",\"trunc\":true}\n", sid);
|
||||
if (n > 0) shard_emit(fd, line, (size_t)n);
|
||||
}
|
||||
|
||||
/* Escape an SD-PARAM-VALUE per RFC 5424 §6.3.3 — backslash, double-quote, and
|
||||
* right bracket must be backslash-escaped; everything else is passed through.
|
||||
* Also drops control chars (< 0x20) and 0x7F since they wreck the collector's
|
||||
* line-oriented parser. */
|
||||
static void sd_escape(char *dst, size_t cap, const char *src) {
|
||||
size_t o = 0;
|
||||
if (cap == 0) return;
|
||||
for (size_t i = 0; src[i] && o + 2 < cap; i++) {
|
||||
unsigned char c = (unsigned char)src[i];
|
||||
if (c < 0x20 || c == 0x7f) continue;
|
||||
if (c == '\\' || c == '"' || c == ']') {
|
||||
if (o + 3 >= cap) break;
|
||||
dst[o++] = '\\';
|
||||
}
|
||||
dst[o++] = (char)c;
|
||||
}
|
||||
dst[o] = '\0';
|
||||
}
|
||||
|
||||
/* ─── syslog emitters (direct to PID 1 stdout) ────────────────────────────── */
|
||||
|
||||
/* Format & write an RFC 5424 line with a [relay@55555 ...] SD block matching
|
||||
* what decnet/templates/syslog_bridge.py emits. Routes the line to PID 1's
|
||||
* stdout fd so the container's Docker log stream picks it up — same channel
|
||||
* the other service templates use. */
|
||||
static void syslog_emit(const char *event_type, const char *sd_params,
|
||||
const char *msg) {
|
||||
int fd = open(PID1_STDOUT, O_WRONLY | O_APPEND | O_CLOEXEC);
|
||||
if (fd < 0) return;
|
||||
|
||||
const char *node = getenv("NODE_NAME");
|
||||
if (!node || !*node) node = "-";
|
||||
|
||||
char ts[64];
|
||||
struct timespec tsp;
|
||||
clock_gettime(CLOCK_REALTIME, &tsp);
|
||||
struct tm tm;
|
||||
gmtime_r(&tsp.tv_sec, &tm);
|
||||
int n = (int)strftime(ts, sizeof ts, "%Y-%m-%dT%H:%M:%S", &tm);
|
||||
snprintf(ts + n, sizeof ts - n, ".%06ld+00:00", tsp.tv_nsec / 1000);
|
||||
|
||||
char line[LINE_SCRATCH];
|
||||
int w = snprintf(line, sizeof line,
|
||||
"<134>1 %s %s sessrec - %s [relay@55555 %s]%s%s\n",
|
||||
ts, node, event_type, sd_params ? sd_params : "",
|
||||
msg && *msg ? " " : "", msg ? msg : "");
|
||||
if (w > 0 && w < (int)sizeof line) write_all(fd, line, (size_t)w);
|
||||
close(fd);
|
||||
}
|
||||
|
||||
/* ─── main relay ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static int open_shard(void) {
|
||||
if (mkdir(TRANSCRIPTS_DIR, 0700) != 0 && errno != EEXIST) return -1;
|
||||
char path[512];
|
||||
shard_path(path);
|
||||
return open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0640);
|
||||
}
|
||||
|
||||
/* Emit an "o" or "i" event, chunking to ATOMIC_CHUNK and tracking bytes_used
|
||||
* against SESSION_CAP_BYTES. On cap crossing, emits the sentinel once and
|
||||
* returns non-zero so the caller stops emitting for this sid. */
|
||||
static int emit_chunked(int fd, const char *sid, double t, char ch,
|
||||
const uint8_t *data, size_t n,
|
||||
uint64_t *bytes_used, int *truncated) {
|
||||
if (*truncated) return 0;
|
||||
size_t off = 0;
|
||||
while (off < n) {
|
||||
size_t take = n - off;
|
||||
if (take > ATOMIC_CHUNK / 4) take = ATOMIC_CHUNK / 4;
|
||||
/* /4 because each raw byte can expand up to 6x under JSON \u00XX
|
||||
* escaping. Keeps the final line < ATOMIC_CHUNK. */
|
||||
if (emit_event_chunk(fd, sid, t, ch, data + off, take) != 0) {
|
||||
/* Shard write failed — treat as truncation to avoid infinite retry
|
||||
* loop and to keep the pty relay going. */
|
||||
*truncated = 1;
|
||||
emit_trunc_sentinel(fd, sid);
|
||||
return 1;
|
||||
}
|
||||
*bytes_used += take;
|
||||
off += take;
|
||||
if (*bytes_used >= SESSION_CAP_BYTES) {
|
||||
*truncated = 1;
|
||||
emit_trunc_sentinel(fd, sid);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void run_relay(int shard_fd, const char *sid, const char *src_ip,
|
||||
const char *service) {
|
||||
/* Capture parent tty state so we can restore + copy winsize to the pty. */
|
||||
struct termios orig_t, raw_t;
|
||||
int have_orig = (tcgetattr(STDIN_FILENO, &orig_t) == 0);
|
||||
struct winsize ws = {24, 80, 0, 0};
|
||||
ioctl(STDIN_FILENO, TIOCGWINSZ, &ws);
|
||||
|
||||
emit_header(shard_fd, sid, ws.ws_col, ws.ws_row, time(NULL));
|
||||
|
||||
int master_fd = -1;
|
||||
pid_t child = forkpty(&master_fd, NULL, have_orig ? &orig_t : NULL, &ws);
|
||||
if (child < 0) {
|
||||
/* Give up recording; fall through to plain shell. */
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
if (child == 0) {
|
||||
/* Child: the login shell. exec into bash, leaving the pty as its ctty. */
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Parent: raw mode on the local tty so keystrokes pass through unmolested. */
|
||||
if (have_orig) {
|
||||
raw_t = orig_t;
|
||||
cfmakeraw(&raw_t);
|
||||
tcsetattr(STDIN_FILENO, TCSANOW, &raw_t);
|
||||
}
|
||||
|
||||
struct sigaction sa = {0};
|
||||
sa.sa_handler = sigwinch_handler;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = SA_RESTART;
|
||||
sigaction(SIGWINCH, &sa, NULL);
|
||||
|
||||
struct timespec t0;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
|
||||
uint64_t bytes_used = 0;
|
||||
int truncated = 0;
|
||||
|
||||
uint8_t buf[BUF_SIZE];
|
||||
struct pollfd pfds[2] = {
|
||||
{ .fd = STDIN_FILENO, .events = POLLIN },
|
||||
{ .fd = master_fd, .events = POLLIN },
|
||||
};
|
||||
|
||||
int child_alive = 1;
|
||||
while (child_alive) {
|
||||
if (sigwinch_pending) {
|
||||
sigwinch_pending = 0;
|
||||
struct winsize nw;
|
||||
if (ioctl(STDIN_FILENO, TIOCGWINSZ, &nw) == 0) {
|
||||
ioctl(master_fd, TIOCSWINSZ, &nw);
|
||||
if (!truncated) emit_resize(shard_fd, sid,
|
||||
monotonic_since(&t0),
|
||||
nw.ws_col, nw.ws_row);
|
||||
}
|
||||
}
|
||||
|
||||
int r = poll(pfds, 2, 1000);
|
||||
if (r < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
|
||||
if (pfds[0].revents & POLLIN) {
|
||||
ssize_t n = read(STDIN_FILENO, buf, sizeof buf);
|
||||
if (n > 0) {
|
||||
write_all(master_fd, buf, (size_t)n);
|
||||
emit_chunked(shard_fd, sid, monotonic_since(&t0), 'i',
|
||||
buf, (size_t)n, &bytes_used, &truncated);
|
||||
} else if (n == 0) {
|
||||
/* stdin EOF — close master so the shell sees EOF too. */
|
||||
close(master_fd);
|
||||
master_fd = -1;
|
||||
pfds[1].fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (master_fd >= 0 && (pfds[1].revents & POLLIN)) {
|
||||
ssize_t n = read(master_fd, buf, sizeof buf);
|
||||
if (n > 0) {
|
||||
write_all(STDOUT_FILENO, buf, (size_t)n);
|
||||
emit_chunked(shard_fd, sid, monotonic_since(&t0), 'o',
|
||||
buf, (size_t)n, &bytes_used, &truncated);
|
||||
} else {
|
||||
/* pty master EOF = shell exited. */
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ((pfds[0].revents | pfds[1].revents) & (POLLHUP | POLLERR | POLLNVAL)) {
|
||||
if (pfds[1].revents & (POLLHUP | POLLERR | POLLNVAL)) break;
|
||||
}
|
||||
|
||||
/* Reap without blocking; tolerate children that exit slightly before
|
||||
* we see the master EOF. */
|
||||
int status;
|
||||
pid_t r2 = waitpid(child, &status, WNOHANG);
|
||||
if (r2 == child) {
|
||||
child_alive = 0;
|
||||
/* Let pty flush remaining output on the next poll cycle. */
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* Final reap. */
|
||||
int status = 0;
|
||||
if (child_alive) waitpid(child, &status, 0);
|
||||
if (master_fd >= 0) close(master_fd);
|
||||
|
||||
if (have_orig) tcsetattr(STDIN_FILENO, TCSANOW, &orig_t);
|
||||
|
||||
double duration = monotonic_since(&t0);
|
||||
|
||||
/* src_ip is always an IP literal (getnameinfo NI_NUMERICHOST or an IPv4/6
|
||||
* token from $SSH_CONNECTION / utmp). 128 B is enough for IPv6 + zone id
|
||||
* + escaping headroom, and keeps the syslog line bounded. */
|
||||
char ip_esc[128];
|
||||
sd_escape(ip_esc, sizeof ip_esc, src_ip[0] ? src_ip : "-");
|
||||
|
||||
char sd[1024];
|
||||
snprintf(sd, sizeof sd,
|
||||
"sid=\"%s\" service=\"%s\" src_ip=\"%s\" duration_s=\"%.3f\" "
|
||||
"bytes=\"%llu\" truncated=\"%s\"",
|
||||
sid, service, ip_esc, duration,
|
||||
(unsigned long long)bytes_used, truncated ? "true" : "false");
|
||||
syslog_emit("session_recorded", sd, NULL);
|
||||
}
|
||||
|
||||
/* ─── main ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc; (void)argv;
|
||||
prctl(PR_SET_NAME, (unsigned long)COMM_DISGUISE, 0, 0, 0);
|
||||
|
||||
/* Non-interactive (`ssh host cmd`) — bypass recording entirely. The
|
||||
* existing PROMPT_COMMAND syslog hook still logs the single command. */
|
||||
if (!isatty(STDIN_FILENO)) {
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Disk pressure: skip recording, fall through to plain shell. */
|
||||
if (free_bytes(TRANSCRIPTS_DIR) < MIN_FREE_BYTES &&
|
||||
free_bytes("/var/lib/systemd/coredump") < MIN_FREE_BYTES) {
|
||||
/* statvfs on the transcripts dir may fail if not yet created; check
|
||||
* the parent mount as a fallback before deciding. */
|
||||
syslog_emit("session_skipped", "reason=\"disk_pressure\"", NULL);
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
int shard_fd = open_shard();
|
||||
if (shard_fd < 0) {
|
||||
syslog_emit("session_skipped", "reason=\"shard_open_failed\"", NULL);
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
char sid[37];
|
||||
if (mint_uuid(sid) != 0) {
|
||||
close(shard_fd);
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Service discriminant: env var SESSREC_SERVICE set by the template
|
||||
* entrypoint (ssh vs telnet). SSH forwards env via PAM; busybox /bin/login
|
||||
* strips env, so as a fallback we read /etc/sessrec.service, a one-line
|
||||
* file the template entrypoint writes at boot. */
|
||||
const char *service = getenv("SESSREC_SERVICE");
|
||||
static char svc_buf[16];
|
||||
if (!service || !*service) {
|
||||
FILE *sf = fopen("/etc/sessrec.service", "r");
|
||||
if (sf) {
|
||||
if (fgets(svc_buf, sizeof svc_buf, sf)) {
|
||||
size_t n = strlen(svc_buf);
|
||||
while (n > 0 && (svc_buf[n - 1] == '\n' || svc_buf[n - 1] == ' ')) {
|
||||
svc_buf[--n] = '\0';
|
||||
}
|
||||
if (svc_buf[0]) service = svc_buf;
|
||||
}
|
||||
fclose(sf);
|
||||
}
|
||||
}
|
||||
if (!service || !*service) service = "ssh";
|
||||
|
||||
char src_ip[NI_MAXHOST];
|
||||
resolve_src_ip(src_ip);
|
||||
|
||||
/* Hostname banner — /bin/login emits "Last login: …" before exec'ing the
|
||||
* shell; we want our header anchored before the shell starts writing, so
|
||||
* emit_header() has already run inside run_relay(). */
|
||||
|
||||
run_relay(shard_fd, sid, src_ip, service);
|
||||
close(shard_fd);
|
||||
|
||||
/* Exit code mirrors the shell's — a bash logout shouldn't surface here
|
||||
* as an error to the parent (sshd / login). */
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user