feat(secubox-auth-logger): Add LuCI auth failure detection

- Add CGI hook to capture client IP during failed auth attempts
- Add JavaScript hook to intercept ubus session.login failures
- Add rpcd plugin for ubus-based auth logging
- Update CrowdSec parser for case-insensitive matching
- Inject JS hook into LuCI theme headers on install

This enables CrowdSec to detect and block brute-force attacks
on the LuCI web interface, which previously only logged
successful authentications.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-13 15:07:40 +01:00
parent da5b88110a
commit 22b344225c
7 changed files with 612 additions and 7 deletions

View File

@ -4,7 +4,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-auth-logger
PKG_VERSION:=1.0.0
PKG_VERSION:=1.1.0
PKG_RELEASE:=1
PKG_ARCH:=all
PKG_LICENSE:=Apache-2.0
@ -16,30 +16,60 @@ define Package/secubox-auth-logger
SECTION:=secubox
CATEGORY:=SecuBox
TITLE:=Authentication Failure Logger for CrowdSec
DEPENDS:=+rpcd +uhttpd
DEPENDS:=+rpcd +uhttpd +libubox-lua
PKGARCH:=all
endef
define Package/secubox-auth-logger/description
Logs authentication failures from LuCI/rpcd and Dropbear SSH
for CrowdSec detection. Patches rpcd to emit auth failure logs
to syslog in a format CrowdSec can parse.
for CrowdSec detection. Includes:
- SSH failure monitoring (OpenSSH/Dropbear)
- LuCI web interface auth failure logging via CGI hook
- JavaScript hook to intercept login failures
- CrowdSec parser and bruteforce scenario
endef
define Build/Compile
endef
define Package/secubox-auth-logger/install
# Auth monitor script
$(INSTALL_DIR) $(1)/usr/lib/secubox
$(INSTALL_BIN) ./files/auth-monitor.sh $(1)/usr/lib/secubox/
# Init script
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/secubox-auth-logger.init $(1)/etc/init.d/secubox-auth-logger
# RPCD plugin for auth logging via ubus
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./files/secubox.auth-logger $(1)/usr/libexec/rpcd/
# ACL for rpcd permissions
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./files/luci-secubox-auth.acl.json $(1)/usr/share/rpcd/acl.d/
# CGI hook for getting client IP during auth
$(INSTALL_DIR) $(1)/www/cgi-bin
$(INSTALL_BIN) ./files/auth-hook.cgi $(1)/www/cgi-bin/secubox-auth-hook
# JavaScript hook for LuCI login interception
$(INSTALL_DIR) $(1)/www/luci-static/resources/secubox
$(INSTALL_DATA) ./files/secubox-auth-hook.js $(1)/www/luci-static/resources/secubox/
# CrowdSec parser
$(INSTALL_DIR) $(1)/etc/crowdsec/parsers/s01-parse
$(INSTALL_DATA) ./files/openwrt-luci-auth.yaml $(1)/etc/crowdsec/parsers/s01-parse/
# CrowdSec scenario
$(INSTALL_DIR) $(1)/etc/crowdsec/scenarios
$(INSTALL_DATA) ./files/openwrt-luci-bf.yaml $(1)/etc/crowdsec/scenarios/
# CrowdSec acquisition config
$(INSTALL_DIR) $(1)/etc/crowdsec/acquis.d
$(INSTALL_DATA) ./files/secubox-auth-acquis.yaml $(1)/etc/crowdsec/acquis.d/
# UCI defaults for first boot setup
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./files/99-secubox-auth-logger $(1)/etc/uci-defaults/
endef
@ -47,8 +77,28 @@ endef
define Package/secubox-auth-logger/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
# Restart rpcd to load new plugin
/etc/init.d/rpcd restart 2>/dev/null
# Enable and start auth monitor
/etc/init.d/secubox-auth-logger enable
/etc/init.d/secubox-auth-logger start
# Run uci-defaults to inject JS hook
/etc/uci-defaults/99-secubox-auth-logger 2>/dev/null || true
echo "SecuBox Auth Logger installed - LuCI login failures now logged for CrowdSec"
}
exit 0
endef
define Package/secubox-auth-logger/postrm
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
# Remove JS hook from LuCI header
if [ -f /usr/lib/lua/luci/view/themes/bootstrap/header.htm ]; then
sed -i '/secubox-auth-hook/d' /usr/lib/lua/luci/view/themes/bootstrap/header.htm 2>/dev/null || true
fi
}
exit 0
endef

View File

@ -1,6 +1,7 @@
#!/bin/sh
# SecuBox Auth Logger - Post-install configuration
# Enables verbose logging for uhttpd and configures CrowdSec
# Copyright (C) 2024 CyberMind.fr
# Note: Dropbear 2024.86 does NOT support -v flag
# Auth monitoring relies on parsing existing syslog messages
@ -17,6 +18,55 @@ fi
touch /var/log/secubox-auth.log
chmod 644 /var/log/secubox-auth.log
# Inject JS hook into LuCI login page
# Try multiple locations for different LuCI versions/themes
inject_js_hook() {
local hook_script='<script src="/luci-static/resources/secubox/secubox-auth-hook.js"></script>'
local hook_marker="secubox-auth-hook"
# Method 1: Bootstrap theme header (LuCI 19.x+)
if [ -f /usr/lib/lua/luci/view/themes/bootstrap/header.htm ]; then
if ! grep -q "$hook_marker" /usr/lib/lua/luci/view/themes/bootstrap/header.htm 2>/dev/null; then
sed -i "s|</head>|$hook_script\n</head>|" /usr/lib/lua/luci/view/themes/bootstrap/header.htm 2>/dev/null
fi
fi
# Method 2: Material theme header
if [ -f /usr/lib/lua/luci/view/themes/material/header.htm ]; then
if ! grep -q "$hook_marker" /usr/lib/lua/luci/view/themes/material/header.htm 2>/dev/null; then
sed -i "s|</head>|$hook_script\n</head>|" /usr/lib/lua/luci/view/themes/material/header.htm 2>/dev/null
fi
fi
# Method 3: OpenWrt theme header
if [ -f /usr/lib/lua/luci/view/themes/openwrt/header.htm ]; then
if ! grep -q "$hook_marker" /usr/lib/lua/luci/view/themes/openwrt/header.htm 2>/dev/null; then
sed -i "s|</head>|$hook_script\n</head>|" /usr/lib/lua/luci/view/themes/openwrt/header.htm 2>/dev/null
fi
fi
# Method 4: Base sysauth view (fallback for login page)
if [ -f /usr/lib/lua/luci/view/sysauth.htm ]; then
if ! grep -q "$hook_marker" /usr/lib/lua/luci/view/sysauth.htm 2>/dev/null; then
sed -i "s|</head>|$hook_script\n</head>|" /usr/lib/lua/luci/view/sysauth.htm 2>/dev/null
fi
fi
# Method 5: LuCI2 / luci-mod-admin-full footer
if [ -f /www/luci-static/resources/footer.htm ]; then
if ! grep -q "$hook_marker" /www/luci-static/resources/footer.htm 2>/dev/null; then
echo "$hook_script" >> /www/luci-static/resources/footer.htm 2>/dev/null
fi
fi
}
inject_js_hook
# Restart rpcd to load new ubus object
if [ -x /etc/init.d/rpcd ]; then
/etc/init.d/rpcd restart 2>/dev/null
fi
# Restart CrowdSec to pick up new acquisition/parser/scenario
if [ -x /etc/init.d/crowdsec ]; then
/etc/init.d/crowdsec restart 2>/dev/null

View File

@ -0,0 +1,150 @@
#!/bin/sh
# SecuBox Auth Hook - CGI endpoint for LuCI authentication with logging
# Copyright (C) 2024 CyberMind.fr
#
# This CGI script intercepts login attempts and logs failures with the real client IP
# Call via: POST /cgi-bin/secubox-auth-hook
#
# Request body: {"username":"...", "password":"..."}
# Special: If password is "__SECUBOX_LOG_FAILURE__", just log the failure (used by JS hook)
# Response: Same as ubus session.login
. /usr/share/libubox/jshn.sh
LOG_FILE="/var/log/secubox-auth.log"
LOG_TAG="secubox-auth"
# Get client IP from CGI environment
CLIENT_IP="${REMOTE_ADDR:-127.0.0.1}"
# Handle X-Forwarded-For if present (reverse proxy)
if [ -n "$HTTP_X_FORWARDED_FOR" ]; then
CLIENT_IP="${HTTP_X_FORWARDED_FOR%%,*}"
fi
# Sanitize IP (remove IPv6 brackets if present)
CLIENT_IP=$(echo "$CLIENT_IP" | sed 's/^\[//;s/\]$//')
# Log authentication failure
log_failure() {
local user="$1"
local ts=$(date "+%b %d %H:%M:%S")
local hostname=$(cat /proc/sys/kernel/hostname 2>/dev/null || echo "OpenWrt")
# Ensure log file exists
[ -f "$LOG_FILE" ] || { touch "$LOG_FILE"; chmod 644 "$LOG_FILE"; }
# Log to dedicated file for CrowdSec
echo "$ts $hostname $LOG_TAG[$$]: authentication failure for $user from $CLIENT_IP via luci" >> "$LOG_FILE"
# Also log to syslog
logger -t "$LOG_TAG" -p auth.warning "authentication failure for $user from $CLIENT_IP via luci"
}
# Output HTTP headers
echo "Content-Type: application/json"
echo ""
# Handle GET request (for IP detection only)
if [ "$REQUEST_METHOD" = "GET" ]; then
json_init
json_add_string ip "$CLIENT_IP"
json_add_string method "GET"
json_dump
exit 0
fi
# Handle POST request (login attempt or log-only)
if [ "$REQUEST_METHOD" = "POST" ]; then
# Read POST body
read -r body
# Parse JSON input
json_load "$body" 2>/dev/null
if [ $? -ne 0 ]; then
json_init
json_add_boolean success 0
json_add_string error "Invalid JSON"
json_dump
exit 0
fi
json_get_var username username
json_get_var password password
# Validate input
if [ -z "$username" ]; then
json_init
json_add_boolean success 0
json_add_string error "Missing username"
json_dump
exit 0
fi
# Check if this is a log-only request from our JS hook
if [ "$password" = "__SECUBOX_LOG_FAILURE__" ]; then
# Just log the failure - don't attempt login
log_failure "$username"
json_init
json_add_boolean success 1
json_add_string message "Auth failure logged"
json_add_string ip "$CLIENT_IP"
json_add_string username "$username"
json_dump
exit 0
fi
# Normal login flow - validate password
if [ -z "$password" ]; then
json_init
json_add_boolean success 0
json_add_string error "Missing password"
json_dump
exit 0
fi
# Attempt login via ubus
result=$(ubus call session login "{\"username\":\"$username\",\"password\":\"$password\"}" 2>&1)
rc=$?
# Check if login failed
# ubus returns error code or empty session on failure
if [ $rc -ne 0 ]; then
# ubus call failed
log_failure "$username"
json_init
json_add_boolean success 0
json_add_string error "Authentication failed"
json_add_string ip "$CLIENT_IP"
json_dump
elif echo "$result" | grep -q '"ubus_rpc_session": ""'; then
# Empty session token = failed login
log_failure "$username"
json_init
json_add_boolean success 0
json_add_string error "Invalid credentials"
json_add_string ip "$CLIENT_IP"
json_dump
else
# Check if result contains valid session
session=$(echo "$result" | jsonfilter -e '@.ubus_rpc_session' 2>/dev/null)
if [ -z "$session" ] || [ "$session" = "null" ]; then
log_failure "$username"
json_init
json_add_boolean success 0
json_add_string error "Invalid credentials"
json_add_string ip "$CLIENT_IP"
json_dump
else
# Login successful - return the session info
echo "$result"
fi
fi
exit 0
fi
# Unsupported method
json_init
json_add_boolean success 0
json_add_string error "Unsupported method"
json_dump

View File

@ -0,0 +1,15 @@
{
"secubox-auth-logger": {
"description": "SecuBox Authentication Logger",
"read": {
"ubus": {
"secubox.auth-logger": ["log_failure", "get_client_info", "wrapped_login"]
}
},
"write": {
"ubus": {
"secubox.auth-logger": ["log_failure", "wrapped_login"]
}
}
}
}

View File

@ -1,6 +1,6 @@
# CrowdSec Parser for SecuBox Auth Logger
# Parses authentication failures from LuCI/uhttpd and Dropbear
# Format: secubox-auth: Authentication failure for <user> from <ip> via <service>
# Parses authentication failures from LuCI/uhttpd and SSH (OpenSSH/Dropbear)
# Format: secubox-auth[pid]: authentication failure for <user> from <ip> via <service>
name: secubox/openwrt-luci-auth
description: "Parse SecuBox auth failure logs for LuCI and SSH"
@ -9,7 +9,8 @@ onsuccess: next_stage
nodes:
- grok:
pattern: "Authentication failure for %{USERNAME:user} from %{IP:source_ip} via %{WORD:service}"
# Case-insensitive match for "authentication failure"
pattern: "(?i)authentication failure for %{USERNAME:user} from %{IP:source_ip} via %{WORD:service}"
apply_on: message
statics:
- meta: log_type
@ -18,3 +19,5 @@ nodes:
expression: evt.Parsed.service
- meta: source_ip
expression: evt.Parsed.source_ip
- meta: username
expression: evt.Parsed.user

View File

@ -0,0 +1,194 @@
/**
* SecuBox Auth Hook - Intercepts LuCI login failures for CrowdSec
* Copyright (C) 2024 CyberMind.fr
*
* This script hooks into LuCI's authentication system to log
* failed login attempts with the real client IP address.
*
* The hook intercepts XMLHttpRequest calls to session.login and
* reports failures to our CGI endpoint which has access to REMOTE_ADDR.
*/
(function() {
'use strict';
// Only run once
if (window._secuboxAuthHookLoaded) return;
window._secuboxAuthHookLoaded = true;
var AUTH_HOOK_URL = '/cgi-bin/secubox-auth-hook';
// Debounce to avoid multiple logs for same attempt
var lastLogTime = 0;
var lastLogUser = '';
/**
* Log auth failure to our CGI endpoint
* The CGI endpoint gets REMOTE_ADDR and logs with real client IP
*/
function logAuthFailure(username) {
var now = Date.now();
// Debounce: don't log same user within 2 seconds
if (username === lastLogUser && (now - lastLogTime) < 2000) {
return;
}
lastLogTime = now;
lastLogUser = username;
try {
var xhr = new XMLHttpRequest();
xhr.open('POST', AUTH_HOOK_URL, true);
xhr.setRequestHeader('Content-Type', 'application/json');
// Send with dummy password - CGI will detect it's a log-only call
// and just log the failure with the real client IP
xhr.send(JSON.stringify({
username: username || 'root',
password: '__SECUBOX_LOG_FAILURE__'
}));
} catch (e) {
// Silently fail - don't break login flow
}
}
/**
* Check if a ubus response indicates login failure
*/
function isLoginFailure(result) {
if (!result) return false;
// UBUS JSON-RPC response format: { result: [error_code, data] }
if (result.result && Array.isArray(result.result)) {
var errorCode = result.result[0];
var data = result.result[1];
// Error code != 0 means failure
if (errorCode !== 0) return true;
// Check for empty session (credential failure)
if (data && data.ubus_rpc_session === '') return true;
if (data && !data.ubus_rpc_session) return true;
}
// Check for error response
if (result.error) return true;
return false;
}
/**
* Extract username from login call params
*/
function extractUsername(call) {
try {
if (call.params && call.params[2] && call.params[2].username) {
return call.params[2].username;
}
} catch (e) {}
return 'root';
}
/**
* Intercept fetch API calls (modern LuCI)
*/
var originalFetch = window.fetch;
if (originalFetch) {
window.fetch = function(url, options) {
var requestBody = null;
var loginCalls = [];
// Parse request to find login calls
if (url && url.indexOf('ubus') !== -1 && options && options.body) {
try {
requestBody = JSON.parse(options.body);
if (Array.isArray(requestBody)) {
requestBody.forEach(function(call, idx) {
if (call && call.method === 'call' &&
call.params && call.params[1] === 'login') {
loginCalls.push({ call: call, idx: idx });
}
});
}
} catch (e) {}
}
return originalFetch.apply(this, arguments).then(function(response) {
// Check login results
if (loginCalls.length > 0) {
response.clone().json().then(function(data) {
if (Array.isArray(data)) {
loginCalls.forEach(function(item) {
var result = data[item.idx];
if (isLoginFailure(result)) {
logAuthFailure(extractUsername(item.call));
}
});
}
}).catch(function() {});
}
return response;
});
};
}
/**
* Intercept XMLHttpRequest (older LuCI versions)
*/
var originalXHRSend = XMLHttpRequest.prototype.send;
var originalXHROpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
this._secuboxUrl = url;
this._secuboxMethod = method;
return originalXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
var xhr = this;
var url = this._secuboxUrl;
var loginCalls = [];
// Only intercept POST to ubus
if (this._secuboxMethod === 'POST' && url && url.indexOf('ubus') !== -1 && body) {
try {
var parsedBody = JSON.parse(body);
if (Array.isArray(parsedBody)) {
parsedBody.forEach(function(call, idx) {
if (call && call.method === 'call' &&
call.params && call.params[1] === 'login') {
loginCalls.push({ call: call, idx: idx });
}
});
}
} catch (e) {}
}
if (loginCalls.length > 0) {
var originalOnReadyStateChange = xhr.onreadystatechange;
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
var response = JSON.parse(xhr.responseText);
if (Array.isArray(response)) {
loginCalls.forEach(function(item) {
var result = response[item.idx];
if (isLoginFailure(result)) {
logAuthFailure(extractUsername(item.call));
}
});
}
} catch (e) {}
}
if (originalOnReadyStateChange) {
originalOnReadyStateChange.apply(this, arguments);
}
};
}
return originalXHRSend.apply(this, arguments);
};
// Debug message in console
if (window.console && console.log) {
console.log('[SecuBox] Auth hook v1.1 loaded - LuCI login failures will be logged for CrowdSec');
}
})();

View File

@ -0,0 +1,143 @@
#!/bin/sh
# SecuBox Auth Logger - RPCD plugin for LuCI authentication logging
# Copyright (C) 2024 CyberMind.fr
#
# This plugin wraps session.login to log authentication failures
# for CrowdSec detection.
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
LOG_FILE="/var/log/secubox-auth.log"
LOG_TAG="secubox-auth"
# Ensure log file exists
[ -f "$LOG_FILE" ] || { touch "$LOG_FILE"; chmod 644 "$LOG_FILE"; }
# Log authentication failure
log_auth_failure() {
local ip="$1"
local user="${2:-root}"
local service="${3:-luci}"
# Get timestamp in syslog format
local ts=$(date "+%b %d %H:%M:%S")
local hostname=$(cat /proc/sys/kernel/hostname 2>/dev/null || echo "OpenWrt")
# Log to dedicated file for CrowdSec
echo "$ts $hostname $LOG_TAG[$$]: authentication failure for $user from $ip via $service" >> "$LOG_FILE"
# Also log to syslog for visibility
logger -t "$LOG_TAG" -p auth.warning "authentication failure for $user from $ip via $service"
}
# Get client IP from environment or connection info
get_client_ip() {
# Try various methods to get client IP
# Method 1: REMOTE_ADDR (if called via CGI)
[ -n "$REMOTE_ADDR" ] && echo "$REMOTE_ADDR" && return
# Method 2: HTTP_X_FORWARDED_FOR (if behind proxy)
[ -n "$HTTP_X_FORWARDED_FOR" ] && echo "${HTTP_X_FORWARDED_FOR%%,*}" && return
# Method 3: Parse from uhttpd connection (not available in rpcd context)
# Method 4: Default to local if unknown
echo "127.0.0.1"
}
# Perform login via ubus and return result
do_login() {
local username="$1"
local password="$2"
local client_ip="$3"
# Call the real session.login via ubus
local result
result=$(ubus call session login "{\"username\":\"$username\",\"password\":\"$password\"}" 2>&1)
local rc=$?
if [ $rc -ne 0 ] || echo "$result" | grep -q '"ubus_rpc_session": ""' || echo "$result" | grep -qi "error\|denied"; then
# Login failed - log it
log_auth_failure "$client_ip" "$username" "luci"
# Return failure
json_init
json_add_boolean success 0
json_add_string error "Login failed"
json_add_string ip "$client_ip"
json_dump
else
# Login succeeded - return the session token
echo "$result"
fi
}
case "$1" in
list)
# List available methods
cat <<'EOF'
{
"wrapped_login": {
"username": "str",
"password": "str",
"client_ip": "str"
},
"log_failure": {
"ip": "str",
"username": "str",
"service": "str"
},
"get_client_info": {}
}
EOF
;;
call)
case "$2" in
wrapped_login)
# Parse JSON input
read -r input
json_load "$input"
json_get_var username username
json_get_var password password
json_get_var client_ip client_ip
# Use provided IP or try to detect
[ -z "$client_ip" ] && client_ip=$(get_client_ip)
# Perform login with logging
do_login "$username" "$password" "$client_ip"
;;
log_failure)
# Direct logging method for JS to call
read -r input
json_load "$input"
json_get_var ip ip
json_get_var username username
json_get_var service service
[ -z "$ip" ] && ip="unknown"
[ -z "$username" ] && username="root"
[ -z "$service" ] && service="luci"
log_auth_failure "$ip" "$username" "$service"
json_init
json_add_boolean success 1
json_add_string message "Auth failure logged for $ip"
json_dump
;;
get_client_info)
# Return whatever client info we can detect
local ip=$(get_client_ip)
json_init
json_add_string ip "$ip"
json_add_string remote_addr "${REMOTE_ADDR:-unknown}"
json_dump
;;
esac
;;
esac