Auto-merge PR #758 (Chack Agent)

* Improve bounded Unix escalation enumeration and speed

* Expand bounded Unix escalation indicators

* Apply Chack Agent review fixes for PR #758

* Add bounded Unix privilege boundary signals

* Improve bounded Unix credential and service signals

* Apply Chack Agent review fixes for PR #758

* Add bounded Linux privilege indicators and redacted config views

* Expand bounded local privilege reviews

* Fix portable awk quote matching

* Improve bounded Unix privilege escalation review and catalog loading

* Use portable output for generated file selectors

* Expand bounded Unix configuration and privilege review

* Expand bounded Unix privilege review and prune scans

* Expand bounded Unix privilege review and catalog paths

* Refine bounded Unix privilege candidates and portable output

* Apply Chack Agent review fixes for PR #758

* Improve bounded Unix privilege escalation review and scan efficiency

* Improve Linux privilege indicators and builder resilience

* Extend bounded Unix sudo, service, and file path review

* Improve bounded Unix privilege discovery and CLI parsing

* Expand bounded Unix privilege review across existing checks

* Improve bounded local configuration and process review

* Refine bounded Unix listener, sudo, and file-path review

* Make listener fixture portable across socket tools

* Apply Chack Agent review fixes for PR #758

* Correct cached file discovery and configuration review

* Apply Chack Agent review fixes for PR #758

* Fetch current GTFOBins data for each applicable build

* Apply Chack Agent review fixes for PR #758

---------

Co-authored-by: Carlos Polop <[email protected]>
Co-authored-by: chack-agent <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-09 14:21:25 +00:00
1 parent 0854b86570
commit 5f7501e7cf
269 files changed
+29033 -669

No files matched your search

File diff suppressed because it is too large. Load diff
+2
View File
@@ -7,6 +7,8 @@ You can **build you own linpeas which will contain only the checks you want**. T
It's possible to indicate the params `--all`, `--all-no-fat` and `--small` to build the classic `linpeas_fat.sh`, `linpeas.sh` and `linpeas_small.sh` outputs:
- When testing builder changes locally, prefer writing the output to `/tmp` so you don't overwrite tracked release artifacts by accident.
Builds that include GTFOBins-based checks download the current GTFOBins archive once. They require network access and stop if the archive cannot be downloaded or validated.
- **linpeas_fat.sh**: Contains all checks, even third party applications in base64 embedded.
- **linpeas.sh**: Contains all checks, but only the third party application `linux exploit suggester` is embedded. This is the default `linpeas.sh`.
- **linpeas_small.sh**: Contains only the most *important* checks making its size smaller.
@@ -1,11 +1,12 @@
# Title: System Information - Protections
# ID: SY_Protections
# Author: Carlos Polop
# Last Update: 08-07-2026
# Last Update: 09-10-2026
# Description: Check for system security protections and their bypass possibilities:
# - AppArmor/SELinux status and profiles
# - ASLR status
# - Seccomp filters
# - NoNewPrivs for the current process tree
# - Capabilities
# - Fail2ban/CrowdSec intrusion prevention systems (affects su/ssh brute forcing)
# - Pending reboot (running kernel/libraries may be outdated -> kernel exploits)
@@ -28,12 +29,12 @@
# - Capability exploitation
# - Protection circumvention
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1518.001
# Functions Used: echo_no, echo_not_found, print_2title, print_list, warn_exec
# Global Variables:
# Global Variables: $IAMROOT, $ROOT_FOLDER, $SEARCH_IN_FOLDER, $TIMEOUT
# Initial Functions:
# Generated Global Variables: $ASLR, $hypervisorflag, $detectedvirt, $unpriv_userns_clone, $perf_event_paranoid, $mmap_min_addr, $ptrace_scope, $dmesg_restrict, $kptr_restrict, $unpriv_bpf_disabled, $protected_symlinks, $protected_hardlinks, $label, $sysctl_path, $sysctl_var, $zero_color, $nonzero_color, $sysctl_value, $f2b_jails
# Generated Global Variables: $ASLR, $hypervisorflag, $detectedvirt, $unpriv_userns_clone, $perf_event_paranoid, $mmap_min_addr, $ptrace_scope, $dmesg_restrict, $kptr_restrict, $unpriv_bpf_disabled, $protected_symlinks, $protected_hardlinks, $protected_regular, $label, $sysctl_path, $sysctl_var, $zero_color, $nonzero_color, $sysctl_value, $f2b_jails, $no_new_privs_status, $cups_job_timeout, $cups_job_ids, $cups_job_id, $cups_job_doc, $cups_job_file, $cups_job_header
# Fat linpeas: 0
# Small linpeas: 0
@@ -60,6 +61,22 @@ print_sysctl_eq_zero() {
fi
}
# Read only the current process status. A missing procfs or field is unknown,
# which is normal on non-Linux systems; do not infer the setting from a unit file.
read_no_new_privs_status() {
[ -r "$1" ] || { printf 'unknown\n'; return; }
awk '
NR > 256 { exit }
$1 == "NoNewPrivs:" {
found = 1
if (NF == 2 && ($2 == "0" || $2 == "1")) print $2
else print "unknown"
exit
}
END { if (!found) print "unknown" }
' "$1" 2>/dev/null
}
#-- SY) AppArmor
print_2title "Protections" "T1518.001"
print_list "AppArmor enabled? .............. "$NC
@@ -101,6 +118,14 @@ print_list "SELinux enabled? ............... "$NC
print_list "Seccomp enabled? ............... "$NC
([ "$(grep Seccomp /proc/self/status 2>/dev/null | grep -v 0)" ] && echo "enabled" || echo "disabled") | sed "s,disabled,${SED_RED}," | sed "s,enabled,${SED_GREEN},"
print_list "NoNewPrivs (this process)? ..... "$NC
no_new_privs_status=$(read_no_new_privs_status /proc/self/status)
case "$no_new_privs_status" in
1) echo "enabled (1): set-ID and file-cap gains blocked on exec for this process tree" ;;
0) echo "disabled (0) for this process" ;;
*) echo "unknown" ;;
esac
#-- SY) AppArmor
print_list "User namespace? ................ "$NC
if [ "$(cat /proc/self/uid_map 2>/dev/null)" ]; then echo "enabled" | sed "s,enabled,${SED_GREEN},"; else echo "disabled" | sed "s,disabled,${SED_RED},"; fi
@@ -126,6 +151,11 @@ print_sysctl_eq_zero "protected_symlinks? ............ " "/proc/sys/fs/protected
print_sysctl_eq_zero "protected_hardlinks? ........... " "/proc/sys/fs/protected_hardlinks" "protected_hardlinks" "$SED_RED" "$SED_GREEN"
# Linux uses 0 (disabled), 1 (world-writable sticky directories), and 2
# (also group-writable sticky directories). A nonzero value is protective;
# privileged wrappers still need to stop if a rewrite is denied.
print_sysctl_eq_zero "protected_regular? ............ " "/proc/sys/fs/protected_regular" "protected_regular" "$SED_RED" "$SED_GREEN"
print_list "perf_event_paranoid? ........... "$NC
perf_event_paranoid=$(cat /proc/sys/kernel/perf_event_paranoid 2>/dev/null)
if [ -z "$perf_event_paranoid" ]; then
@@ -154,6 +184,14 @@ else
fi
#-- SY) Fail2ban (Intrusion Prevention System)
fail2ban_action_dir_review() {
# Exact, metadata-only check. A symlinked final component can point to an
# unrelated or slow filesystem, so leave that case for manual review.
if ! [ "$IAMROOT" ] && [ ! -L "$1" ] && [ -d "$1" ] && [ -w "$1" ] && [ -x "$1" ]; then
printf ' Fail2ban action directory review candidate: %s is writable/searchable (parent paths, selected jail/action, service identity, effective restart policy, and event trigger unverified).\n' "$1"
fi
}
print_list "Fail2ban present? .............. "$NC
if command -v fail2ban-client >/dev/null 2>&1 || [ -S "/var/run/fail2ban/fail2ban.sock" ] || pgrep -x fail2ban-server >/dev/null 2>&1; then
f2b_jails=$(fail2ban-client status 2>/dev/null | grep "Jail list" | sed "s/.*Jail list:[[:space:]]*//")
@@ -162,6 +200,7 @@ if command -v fail2ban-client >/dev/null 2>&1 || [ -S "/var/run/fail2ban/fail2ba
else
echo "Yes - installed (could not query jails, may need root)" | sed -${E} "s,.*,${SED_GREEN},"
fi
fail2ban_action_dir_review /etc/fail2ban/action.d
else
echo_not_found "fail2ban"
fi
@@ -227,6 +266,44 @@ fi
print_list "Printer? ....................... "$NC
(lpstat -a || system_profiler SPPrintersDataType || echo_no) 2>/dev/null
# A searchable but unlistable CUPS spool can still contain readable retained
# documents. Use completed job IDs only to test exact paths; never print data.
if [ -z "$SEARCH_IN_FOLDER" ] && { [ -z "$ROOT_FOLDER" ] || [ "$ROOT_FOLDER" = / ]; } &&
[ -d /var/spool/cups ] && command -v lpstat >/dev/null 2>&1; then
cups_job_timeout=${TIMEOUT:-$(command -v gtimeout 2>/dev/null)}
if [ -n "$cups_job_timeout" ]; then
cups_job_ids=$("$cups_job_timeout" 2 lpstat -h localhost -W completed -o 2>/dev/null |
awk '
NR > 128 { exit }
$1 ~ /-[0-9]+$/ {
id = $1
sub(/^.*-/, "", id)
sub(/^0+/, "", id)
if (id == "" || length(id) > 9) next
while (length(id) < 5) id = "0" id
if (!(id in seen)) {
seen[id] = 1
print id
if (++count == 20) exit
}
}
')
cups_job_header=0
for cups_job_id in $cups_job_ids; do
for cups_job_doc in 001 002 003; do
cups_job_file="/var/spool/cups/d${cups_job_id}-${cups_job_doc}"
if [ -f "$cups_job_file" ] && [ ! -L "$cups_job_file" ] && [ -r "$cups_job_file" ]; then
if [ "$cups_job_header" -eq 0 ]; then
print_2title "Readable retained CUPS print jobs (paths only)" "T1552.001"
cups_job_header=1
fi
ls -ld "$cups_job_file" 2>/dev/null
fi
done
done
fi
fi
#-- SY) Running in a virtual environment
print_list "Is this a virtual machine? ..... "$NC
hypervisorflag=$(grep flags /proc/cpuinfo 2>/dev/null | grep hypervisor)
@@ -26,7 +26,7 @@
# License: GNU GPL
# Version: 1.0
# Mitre: T1082
# Functions Used: print_2title, print_info, warn_exec, linpeas_start_host_checker_lookup
# Functions Used: checkGlibcTunablesCVE20234911, print_2title, print_info, warn_exec, linpeas_start_host_checker_lookup
# Global Variables: $MACPEAS, $kernelDCW_Ubuntu_Precise_1, $kernelB, $kernelDCW_Ubuntu_Precise_2, $kernelDCW_Ubuntu_Precise_3, $kernelDCW_Ubuntu_Precise_4, $kernelDCW_Ubuntu_Precise_5, $kernelDCW_Ubuntu_Precise_6, $kernelDCW_Rhel5_1, $kernelDCW_Rhel5_2, $kernelDCW_Rhel5_3, $kernelDCW_Rhel6_1, $kernelDCW_Rhel6_2, $kernelDCW_Rhel6_3, $kernelDCW_Rhel6_4, $kernelDCW_Rhel7, $kernelDCW_Ubuntu_Trusty_1, $kernelDCW_Ubuntu_Trusty_2, $kernelDCW_Ubuntu_Trusty_3, $kernelDCW_Ubuntu_Trusty_4, $kernelDCW_Ubuntu_Xenial
# Initial Functions: linpeas_start_host_checker_lookup
# Generated Global Variables:
@@ -37,6 +37,7 @@ print_2title "Operative system" "T1082"
print_info "https://book.hacktricks.wiki/en/linux-hardening/main-system-information/kernel-vulnerability-assessment.html"
(cat /proc/version || uname -a ) 2>/dev/null | sed -${E} "s,$kernelDCW_Ubuntu_Precise_1,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Ubuntu_Precise_2,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Ubuntu_Precise_3,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Ubuntu_Precise_4,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Ubuntu_Precise_5,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Ubuntu_Precise_6,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Ubuntu_Trusty_1,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Ubuntu_Trusty_2,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Ubuntu_Trusty_3,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Ubuntu_Trusty_4,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Ubuntu_Xenial,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Rhel5_1,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Rhel5_2,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Rhel5_3,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Rhel6_1,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Rhel6_2,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Rhel6_3,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Rhel6_4,${SED_RED_YELLOW}," | sed -${E} "s,$kernelDCW_Rhel7,${SED_RED_YELLOW}," | sed -${E} "s,$kernelB,${SED_RED},"
warn_exec lsb_release -a 2>/dev/null
checkGlibcTunablesCVE20234911
if [ "$MACPEAS" ]; then
warn_exec system_profiler SPSoftwareDataType
fi
@@ -1,37 +1,126 @@
# Title: System Information - Sudo Version
# ID: SY_Sudo_version
# Author: Carlos Polop
# Last Update: 07-03-2024
# Description: Check for sudo vulnerabilities and misconfigurations that could lead to privilege escalation:
# - Vulnerable sudo versions with known exploits
# - Common vulnerable versions and CVEs:
# * CVE-2021-3156 (Baron Samedit): Heap overflow in sudo
# * CVE-2021-23239: Potential privilege escalation
# * CVE-2021-23240: Potential privilege escalation
# * CVE-2021-23241: Potential privilege escalation
# - Exploitation methods:
# * Version exploits: Use known exploits for vulnerable sudo versions
# * Common targets: sudo < 1.9.5p2 (Baron Samedit)
# * Exploit techniques:
# - Heap overflow exploitation
# - Race conditions
# - Memory corruption
# - Command injection
# Last Update: 09-10-2026
# Description: Inspect bounded, known sudo paths for upstream version candidates.
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1548.003,T1068
# Functions Used: echo_not_found, print_2title, print_info
# Global Variables: $sudovB
# Functions Used: echo_not_found, lp_trusted_version_path, print_2title, print_info
# Global Variables: $PATH
# Initial Functions:
# Generated Global Variables:
# Generated Global Variables: $sudo_trusted_candidate, $sudo_seen, $sudo_found, $sudo_timeout, $sudo_path_left, $sudo_path_count, $sudo_dir, $sudo_last, $sudo_candidate, $sudo_identity, $sudo_line, $sudo_version, $sudo_parts, $sudo_major, $sudo_minor, $sudo_patch, $sudo_revision, $sudo_host_candidate, $sudo_chroot_candidate
# Fat linpeas: 0
# Small linpeas: 1
# shellcheck shell=sh
print_2title "Sudo version" "T1548.003,T1068"
if [ "$(command -v sudo 2>/dev/null || echo -n '')" ]; then
print_info "https://book.hacktricks.wiki/en/linux-hardening/linux-basics/linux-privilege-escalation/index.html#sudo-version"
sudo -V 2>/dev/null | grep "Sudo ver" | sed -${E} "s,$sudovB,${SED_RED},"
else echo_not_found "sudo"
print_info "https://www.sudo.ws/security/advisories/"
# Only execute a root-owned setuid sudo target. A user-controlled PATH entry may
# contain an arbitrary program named sudo, so do not run it just to get a version.
sudo_version_check() {
sudo_candidate=$1
[ -f "$sudo_candidate" ] && [ -x "$sudo_candidate" ] || return 0
sudo_identity=$(stat -Lc '%d:%i:%u' "$sudo_candidate" 2>/dev/null) ||
sudo_identity=$(stat -L -f '%d:%i:%u' "$sudo_candidate" 2>/dev/null) || return 0
case "
$sudo_seen
" in *"
$sudo_identity
"*) return 0 ;; esac
sudo_seen="${sudo_seen}
${sudo_identity}"
sudo_found=1
case "$sudo_identity" in
*:0) [ -u "$sudo_candidate" ] || {
printf ' %s: root-owned, but not setuid; version query skipped\n' "$sudo_candidate"
return 0
} ;;
*) printf ' %s: not root-owned setuid; version query skipped\n' "$sudo_candidate"
return 0 ;;
esac
if [ -z "$sudo_timeout" ]; then
printf ' %s: root-owned setuid; version query skipped (timeout unavailable)\n' "$sudo_candidate"
return 0
fi
sudo_trusted_candidate=$(lp_trusted_version_path "$sudo_candidate") || {
printf ' %s: version query skipped (executable or ancestor permissions are untrusted)\n' "$sudo_candidate"
return 0
}
sudo_line=$("$sudo_timeout" -k 1 2 "$sudo_trusted_candidate" -V 2>/dev/null | head -c 256 | head -n 1)
sudo_version=$(printf '%s\n' "$sudo_line" | awk '$1 == "Sudo" && $2 == "version" { print $3 }')
if [ -z "$sudo_version" ]; then
printf ' %s: root-owned setuid; version unavailable or query timed out\n' "$sudo_candidate"
return 0
fi
printf ' %s: Sudo version %s\n' "$sudo_candidate" "$sudo_version"
sudo_parts=$(printf '%s\n' "$sudo_version" | sed -nE 's/^([0-9]+)\.([0-9]+)\.([0-9]+)(p([0-9]+))?$/\1 \2 \3 \5/p')
if [ -z "$sudo_parts" ]; then
printf ' Upstream candidate range unknown; check the installed package advisory.\n'
return 0
fi
# Split only the four numeric fields produced by the anchored expression.
set -- $sudo_parts
sudo_major=$1 sudo_minor=$2 sudo_patch=$3 sudo_revision=${4:-0}
sudo_host_candidate=0 sudo_chroot_candidate=0
if [ "$sudo_major" -eq 1 ]; then
if [ "$sudo_minor" -eq 8 ] && [ "$sudo_patch" -ge 8 ]; then
sudo_host_candidate=1
elif [ "$sudo_minor" -eq 9 ]; then
if [ "$sudo_patch" -le 16 ] || { [ "$sudo_patch" -eq 17 ] && [ "$sudo_revision" -eq 0 ]; }; then
sudo_host_candidate=1
fi
if { [ "$sudo_patch" -ge 14 ] && [ "$sudo_patch" -le 16 ]; } ||
{ [ "$sudo_patch" -eq 17 ] && [ "$sudo_revision" -eq 0 ]; }; then
sudo_chroot_candidate=1
fi
fi
fi
if [ "$sudo_host_candidate" -eq 1 ]; then
printf ' CVE-2025-32462 upstream candidate (1.8.8-1.9.17); requires a host-specific sudoers or LDAP rule.\n'
fi
if [ "$sudo_chroot_candidate" -eq 1 ]; then
printf ' CVE-2025-32463 upstream candidate (1.9.14-1.9.17); chroot/NSS behavior is relevant.\n'
fi
if [ "$sudo_host_candidate" -eq 1 ] || [ "$sudo_chroot_candidate" -eq 1 ]; then
printf ' Version alone cannot confirm exposure: vendors backport fixes; check the installed package advisory.\n'
else
printf ' No CVE-2025-32462/32463 upstream candidate by version.\n'
fi
}
sudo_seen=''
sudo_found=''
sudo_timeout=''
if command -v timeout >/dev/null 2>&1; then
sudo_timeout=$(command -v timeout)
elif command -v gtimeout >/dev/null 2>&1; then
sudo_timeout=$(command -v gtimeout)
fi
echo ""
# Probe each PATH entry in order, then the bounded standard locations. Inode
# identity deduplicates aliases (including symlinks) without traversing the disk.
sudo_path_left=$PATH
sudo_path_count=0
while :; do
sudo_path_count=$((sudo_path_count + 1))
[ "$sudo_path_count" -le 24 ] || break
sudo_last=
case "$sudo_path_left" in
*:*) sudo_dir=${sudo_path_left%%:*}; sudo_path_left=${sudo_path_left#*:} ;;
*) sudo_dir=$sudo_path_left; sudo_last=1 ;;
esac
[ -n "$sudo_dir" ] || sudo_dir=.
case "$sudo_dir" in /*) ;; *) sudo_dir="$(pwd -P)/$sudo_dir" ;; esac
sudo_version_check "$sudo_dir/sudo"
[ -n "$sudo_last" ] && break
done
for sudo_dir in /usr/bin /bin /usr/local/bin /usr/local/sbin; do
sudo_version_check "$sudo_dir/sudo"
done
[ -n "$sudo_found" ] || echo_not_found "sudo"
echo ""
@@ -1,48 +1,78 @@
# Title: System Information - USBCreator
# ID: SY_USBCreator
# Author: Carlos Polop
# Last Update: 07-03-2024
# Description: Check for USBCreator vulnerabilities that could lead to privilege escalation:
# - Vulnerable policykit-desktop-privileges versions
# - Common vulnerable versions:
# * policykit-desktop-privileges < 0.21
# - Exploitation methods:
# * D-Bus command injection through USBCreator
# * Abuse of policykit privileges
# * Common attack vectors:
# - D-Bus method call injection
# - PolicyKit authentication bypass
# - Command execution through USB creation
# * Exploit techniques:
# - D-Bus method spoofing
# - PolicyKit privilege escalation
# - USB device creation abuse
# Last Update: 2026-10-09
# Description: Passive review of the USB imaging D-Bus service and legacy passwordless group policy
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1548.003,T1068
# Functions Used: print_2title, print_info
# Global Variables: $DEBUG
# Initial Functions:
# Generated Global Variables: $pc_version, $pc_length, $pc_major, $pc_minor
# Generated Global Variables: $usbcreator_service, $usbcreator_policy, $usbcreator_size, $usbcreator_groups
# Fat linpeas: 0
# Small linpeas: 0
# Read only the exact legacy vendor policy file, after a metadata size check.
# A matching stanza is a review lead: later rules and the installed helper's
# implementation can change the effective authorization and file-copy behavior.
usbcreator_passwordless_group_policy() (
usbcreator_policy=$1
[ -f "$usbcreator_policy" ] && [ ! -L "$usbcreator_policy" ] &&
[ -r "$usbcreator_policy" ] || exit 1
usbcreator_size=$(LC_ALL=C ls -ldn "$usbcreator_policy" 2>/dev/null | awk 'NR == 1 { print $5 }')
case "$usbcreator_size" in ''|*[!0-9]*) exit 1 ;; esac
[ "$usbcreator_size" -le 16384 ] || exit 1
awk '
function match_stanza() { return identity && action && active }
/^\[/ { if (match_stanza()) found = 1; identity = action = active = 0; next }
NR > 200 { overflow = 1; exit }
{
line = $0
sub(/^[[:space:]]*/, "", line)
sub(/[[:space:]]*$/, "", line)
if (line ~ /^Identity[[:space:]]*=/) {
sub(/^[^=]*=[[:space:]]*/, "", line)
identity = line ~ /(^|;)unix-group:(admin|sudo)(;|$)/
} else if (line ~ /^Action[[:space:]]*=/) {
sub(/^[^=]*=[[:space:]]*/, "", line)
action = line ~ /(^|;)com[.]ubuntu[.]usbcreator[.]image(;|$)/
} else if (line ~ /^ResultActive[[:space:]]*=/) {
sub(/^[^=]*=[[:space:]]*/, "", line)
active = line == "yes"
}
}
END { if (overflow) exit 1; if (match_stanza()) found = 1; if (found) exit 0; exit 1 }
' "$usbcreator_policy"
)
if (busctl list 2>/dev/null | grep -q com.ubuntu.USBCreator) || [ "$DEBUG" ]; then
usbcreator_service=''
for usbcreator_policy in /etc/dbus-1/system-services/com.ubuntu.USBCreator.service \
/run/dbus-1/system-services/com.ubuntu.USBCreator.service \
/usr/local/share/dbus-1/system-services/com.ubuntu.USBCreator.service \
/usr/share/dbus-1/system-services/com.ubuntu.USBCreator.service; do
if [ -f "$usbcreator_policy" ] && [ ! -L "$usbcreator_policy" ]; then
usbcreator_service=$usbcreator_policy
break
fi
done
if [ -n "$usbcreator_service" ] || [ "$DEBUG" ]; then
print_2title "USBCreator" "T1548.003,T1068"
print_info "https://book.hacktricks.wiki/en/linux-hardening/processes-crontab-systemd-dbus/d-bus-enumeration-and-command-injection-privilege-escalation.html"
pc_version=$(dpkg -l 2>/dev/null | grep policykit-desktop-privileges | grep -oP "[0-9][0-9a-zA-Z\.]+")
if [ -z "$pc_version" ]; then
pc_version=$(apt-cache policy policykit-desktop-privileges 2>/dev/null | grep -oP "\*\*\*.*" | cut -d" " -f2)
if [ -n "$usbcreator_service" ]; then
printf 'USB imaging system-bus service descriptor: %s (installed/activatable candidate; effective service and patch state unverified)\n' "$usbcreator_service"
fi
if [ -n "$pc_version" ]; then
pc_length=${#pc_version}
pc_major=$(echo "$pc_version" | cut -d. -f1)
pc_minor=$(echo "$pc_version" | cut -d. -f2)
if [ "$pc_length" -eq 4 ] && [ "$pc_major" -eq 0 ] && [ "$pc_minor" -lt 21 ]; then
echo "Vulnerable!!" | sed -${E} "s,.*,${SED_RED},"
fi
if [ -n "$usbcreator_service" ] && [ "$(id -u 2>/dev/null)" != 0 ]; then
usbcreator_groups=$(id -Gn 2>/dev/null)
case " $usbcreator_groups " in
*' sudo '*|*' admin '*)
usbcreator_policy=/var/lib/polkit-1/localauthority/10-vendor.d/com.ubuntu.desktop.pkla
if usbcreator_passwordless_group_policy "$usbcreator_policy"; then
printf 'USB imaging legacy group policy candidate: %s (image action permits an admin group without a prompt in an active session; effective rules and helper behavior unverified)\n' "$usbcreator_policy"
fi
;;
esac
fi
fi
echo ""
echo ""
@@ -1,8 +1,8 @@
# Title: System Information - Mounts
# ID: SY_Mounts
# Author: Carlos Polop
# Last Update: 07-09-2026
# Description: Check for mount point misconfigurations that could lead to privilege escalation, udisks2 CVE-2026-7867 exposure, and XFSTango CVE-2026-80530 exposure:
# Last Update: 09-10-2026
# Description: Check mount point misconfigurations, bounded local NFS export policy, udisks2 CVE-2026-7867 exposure, and XFSTango CVE-2026-80530 exposure:
# - Unmounted filesystems
# - Mount point permissions
# - Mount options
@@ -26,19 +26,110 @@
# License: GNU GPL
# Version: 1.2
# Mitre: T1068,T1082,T1120
# Functions Used: checkUDisksCVE20267867, checkXFSTangoCVE202680530, print_2title, print_info
# Functions Used: checkLibblockdevCVE20256019, checkUDisksCVE20267867, checkXFSTangoCVE202680530, print_2title, print_info
# Global Variables: $DEBUG, $SEARCH_IN_FOLDER, $mountG, $mountpermsB, $mountpermsG, $notmounted, $Wfolders, $mounted
# Initial Functions:
# Generated Global Variables:
# Generated Global Variables: $nfs_exports_file, $nfs_exports_size, $nfs_exports_seen, $nfs_exports_output, $nfs_exports_result
# Fat linpeas: 0
# Small linpeas: 1
fuse_allow_other_state() {
[ -f "$1" ] && [ -r "$1" ] || { printf 'unknown\n'; return; }
awk '
NR > 256 { truncated = 1; exit }
/^[[:space:]]*user_allow_other[[:space:]]*$/ { enabled = 1; exit }
END {
if (enabled) print "enabled"
else if (truncated) print "unknown"
else print "disabled"
}
' "$1" 2>/dev/null
}
# Inspect only short, regular local policy files. The server's effective
# exports, security flavor, clients, and numeric UID mapping remain unknown.
nfs_export_file_candidates() {
[ -f "$1" ] && [ -r "$1" ] && [ ! -L "$1" ] || return 0
nfs_exports_size=$(dd if="$1" bs=16385 count=1 2>/dev/null | wc -c | tr -d '[:space:]') || :
case "$nfs_exports_size" in ''|*[!0-9]*) return 0 ;; esac
if [ "$nfs_exports_size" -gt 16384 ]; then
printf 'NFS export review partial (16 KiB file limit): %s\n' "$1"
return 0
fi
[ "$nfs_exports_size" -gt 0 ] || return 0
dd if="$1" bs=16384 count=1 2>/dev/null | awk -v source="$1" '
NR > 128 { print "NFS export review partial (128-line limit): " source; exit }
length($0) > 512 {
if (!long_line++) print "NFS export review partial (long line): " source
next
}
{
line = $0
sub(/[[:space:]]+#.*/, "", line)
if (line ~ /^[[:space:]]*#/) next
candidate = 0
while (match(line, /\([^)]*\)/)) {
options = substr(line, RSTART + 1, RLENGTH - 2)
gsub(/[[:space:]]/, "", options)
if (options ~ /(^|,)rw(,|$)/ &&
options !~ /(^|,)all_squash(,|$)/ &&
(options !~ /(^|,)sec=/ || options ~ /(^|,)sec=([^,:]*:)*sys([,:]|$)/)) candidate = 1
line = substr(line, RSTART + RLENGTH)
}
if (!candidate) next
if (++matches <= 4) printf "NFS writable export identity review candidate: %s:%d\n", source, NR
else if (matches == 5) { print "NFS export review partial (4-candidate file limit): " source; exit }
}
' 2>/dev/null
}
if ! [ "$SEARCH_IN_FOLDER" ]; then
checkLibblockdevCVE20256019
checkUDisksCVE20267867
checkXFSTangoCVE202680530
fi
if [ -e /etc/fuse.conf ] || [ -L /etc/fuse.conf ]; then
print_2title "FUSE cross-user mount policy" "T1082"
case "$(fuse_allow_other_state /etc/fuse.conf)" in
enabled) echo "user_allow_other enabled: non-root mounts may opt into access by other users, including root; review privileged writes into caller-controlled paths" ;;
disabled) echo "user_allow_other disabled in /etc/fuse.conf" ;;
*) echo "user_allow_other unknown (unreadable, non-regular, or beyond first 256 lines)" ;;
esac
echo ""
fi
# Live-host policy must not be mixed into an extracted filesystem review.
if ! [ "$SEARCH_IN_FOLDER" ]; then
nfs_exports_seen=0
nfs_exports_output=""
for nfs_exports_file in /etc/exports /etc/exports.d/*.exports; do
case "$nfs_exports_file" in
/etc/exports.d/*)
[ -d /etc/exports.d ] && [ ! -L /etc/exports.d ] || continue
nfs_exports_seen=$((nfs_exports_seen + 1))
if [ "$nfs_exports_seen" -gt 8 ]; then
nfs_exports_output="${nfs_exports_output}NFS export review partial (8 drop-in limit).
"
break
fi
;;
esac
nfs_exports_result=$(nfs_export_file_candidates "$nfs_exports_file") || :
if [ -n "$nfs_exports_result" ]; then
nfs_exports_output="${nfs_exports_output}${nfs_exports_result}
"
fi
done
if [ -n "$nfs_exports_output" ]; then
print_2title "NFS writable export identity mapping (passive review)" "T1082"
printf '%s' "$nfs_exports_output"
echo "Verify active export policy, allowed clients, AUTH_SYS, numeric UID/GID mapping, ACLs, and nosuid/SELinux before treating a writable export as cross-user execution."
echo ""
fi
fi
if [ -f "/etc/fstab" ] || [ "$DEBUG" ]; then
print_2title "Unmounted file-system?" "T1082,T1120"
print_info "Check if you can mount umounted devices"
@@ -6,10 +6,10 @@
# License: GNU GPL
# Version: 1.0
# Mitre: T1613,T1611
# Functions Used: containerCheck, echo_no, enumerateDockerSockets, print_2title, print_list, warn_exec
# Functions Used: checkDockerOverlayStorage, containerCheck, echo_no, enumerateDockerSockets, print_2title, print_list, warn_exec
# Global Variables: $containerType
# Initial Functions: containerCheck
# Generated Global Variables: $containerCounts, $crictlcontainers, $ctrcontainers, $dockercontainers, $lxccontainers, $nerdctlcontainers, $podmancontainers, $rktcontainers
# Generated Global Variables: $containerCounts, $crictlcontainers, $ctrcontainers, $dockercontainers, $docker_overlay_candidates, $lxccontainers, $minikube_log_checked, $minikube_log_home, $minikube_log_parent, $minikube_log_path, $minikube_log_version, $nerdctlcontainers, $podmancontainers, $rktcontainers
# Fat linpeas: 0
# Small linpeas: 1
@@ -43,6 +43,32 @@ print_runtime_info() {
fi
}
minikube_crio_log_probe() {
minikube_log_checked=0
for minikube_log_home in "$@"; do
minikube_log_checked=$((minikube_log_checked + 1))
[ "$minikube_log_checked" -le 8 ] || break
minikube_log_parent="${minikube_log_home%/*}"
minikube_log_path="$minikube_log_home/.minikube/logs/lastStart.txt"
# Avoid links into unrelated or remote trees; only inspect this exact log.
[ -L "$minikube_log_parent" ] && continue
[ -L "$minikube_log_home" ] && continue
[ -L "$minikube_log_home/.minikube" ] && continue
[ -L "$minikube_log_home/.minikube/logs" ] && continue
[ -L "$minikube_log_path" ] && continue
[ -f "$minikube_log_path" ] && [ -r "$minikube_log_path" ] || continue
minikube_log_version="$(head -c 65536 "$minikube_log_path" 2>/dev/null |
head -n 200 |
sed -n 's/.*Preparing Kubernetes .* on CRI-O \([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\) .*/\1/p' |
head -n 1)"
[ "${#minikube_log_version}" -le 20 ] || continue
case "$minikube_log_version" in
''|*[!0-9.]*) continue ;;
esac
print_list "Minikube startup log CRI-O ....$NC $minikube_log_version (historical; verify live runtime, fixes, Pod creation rights, seccomp/userns)"
done
}
get_runtime_container_count() {
if has_runtime_cli "$1"; then
shift
@@ -86,6 +112,7 @@ print_runtime_info podman "Podman info ................" podman info
print_runtime_info lxc "LXC version ................" lxc version
print_runtime_info lxc "LXC info ..................." lxc info
print_runtime_info crio "CRI-O version ..............." crio --version
minikube_crio_log_probe /root /home/*
print_runtime_info runc "runc version ..............." runc --version
print_runtime_info crun "crun version ..............." crun --version
print_runtime_info nerdctl "nerdctl version ............" nerdctl version
@@ -95,6 +122,13 @@ print_runtime_info ctr "ctr version ................" ctr version
print_list "Interesting runtime sockets ... "$NC
enumerateDockerSockets
docker_overlay_candidates="$(checkDockerOverlayStorage)"
if [ "$docker_overlay_candidates" ]; then
print_list "Traversable Docker overlay roots (review candidate; mount lacks nosuid/noexec):\n"$NC
printf '%s\n' "$docker_overlay_candidates"
print_list "Requires a root-owned SetUID file, compatible rootful/userns mapping, and effective execution policy; newer daemon versions may leave old container permissions until restart. No file search was run.\n"$NC
fi
print_list "Any running containers? ........ "$NC
# Get counts of running containers for each platform
dockercontainers=0
@@ -23,9 +23,10 @@ if echo "$containerType" | grep -qi "docker"; then
enumerateDockerSockets
print_list "Docker version .................$NC$dockerVersion"
checkDockerVersionExploits
print_list "Vulnerable to CVE-2019-5736 ....$NC$VULN_CVE_2019_5736"$NC | sed -${E} "s,Yes,${SED_RED_YELLOW},"
print_list "Vulnerable to CVE-2019-13139 ...$NC$VULN_CVE_2019_13139"$NC | sed -${E} "s,Yes,${SED_RED_YELLOW},"
print_list "Vulnerable to CVE-2021-41091 ...$NC$VULN_CVE_2021_41091"$NC | sed -${E} "s,Yes,${SED_RED_YELLOW},"
print_list "Docker version may predate bundled runc CVE-2019-5736 fix ...$NC$VULN_CVE_2019_5736"$NC | sed -${E} "s,Yes,${SED_RED_YELLOW},"
print_list "Docker version may predate CVE-2019-13139 fix ...$NC$VULN_CVE_2019_13139"$NC | sed -${E} "s,Yes,${SED_RED_YELLOW},"
print_list "Docker version may predate CVE-2021-41091 fix ...$NC$VULN_CVE_2021_41091"$NC | sed -${E} "s,Yes,${SED_RED_YELLOW},"
print_list "A newer daemon version does not rule out stale permissions in containers that have not restarted.\n"$NC
if [ "$inContainer" ]; then
checkDockerRootless
print_list "Rootless Docker? ............... $DOCKER_ROOTLESS\n"$NC | sed -${E} "s,No,${SED_RED}," | sed -${E} "s,Yes,${SED_GREEN},"
@@ -9,7 +9,7 @@
# Functions Used: print_2title, print_3title
# Global Variables: $containerType, $EXTRA_CHECKS
# Initial Functions: containerCheck
# Generated Global Variables: $k8s_aws_bucket, $k8s_cap_eff, $k8s_cap_low, $k8s_cfg, $k8s_cfg_env, $k8s_context_name, $k8s_count, $k8s_current_context, $k8s_dir, $k8s_direct_base, $k8s_direct_ca, $k8s_direct_host, $k8s_direct_port, $k8s_direct_token, $k8s_direct_token_file, $k8s_docker_host, $k8s_file, $k8s_gcs_bucket, $k8s_mount, $k8s_mount_options, $k8s_mount_root, $k8s_namespace, $k8s_node_address, $k8s_node_addresses, $k8s_node_name, $k8s_ns_one, $k8s_ns_self, $k8s_pid, $k8s_probe_host, $k8s_probe_port, $k8s_probe_scheme, $k8s_probe_status, $k8s_rc, $k8s_readable, $k8s_root, $k8s_root_count, $k8s_runc_version, $k8s_socket, $k8s_writable
# Generated Global Variables: $k8s_access_exit, $k8s_access_http, $k8s_access_json, $k8s_access_response, $k8s_access_result, $k8s_aws_bucket, $k8s_cap_eff, $k8s_cap_low, $k8s_cfg, $k8s_cfg_env, $k8s_context_name, $k8s_count, $k8s_current_context, $k8s_dir, $k8s_direct_base, $k8s_direct_ca, $k8s_direct_host, $k8s_direct_port, $k8s_direct_token, $k8s_direct_token_file, $k8s_docker_host, $k8s_file, $k8s_gcs_bucket, $k8s_mount, $k8s_mount_options, $k8s_mount_root, $k8s_namespace, $k8s_node_address, $k8s_node_addresses, $k8s_node_name, $k8s_ns_one, $k8s_ns_self, $k8s_pid, $k8s_probe_host, $k8s_probe_port, $k8s_probe_scheme, $k8s_probe_status, $k8s_rc, $k8s_readable, $k8s_root, $k8s_root_count, $k8s_runc_version, $k8s_secret_response, $k8s_secret_exit, $k8s_secret_http, $k8s_secret_json, $k8s_secret_summary, $k8s_socket, $k8s_writable, $count, $keys, $continued, $k8s_curl_limit
# Fat linpeas: 0
# Small linpeas: 0
@@ -371,7 +371,109 @@ k8s_sa_api_get() {
"$k8s_direct_base$1" 2>/dev/null
}
# Bound captured bytes even on curl versions whose --max-filesize only checks
# Content-Length. Keep curl's exit status in-band because head owns the pipeline
# status. A final dot preserves trailing newlines in command substitutions.
# Callers reserve 15 bytes: HTTP status (4), exit trailer (up to 10), and
# one over-limit byte. Reject that extra byte before parsing any response.
k8s_bounded_curl() (
k8s_curl_limit=$1
shift
{ curl "$@"; printf '\nexit=%s\n' "$?"; } | head -c "$k8s_curl_limit"
printf '.'
)
k8s_sa_kube_system_secret_access() {
# SelfSubjectAccessReview evaluates the mounted token without reading Secrets.
k8s_access_response="$(printf 'header = "Authorization: Bearer %s"\n' "$k8s_direct_token" |
k8s_bounded_curl 4111 -q --config - -fsS --connect-timeout 2 --max-time 5 --max-filesize 4096 \
--cacert "$k8s_direct_ca" -H 'Accept: application/json' \
-H 'Content-Type: application/json' \
--data-binary '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","spec":{"resourceAttributes":{"namespace":"kube-system","verb":"list","resource":"secrets"}}}' \
-w '\n%{http_code}' \
"$k8s_direct_base/apis/authorization.k8s.io/v1/selfsubjectaccessreviews" 2>/dev/null)"
k8s_access_response=${k8s_access_response%.}
if [ "$(printf '%s' "$k8s_access_response" | wc -c)" -gt 4110 ]; then
echo unknown
return
fi
k8s_access_exit="$(printf '%s' "$k8s_access_response" | tail -n 1)"
k8s_access_exit=${k8s_access_exit#exit=}
k8s_access_response=${k8s_access_response%exit=*}
k8s_access_response=${k8s_access_response%?}
k8s_access_http="$(printf '%s' "$k8s_access_response" | tail -c 3)"
case "$k8s_access_exit:$k8s_access_http" in 0:200|0:201) ;; *) echo unknown; return ;; esac
k8s_access_json="${k8s_access_response%????}"
k8s_access_result="$(printf '%s' "$k8s_access_json" | jq -r '
if (.status | type) != "object" or
(.status.allowed | type) != "boolean" or
((.status.evaluationError // "") != "") or
(.status.allowed == true and .status.denied == true)
then "unknown"
elif .status.allowed then "allowed"
else "denied"
end
' 2>/dev/null | head -n 1)"
case "$k8s_access_result" in allowed|denied) echo "$k8s_access_result" ;; *) echo unknown ;; esac
}
k8s_scan_sa_secrets() {
# The API response contains values; keep it in memory and emit only selected metadata.
# Bound the capture independently of Content-Length and curl version.
k8s_secret_response="$(printf 'header = "Authorization: Bearer %s"\n' "$k8s_direct_token" |
k8s_bounded_curl 1048591 -q --config - -fsS --connect-timeout 2 --max-time 5 --max-filesize 1048576 \
--cacert "$k8s_direct_ca" -H 'Accept: application/json' -w '\n%{http_code}' \
"$k8s_direct_base/api/v1/namespaces/$k8s_namespace/secrets?limit=40" 2>/dev/null)"
k8s_secret_response=${k8s_secret_response%.}
if [ "$(printf '%s' "$k8s_secret_response" | wc -c)" -gt 1048590 ]; then
echo ' secrets: response exceeded 1 MiB; inventory skipped'
return
fi
k8s_secret_exit="$(printf '%s' "$k8s_secret_response" | tail -n 1)"
k8s_secret_exit=${k8s_secret_exit#exit=}
k8s_secret_response=${k8s_secret_response%exit=*}
k8s_secret_response=${k8s_secret_response%?}
k8s_secret_http="$(printf '%s' "$k8s_secret_response" | tail -c 3)"
case "$k8s_secret_exit:$k8s_secret_http" in
0:200) ;;
22:403) echo ' secrets: access denied (RBAC)' ; return ;;
22:401) echo ' secrets: authentication denied' ; return ;;
22:404) echo ' secrets: namespace unavailable' ; return ;;
28:*) echo ' secrets: request timed out' ; return ;;
63:*) echo ' secrets: response exceeded 1 MiB; inventory skipped' ; return ;;
*) echo ' secrets: API request failed or returned an unexpected status' ; return ;;
esac
k8s_secret_json="${k8s_secret_response%????}"
k8s_secret_summary="$(printf '%s' "$k8s_secret_json" | jq -r '
if type != "object" or (.items | type) != "array" or
any(.items[]; type != "object" or (.metadata.name | type) != "string" or
((.type // "") | type) != "string" or
((.data // {}) | type) != "object" or
((.stringData // {}) | type) != "object")
then error("invalid Secret list")
else
(.items | length) as $count |
((.metadata["continue"] // "") != "") as $continued |
if $count == 0 then " secrets: none (accessible list)"
else
.items[:40][] |
((.data // {} | keys) + (.stringData // {} | keys) | unique) as $keys |
" secrets: name=\(.metadata.name | @json) type=\((.type // "unknown") | @json) keys=\($keys[:20] | @json)\(if ($keys | length) > 20 then " (additional keys omitted)" else "" end)"
end,
if $count > 40 or $continued then " secrets: additional results omitted (40 shown; no pagination)" else empty end
end
' 2>/dev/null)" || {
echo ' secrets: malformed API response; inventory unknown'
return
}
printf '%s\n' "$k8s_secret_summary"
}
k8s_scan_sa_api() {
if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
echo ' Kubernetes API discovery skipped (curl or jq unavailable)'
return
fi
k8s_direct_api_ready "$@" || return
echo ' Kubernetes API discovery with mounted service account (names and settings only):'
k8s_sa_api_get '/api/v1/namespaces?limit=100' |
@@ -382,11 +484,14 @@ k8s_scan_sa_api() {
(.spec.containers[]? | " container \(.name) privileged=\(.securityContext.privileged // false) allowPE=\((.securityContext.allowPrivilegeEscalation | if . == null then "default" else . end)) caps=\((.securityContext.capabilities.add // []) | join(","))"),
(.spec.volumes[]? | select(.hostPath.path != null) | " hostPath \(.hostPath.path)")
)' 2>/dev/null | head -n 100 | sed 's/^/ pod: /'
for k8s_file in services serviceaccounts secrets; do
for k8s_file in services serviceaccounts; do
k8s_sa_api_get "/api/v1/namespaces/$k8s_namespace/$k8s_file?limit=100" |
jq -r '.items[]? | .metadata.name // empty' 2>/dev/null | head -n 40 |
sed "s/^/ $k8s_file: /"
done
k8s_scan_sa_secrets
printf ' kube-system secrets list authorization (mounted identity): %s\n' \
"$(k8s_sa_kube_system_secret_access)"
k8s_sa_api_get '/api/v1/nodes?limit=100' |
jq -r '.items[]?.metadata.name // empty' 2>/dev/null | head -n 40 | sed 's/^/ node: /'
k8s_direct_token=''
@@ -421,6 +526,10 @@ k8s_scan_kubectl() {
echo ' Current context authorization rules:'
k8s_kubectl --request-timeout=5s auth can-i --list -n "$k8s_namespace" \
2>/dev/null | head -n 80
k8s_access_result="$(k8s_kubectl --request-timeout=5s auth can-i list secrets \
-n kube-system 2>/dev/null | head -n 1)"
case "$k8s_access_result" in yes) k8s_access_result=allowed ;; no) k8s_access_result=denied ;; *) k8s_access_result=unknown ;; esac
printf ' kube-system secrets list authorization (current context): %s\n' "$k8s_access_result"
echo ' Other configured context authorization rules (up to five):'
k8s_kubectl config get-contexts -o name 2>/dev/null | head -n 5 |
while IFS= read -r k8s_context_name; do
@@ -1,13 +1,11 @@
# Title: Container - Writable bind mounts without nosuid (SUID risk)
# Title: Container - Possible shared writable mounts (SUID conditions)
# ID: CT_RW_bind_mounts_nosuid
# Author: HT Bot
# Last Update: 17-09-2025
# Description: Detect writable bind-mounted paths inside containers that are not mounted with nosuid.
# If the container user is root and the mount is a host bind mount without nosuid, an attacker may
# be able to drop a SUID binary on the shared path and execute it from the host to escalate to root
# (classic container-to-host breakout via writable bind mount).
# Last Update: 09-10-2026
# Description: Show ordinary writable mounts that may be shared with a host.
# Mountinfo does not identify bind mounts or prove that a path is host-backed.
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1611
# Functions Used: containerCheck, print_2title, print_list, print_info
# Global Variables: $inContainer
@@ -16,32 +14,101 @@
# Fat linpeas: 0
# Small linpeas: 1
# Keep mountinfo's octal path escapes intact so spaces and tabs cannot split rows.
# The two groups put non-root filesystem roots first, but root=/ is retained:
# a bind of an entire filesystem has root=/ too. At most ten rows are printed.
ct_rw_mountinfo_candidates() {
awk '
function hasopt(opts, opt) { return index("," opts ",", "," opt ",") != 0 }
function ordinary(fs, target) {
if (fs ~ /^(proc|sysfs|devtmpfs|devpts|tmpfs|cgroup|cgroup2|mqueue|pstore|securityfs|debugfs|tracefs|configfs|fusectl|hugetlbfs|autofs|rpc_pipefs|binfmt_misc|nsfs|bpf)$/) return 0
if (target == "/" && fs == "overlay") return 0
if (target ~ /^\/(proc|sys|dev)(\/|$)/) return 0
if (target == "/etc/hosts" || target == "/etc/hostname" || target == "/etc/resolv.conf" || target ~ /^\/run\/secrets(\/|$)/) return 0
return 1
}
{
sep = 0
for (i = 7; i <= NF; i++) if ($i == "-") { sep = i; break }
if (!sep || sep + 3 > NF) next
root = $4; target = $5; opts = $6
fs = $(sep + 1); source = $(sep + 2); superopts = $(sep + 3)
if (!ordinary(fs, target) || !hasopt(opts, "rw") || !hasopt(superopts, "rw")) next
suid = hasopt(opts, "nosuid") ? "blocked-here" : "not-blocked-here"
exec = hasopt(opts, "noexec") ? "blocked-here" : "not-blocked-here"
group = root == "/" ? 2 : 1
count[group]++
if (count[group] <= 10) rows[group, count[group]] = " mount(raw)=" target " root(raw)=" root " fs=" fs " source(raw)=" source " opts=" opts " local-suid=" suid " local-exec=" exec
}
END {
shown = 0
for (group = 1; group <= 2; group++)
for (i = 1; i <= count[group] && shown < 10; i++) { print rows[group, i]; shown++ }
total = count[1] + count[2]
if (total > shown) print " ... " total - shown " more ordinary writable mounts omitted"
}
' "$1" 2>/dev/null
}
# /proc/mounts has no filesystem-root field, so this is only a generic list.
ct_rw_mounts_fallback() {
awk '
function hasopt(opts, opt) { return index("," opts ",", "," opt ",") != 0 }
$3 !~ /^(proc|sysfs|devtmpfs|devpts|tmpfs|cgroup|cgroup2|mqueue|pstore|securityfs|debugfs|tracefs|configfs|fusectl|hugetlbfs|autofs|rpc_pipefs|binfmt_misc|nsfs|bpf)$/ &&
$2 !~ /^\/(proc|sys|dev)(\/|$)/ && $2 != "/" &&
$2 != "/etc/hosts" && $2 != "/etc/hostname" && $2 != "/etc/resolv.conf" &&
hasopt($4, "rw") {
count++
if (count <= 10) print " mount(raw)=" $2 " fs=" $3 " source(raw)=" $1 " opts=" $4
}
END { if (count > 10) print " ... " count - 10 " more writable mounts omitted" }
' "$1" 2>/dev/null
}
ct_rw_uid_note() {
if [ "$2" != "0" ]; then
echo " Current effective UID is not 0; container-root access is an additional condition."
elif [ ! -r "$1" ]; then
echo " UID 0 mapping is unknown."
else
awk '
$1 == 0 && $3 > 0 {
if ($2 == 0) print " UID 0 maps to UID 0 in the parent user namespace; host-root mapping is unverified."
else print " UID 0 is remapped in the parent user namespace; host-root ownership is less likely."
found = 1; exit
}
END { if (!found) print " UID 0 mapping is unknown." }
' "$1" 2>/dev/null
fi
}
containerCheck
if [ "$inContainer" ]; then
echo ""
print_2title "Container - Writable bind mounts w/o nosuid (SUID persistence risk)" "T1611"
print_2title "Container - Possible shared writable mounts (SUID conditions)" "T1611"
print_info "https://book.hacktricks.wiki/en/linux-hardening/containers-namespaces/container-security/sensitive-host-mounts.html"
if [ -r /proc/self/mountinfo ]; then
CT_RW_bind_mounts_matches=$(grep -E "(^| )bind( |$)" /proc/self/mountinfo 2>/dev/null | grep -E "(^|,)rw(,|$)" | grep -v "nosuid" || true)
CT_RW_bind_mounts_matches=$(ct_rw_mountinfo_candidates /proc/self/mountinfo)
print_list "Ordinary writable mounts (mountinfo) ......."
elif [ -r /proc/mounts ]; then
CT_RW_bind_mounts_matches=$(ct_rw_mounts_fallback /proc/mounts)
print_list "Generic writable mounts (/proc/mounts) ...."
echo " Mountinfo unavailable; filesystem-root and bind classification unavailable."
else
CT_RW_bind_mounts_matches=$(mount -l 2>/dev/null | grep -E "bind" | grep -E "(^|,)rw(,|$)" | grep -v "nosuid" || true)
CT_RW_bind_mounts_matches=""
print_list "Writable mount data ......................."
echo " Unavailable"
fi
if [ -z "$CT_RW_bind_mounts_matches" ]; then
print_list "Writable bind mounts without nosuid ............ No"
else
print_list "Writable bind mounts without nosuid ............ Yes" | sed -${E} "s,Yes,${SED_RED},"
echo "$CT_RW_bind_mounts_matches" | sed -${E} "s,/proc/self/mountinfo,${SED_GREEN},"
echo ""
if [ "$(id -u 2>/dev/null)" = "0" ]; then
print_list "Note"; echo ": You are root inside a container and there are writable bind mounts without nosuid." | sed -${E} "s,.*,${SED_RED},"
echo " If the path is shared with the host and executable there, you may plant a SUID binary (e.g., copy /bin/bash and chmod 6777)"
echo " and execute it from the host to obtain root. Ensure proper authorization before testing."
else
print_list "Note"; echo ": Current user is not root; if you obtain container root, these mounts may enable host escalation via SUID planting." | sed -${E} "s,.*,${SED_RED},"
fi
if [ -n "$CT_RW_bind_mounts_matches" ]; then
printf '%s\n' "$CT_RW_bind_mounts_matches"
elif [ -r /proc/self/mountinfo ] || [ -r /proc/mounts ]; then
echo " No ordinary writable mounts observed; this does not rule out host shares."
fi
ct_rw_uid_note /proc/self/uid_map "$(id -u 2>/dev/null)"
echo " nosuid/noexec describe this mount view only; host mount options may differ."
echo " Verify host path identity, ownership, mount options, security policy and host access separately."
echo ""
fi
@@ -1,23 +1,70 @@
# Title: Processes & Cron & Services & Timers - Services and Service Files
# ID: PR_Services
# Author: Carlos Polop
# Last Update: 16-09-2026
# Description: Services and service files analysis with privilege escalation vectors, including replaceable root-service executables.
# Last Update: 09-10-2026
# Description: Services and service files analysis with privilege escalation vectors, including replaceable executables, writable root-service drop-ins, and root TFTP upload scope.
# License: GNU GPL
# Version: 1.3
# Version: 1.5
# Mitre: T1543.002,T1574.010,T1007
# Functions Used: checkSystemdWritableExecPaths, echo_not_found, print_2title, print_info, print_3title
# Functions Used: checkSystemdWritableExecPaths, checkSystemdWritableDropins, echo_not_found, print_2title, print_info, print_3title
# Global Variables: $EXTRA_CHECKS, $IAMROOT, $SEARCH_IN_FOLDER, $TIMEOUT, $WRITABLESYSTEMDPATH
# Initial Functions:
# Generated Global Variables: $service_unit, $service_path, $service_content, $finding, $findings, $service_file, $exec_path, $exec_paths, $service, $line, $target_file, $target_exec, $relpath1, $relpath2
# Generated Global Variables: $service_unit, $service_path, $service_content, $finding, $findings, $service_file, $exec_path, $exec_paths, $service, $line, $target_file, $target_exec, $relpath1, $relpath2, $conf, $bytes
# Fat linpeas: 0
# Small linpeas: 0
# Read only the small, conventional tftpd-hpa settings file. Configuration is a
# review cue: the running daemon, network access and effective arguments may differ.
check_tftpd_hpa_root_upload_scope() (
conf="${1:-/etc/default/tftpd-hpa}"
if [ "$conf" = /etc/default/tftpd-hpa ]; then
[ ! -L /etc ] && [ ! -L /etc/default ] || return 0
fi
[ -f "$conf" ] && [ ! -L "$conf" ] && [ -r "$conf" ] || return 0
bytes=$(stat -c %s "$conf" 2>/dev/null) ||
bytes=$(stat -f %z "$conf" 2>/dev/null) || return 0
case "$bytes" in ''|*[!0-9]*) return 0 ;; esac
[ "$bytes" -le 4096 ] || return 0
if awk '
function setting(line) {
sub(/^[^=]*=[[:space:]]*/, "", line)
sub(/[[:space:]]*$/, "", line)
if (length(line) >= 2 &&
((substr(line, 1, 1) == "\"" && substr(line, length(line), 1) == "\"") ||
(substr(line, 1, 1) == "\047" && substr(line, length(line), 1) == "\047")))
line = substr(line, 2, length(line) - 2)
return line
}
/^[[:space:]]*(#|$)/ { next }
/^[[:space:]]*TFTP_USERNAME[[:space:]]*=/ { user = setting($0); next }
/^[[:space:]]*TFTP_DIRECTORY[[:space:]]*=/ { directory = setting($0); next }
/^[[:space:]]*TFTP_OPTIONS[[:space:]]*=/ { options = setting($0); next }
END {
if (user != "root" || directory != "/") exit 1
n = split(options, words, /[[:space:]]+/)
for (i = 1; i <= n; i++) {
if (words[i] == "--create" || words[i] == "-c") create = 1
if (words[i] == "--secure" || words[i] == "-s") secure = 1
if (words[i] == "--read-only" || words[i] == "-r") readonly = 1
}
exit !(create && secure && !readonly)
}
' "$conf" 2>/dev/null; then
print_3title "Root TFTP file-creation scope (review candidate)" "T1543.002"
echo "Configured tftpd-hpa root user, filesystem-root secure directory and --create: $conf"
echo "Verify active effective service, listener reachability, TFTP access rules, and whether a privileged job consumes newly created files (for example APT hooks)."
fi
)
if ! [ "$SEARCH_IN_FOLDER" ]; then
print_2title "Services and Service Files" "T1543.002,T1007"
print_info "https://book.hacktricks.wiki/en/linux-hardening/processes-crontab-systemd-dbus/process-enumeration-and-service-paths.html#follow-the-service-execution-chain"
check_tftpd_hpa_root_upload_scope
checkSystemdWritableExecPaths
checkSystemdWritableDropins
# Function to check service content for privilege escalation vectors
check_service_content() {
@@ -1,27 +1,117 @@
# Title: System Information - Systemd
# ID: SY_Systemd
# Author: Carlos Polop
# Last Update: 2024-03-19
# Description: Check for systemd vulnerabilities and misconfigurations that could lead to privilege escalation:
# - Systemd version vulnerabilities (CVE-2021-4034, CVE-2021-33910, etc.)
# Last Update: 2026-10-09
# Description: Check systemd version, service misconfigurations, and readable service environment files:
# - Systemd release (distribution package and patch status require separate verification)
# - Services running as root that could be exploited
# - Services with dangerous capabilities that could be abused
# - Services with writable paths that could be used to inject malicious code
# - Exploitation methods:
# * Version exploits: Use known exploits for vulnerable systemd versions
# * Version review: Compare the installed distribution package with vendor fixes
# * Root services: Abuse services running as root to execute commands
# * Capabilities: Abuse services with dangerous capabilities (CAP_SYS_ADMIN, etc.)
# * Writable paths: Replace executables in writable paths to get code execution
# License: GNU GPL
# Version: 1.1
# Mitre: T1543.002
# Version: 1.2
# Mitre: T1543.002,T1552.001
# Functions Used: print_2title, print_list, echo_not_found
# Global Variables: $SEARCH_IN_FOLDER, $Wfolders, $SED_RED, $SED_RED_YELLOW, $NC
# Global Variables: $SEARCH_IN_FOLDER, $IAMROOT, $Wfolders, $SED_RED, $SED_RED_YELLOW, $NC
# Initial Functions:
# Generated Global Variables: $WRITABLESYSTEMDPATH, $line, $service, $file, $version, $user, $caps, $path, $path_line, $service_file, $exec_line, $exec_value, $cmd, $cmd_path, $svc_path_entry, $svc_writable_path
# Generated Global Variables: $WRITABLESYSTEMDPATH, $line, $service, $file, $version, $user, $caps, $path, $path_line, $service_file, $exec_line, $exec_value, $cmd, $cmd_path, $svc_path_entry, $svc_writable_path, $running_services, $env_file_findings, $env_file_path, $env_file_size, $env_key_names, $env_unit_size
# Fat linpeas: 0
# Small linpeas: 1
# Inspect only literal EnvironmentFile paths in a service unit. This is an
# indicator of readable credential-like assignments, not evidence that a key
# is used for authentication or provides privilege escalation.
systemd_envfile_candidates() {
[ -f "$1" ] && [ -r "$1" ] || return
local env_unit_size
env_unit_size=$(stat -c %s "$1" 2>/dev/null) ||
env_unit_size=$(stat -f %z "$1" 2>/dev/null) || return
case "$env_unit_size" in ''|*[!0-9]*) return ;; esac
[ "$env_unit_size" -le 65536 ] || return
awk '
function add_path(value, path, quoted, optional) {
sub(/^[[:space:]]*/, "", value)
sub(/[[:space:]]*$/, "", value)
if (value == "") { count = 0; return }
optional = 0
if (substr(value, 1, 1) == "-") { value = substr(value, 2); optional = 1 }
quoted = 0
if ((substr(value, 1, 1) == "\"" && substr(value, length(value), 1) == "\"") ||
(substr(value, 1, 1) == sprintf("%c", 39) && substr(value, length(value), 1) == sprintf("%c", 39))) {
quoted = 1
value = substr(value, 2, length(value) - 2)
}
if (!optional && substr(value, 1, 1) == "-") value = substr(value, 2)
# Skip globs, escapes, unquoted whitespace, and relative paths.
if (!quoted && value ~ /[[:space:]]/) return
if (value !~ /^\// || value ~ /[*?\[\]{}\\%$"\047]/ || value ~ /[\r\n]/) return
for (path = 1; path <= count; path++) if (paths[path] == value) return
paths[++count] = value
}
/^[[:space:]]*\[/ {
section = $0
sub(/^[[:space:]]*/, "", section)
sub(/[[:space:]]*$/, "", section)
next
}
section == "[Service]" && /^[[:space:]]*EnvironmentFile[[:space:]]*=/ {
value = $0
sub(/^[[:space:]]*EnvironmentFile[[:space:]]*=/, "", value)
add_path(value)
}
END { for (i = 1; i <= count && i <= 4; i++) print paths[i] }
' "$1" 2>/dev/null
}
systemd_envfile_key_names() {
awk '
/^[[:space:]]*[#;]/ { next }
/^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=/ {
line = $0
sub(/=.*/, "", line)
gsub(/[[:space:]]/, "", line)
value = $0
sub(/^[^=]*=[[:space:]]*/, "", value)
if (value == "" || value == "\"\"" || value == sprintf("%c%c", 39, 39)) next
upper = toupper(line)
if (upper !~ /(^|_)(SECRET|TOKEN|PASSWORD|PASSWD|APIKEY|KEY)($|_)/) next
if (seen[line]++) next
if (found >= 20) exit
if (found++) printf ","
printf "%s", line
}
END { if (found) printf "\n" }
' "$1" 2>/dev/null
}
systemd_envfile_active_units() {
awk '
{
unit = $1
if (unit == "●") unit = $2
if (unit !~ /^[A-Za-z0-9_.@-]+[.]service$/ || unit ~ /[.][.]/) next
print unit
if (++count >= 200) exit
}
'
}
systemd_envfile_findings_for_unit() {
systemd_envfile_candidates "$1" | while IFS= read -r env_file_path; do
[ -f "$env_file_path" ] && [ -r "$env_file_path" ] || continue
env_file_size=$(stat -c %s "$env_file_path" 2>/dev/null) ||
env_file_size=$(stat -f %z "$env_file_path" 2>/dev/null) || continue
case "$env_file_size" in ''|*[!0-9]*) continue ;; esac
[ "$env_file_size" -le 65536 ] || continue
env_key_names=$(systemd_envfile_key_names "$env_file_path")
[ "$env_key_names" ] && printf '%s: %s\n' "$env_file_path" "$env_key_names"
done
}
if ! [ "$SEARCH_IN_FOLDER" ]; then
print_2title "Systemd Information" "T1543.002"
print_info "https://book.hacktricks.wiki/en/linux-hardening/linux-basics/linux-privilege-escalation/index.html#systemd-path---relative-paths"
@@ -44,7 +134,7 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
get_service_file() {
local service="$1"
local file=""
for path in "/etc/systemd/system/$service" "/lib/systemd/system/$service"; do
for path in "/etc/systemd/system/$service" "/run/systemd/system/$service" "/usr/lib/systemd/system/$service" "/lib/systemd/system/$service"; do
if [ -f "$path" ]; then
file="$path"
break
@@ -60,28 +150,26 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
return $?
}
# Check systemd version and known vulnerabilities
print_list "Systemd version and vulnerabilities? .............. "$NC
# Reuse one active-unit listing without changing the scope of older checks.
running_services=""
if check_systemctl; then
version=$(systemctl --version | head -n 1 | grep -oE '([0-9]+(\.[0-9]+)+)')
running_services=$(list_running_services)
fi
# The upstream release alone does not identify distribution backports or
# mitigations. PwnKit is a polkit/pkexec issue, not a systemd version issue.
print_list "Systemd release (verify package fixes)? .......... "$NC
if check_systemctl; then
version=$(systemctl --version 2>/dev/null | awk 'NR == 1 && $1 == "systemd" && $2 ~ /^[0-9]+$/ { print $2 }')
if [ -n "$version" ]; then
echo "$version" | sed -${E} "s,([0-9]+(\.[0-9]+)+),${SED_RED},g"
# Check for known vulnerable versions
case "$version" in
"2.3"[0-4]|"2.3"[0-4]"."*)
echo " └─ Vulnerable to CVE-2021-4034 (Polkit)" | sed -${E} "s,.*,${SED_RED},g"
;;
"2.4"[0-9]|"2.4"[0-9]"."*)
echo " └─ Vulnerable to CVE-2021-33910 (systemd-tmpfiles)" | sed -${E} "s,.*,${SED_RED},g"
;;
esac
printf '%s\n' "$version"
fi
fi
# Check for systemd services running as root
print_list "Services running as root? ..... "$NC
if check_systemctl; then
list_running_services |
printf '%s\n' "$running_services" |
grep -E "root|0:0" |
while read -r line; do
service=$(echo "$line" | awk '{print $1}')
@@ -96,7 +184,7 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
# Check for systemd services with dangerous capabilities
print_list "Running services with dangerous capabilities? ... "$NC
if check_systemctl; then
list_running_services |
printf '%s\n' "$running_services" |
grep -E "\.service" |
while read -r line; do
service=$(echo "$line" | awk '{print $1}')
@@ -113,7 +201,7 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
# Check for systemd services with writable paths
print_list "Services with writable paths? . "$NC
if check_systemctl; then
list_running_services |
printf '%s\n' "$running_services" |
grep -E "\.service" |
while read -r line; do
service=$(echo "$line" | awk '{print $1}')
@@ -162,6 +250,23 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
echo ""
# Unit files may point at files outside the usual .env naming convention.
# Keep this passive and print key names only, never environment values.
if ! [ "$IAMROOT" ]; then
env_file_findings=$(
printf '%s\n' "$running_services" | systemd_envfile_active_units | while IFS= read -r service; do
service_file=$(get_service_file "$service")
[ "$service_file" ] || continue
systemd_envfile_findings_for_unit "$service_file"
done | sort -u
)
if [ "$env_file_findings" ]; then
print_2title "Readable systemd EnvironmentFile credential-like keys (possible exposure)" "T1552.001"
printf '%s\n' "$env_file_findings"
echo ""
fi
fi
print_2title "Systemd PATH" "T1543.002"
print_info "https://book.hacktricks.wiki/en/linux-hardening/linux-basics/linux-privilege-escalation/index.html#systemd-path---relative-paths"
if check_systemctl; then
@@ -12,9 +12,9 @@
# Version: 1.1
# Mitre: T1571,T1049
# Functions Used: print_2title, print_info
# Global Variables: $EXTRA_CHECKS, $groupsB, $groupsVB, $IAMROOT, $idB, $knw_grps, $knw_usrs, $nosh_usrs, $SEARCH_IN_FOLDER, $sh_usrs, $USER, $SED_RED, $SED_GREEN, $SED_RED_YELLOW, $NC, $RED
# Global Variables: $EXTRA_CHECKS, $groupsB, $groupsVB, $IAMROOT, $idB, $knw_grps, $knw_usrs, $nosh_usrs, $SEARCH_IN_FOLDER, $sh_usrs, $USER, $SED_RED, $SED_GREEN
# Initial Functions:
# Generated Global Variables: $unix_scks_list, $unix_scks_list2, $perms, $owner, $owner_info, $response, $socket, $cmd, $mode, $group
# Generated Global Variables: $unix_scks_list, $unix_scks_list2, $ss_metadata, $socket_listing, $socket_info, $perms, $owner, $owner_info, $owner_uid, $response, $socket, $cmd, $mode, $group, $read_access, $write_access, $listener_info, $listener_pid, $listener_uid, $listener_is_active
# Fat linpeas: 0
# Small linpeas: 0
@@ -27,15 +27,18 @@ if ! [ "$IAMROOT" ]; then
# Function to get socket permissions
get_socket_perms() {
local socket="$1"
local mode="$2"
local read_access="$3"
local write_access="$4"
local perms=""
# Check read permission
if [ -r "$socket" ]; then
if [ "$read_access" = "yes" ]; then
perms="Read "
fi
# Check write permission
if [ -w "$socket" ]; then
if [ "$write_access" = "yes" ]; then
perms="${perms}Write "
fi
@@ -44,8 +47,6 @@ if ! [ "$IAMROOT" ]; then
perms="${perms}Execute "
fi
# Check socket mode
local mode=$(stat -c "%a" "$socket" 2>/dev/null)
if [ "$mode" = "777" ] || [ "$mode" = "666" ]; then
perms="${perms}(Weak Permissions: $mode) "
fi
@@ -87,24 +88,33 @@ if ! [ "$IAMROOT" ]; then
fi
}
# Function to get socket owner and group
get_socket_owner() {
local socket="$1"
local owner=""
local group=""
if [ -e "$socket" ]; then
owner=$(ls -l "$socket" 2>/dev/null | awk '{print $3}')
group=$(ls -l "$socket" 2>/dev/null | awk '{print $4}')
echo "$owner:$group"
fi
}
# Collect listening sockets using multiple methods
unix_scks_list=""
ss_metadata=""
for cmd in "ss -xlp -H state listening" "ss -l -p -A 'unix'" "netstat -a -p --unix"; do
if [ -z "$unix_scks_list" ]; then
unix_scks_list=$($cmd 2>/dev/null | grep -Eo "/[a-zA-Z0-9\._/\-]+" | grep -v " " | sort -u)
case "$cmd" in
ss\ *)
socket_listing=$($cmd 2>/dev/null)
unix_scks_list=$(printf '%s\n' "$socket_listing" | grep -Eo "/[a-zA-Z0-9\._/\-]+" | grep -v " " | sort -u)
if [ -n "$unix_scks_list" ]; then
# Reuse the ss output already collected; never query processes per socket.
ss_metadata=$(printf '%s\n' "$socket_listing" | awk '
match($0, /\/[a-zA-Z0-9._\/-]+/) {
path = substr($0, RSTART, RLENGTH)
pid = ""; uid = ""
if (match($0, /pid=[0-9]+/)) pid = substr($0, RSTART + 4, RLENGTH - 4)
if (match($0, /uid[:=][0-9]+/)) uid = substr($0, RSTART + 4, RLENGTH - 4)
print path "|" pid "|" uid
}')
ss_metadata="
$ss_metadata"
fi
;;
*)
unix_scks_list=$($cmd 2>/dev/null | grep -Eo "/[a-zA-Z0-9\._/\-]+" | grep -v " " | sort -u)
;;
esac
fi
done
@@ -124,29 +134,69 @@ if ! [ "$IAMROOT" ]; then
(printf "%s\n" "$unix_scks_list" && printf "%s\n" "$unix_scks_list2") | sort -u | while read -r socket; do
if [ -n "$socket" ] && [ -e "$socket" ]; then
# Get socket information
perms=$(get_socket_perms "$socket")
perms=$(check_socket_connectivity "$socket" "$perms")
owner_info=$(get_socket_owner "$socket")
# Print socket information
if [ -z "$perms" ]; then
echo "$socket" | sed -${E} "s,$socket,${SED_GREEN},g"
socket_info=$(stat -c '%a|%U|%G|%u' "$socket" 2>/dev/null)
[ -n "$socket_info" ] || socket_info=$(stat -f '%Lp|%Su|%Sg|%u' "$socket" 2>/dev/null)
if [ -n "$socket_info" ]; then
mode=${socket_info%%|*}
socket_info=${socket_info#*|}
owner=${socket_info%%|*}
socket_info=${socket_info#*|}
group=${socket_info%%|*}
owner_uid=${socket_info#*|}
else
echo "$socket" | sed -${E} "s,$socket,${SED_RED},g"
echo " └─(${RED}${perms}${NC})" | sed -${E} "s,Cannot Connect,${SED_GREEN},g"
# Analyze socket protocol if we can connect
if echo "$perms" | grep -q "Can Connect"; then
analyze_socket_protocol "$socket" "$owner_info"
fi
# Highlight dangerous ownership
if echo "$owner_info" | grep -q "root"; then
echo " └─(${RED}Owned by root${NC})"
if echo "$perms" | grep -q "Write"; then
echo " └─High risk: root-owned and writable Unix socket" | sed -${E} "s,.*,${SED_RED_YELLOW},g"
fi
fi
mode="unknown"
owner="unknown"
group="unknown"
owner_uid=""
fi
owner_info="$owner:$group"
read_access="no"
write_access="no"
[ -r "$socket" ] && read_access="yes"
[ -w "$socket" ] && write_access="yes"
perms=$(get_socket_perms "$socket" "$mode" "$read_access" "$write_access")
perms=$(check_socket_connectivity "$socket" "$perms")
echo "Path: $socket"
echo " └─ Mode: $mode; Owner/Group: $owner_info"
echo " └─ Current user access: read=$read_access, write=$write_access"
if [ -n "$perms" ]; then
echo " └─ $perms" | sed -${E} "s,Cannot Connect,${SED_GREEN},g"
fi
# A discovered socket file can remain after its listener exits.
listener_is_active=no
case "
$unix_scks_list
" in
*"
$socket
"*) listener_is_active=yes ;;
esac
# Only display listener details that came with the existing ss listing.
case "$ss_metadata" in
*"
$socket|"*)
listener_info=${ss_metadata#*"
$socket|"}
listener_info=${listener_info%%"
"*}
listener_pid=${listener_info%%|*}
listener_uid=${listener_info#*|}
[ -n "$listener_pid" ] && echo " └─ ss listener PID: $listener_pid"
[ -n "$listener_uid" ] && echo " └─ ss socket UID: $listener_uid"
;;
esac
# Access alone does not establish what a service executes.
if [ "$listener_is_active" = "yes" ] && [ "$owner_uid" = "0" ] && [ "$write_access" = "yes" ]; then
echo " └─ Review lead: current user can write to a root-owned Unix socket; inspect service behavior and privileges."
fi
# Preserve the optional protocol check when EXTRA_CHECKS found connectivity.
if echo "$perms" | grep -q "Can Connect"; then
analyze_socket_protocol "$socket" "$owner_info"
fi
fi
done
@@ -10,10 +10,49 @@
# Functions Used: print_2title, print_3title, echo_not_found
# Global Variables:
# Initial Functions:
# Generated Global Variables: $rfile, $perms, $owner, $g, $o, $any_rhosts, $shown, $f, $p
# Generated Global Variables: $rfile, $perms, $owner, $size, $metadata, $metadata_rest, $any_rhosts, $rhosts_scanned, $shown, $f, $p
# Fat linpeas: 0
# Small linpeas: 1
rcommands_trust_metadata() {
# GNU and BSD/OpenBSD stat use different format flags.
stat -c '%a %U %s' "$1" 2>/dev/null || stat -f '%Lp %Su %z' "$1" 2>/dev/null
}
rcommands_trust_group_other_writable() {
case "$1" in
[0-7][0-7][0-7]|[0-7][0-7][0-7][0-7])
[ "$((0${1} & 0022))" -ne 0 ]
;;
*) return 1 ;;
esac
}
rcommands_trust_entries() {
# Read at most 64 KiB and stop after one extra non-comment entry.
dd if="$1" bs=65536 count=1 2>/dev/null | LC_ALL=C awk -v size="$2" '
/^[[:space:]]*(#|$)/ { next }
{
entries++
if (entries > 64) {
print " [!] Trust entries truncated after 64 lines"
exit
}
if (length($0) > 512) print " " substr($0, 1, 512) " ... [line shortened]"
else print " " $0
if ($1 !~ /^[-+@]/ && $1 !~ /:/ && $1 ~ /[[:alpha:]]/) hostname = 1
if ($2 == "+") any_user = 1
if ($1 == "+" || $2 == "+") wildcard = 1
}
END {
if (size == "" || size + 0 > 65536) print " [!] Trust-file input limited to first 64 KiB"
if (hostname) print " [!] Hostname-based trust candidate: review control of DNS and local name mappings"
if (any_user) print " [!] Remote-user + may broaden trust (service and PAM policy still apply)"
if (wildcard) print " [!] Wildcard '\''+'\'' trust found"
}
'
}
if ! [ "$SEARCH_IN_FOLDER" ]; then
print_2title "Legacy r-commands (rsh/rlogin/rexec) and host-based trust" "T1021.004"
echo ""
@@ -73,33 +112,48 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
print_3title "/etc/hosts.equiv and /etc/shosts.equiv" "T1021.004"
for f in /etc/hosts.equiv /etc/shosts.equiv; do
if [ -f "$f" ]; then
perms=$(stat -c %a "$f" 2>/dev/null)
owner=$(stat -c %U "$f" 2>/dev/null)
echo " $f (perm $perms, owner $owner)"
# Print non-comment lines
awk 'NF && $0 !~ /^\s*#/ {print " " $0}' "$f" 2>/dev/null
if grep -qEv '^\s*#|^\s*$' "$f" 2>/dev/null; then
if grep -qE '(^|\s)\+' "$f" 2>/dev/null; then
echo " [!] Wildcard '+' trust found"
fi
metadata=$(rcommands_trust_metadata "$f")
if [ -n "$metadata" ]; then
perms=${metadata%% *}
metadata_rest=${metadata#* }
owner=${metadata_rest%% *}
size=${metadata_rest##* }
echo " $f (perm $perms, owner $owner)"
else
size=
echo " $f (metadata unavailable)"
fi
rcommands_trust_entries "$f" "$size"
fi
done
echo ""
print_3title "Per-user .rhosts files" "T1021.004"
any_rhosts=false
rhosts_scanned=0
for rfile in /root/.rhosts /home/*/.rhosts; do
if [ -f "$rfile" ]; then
rhosts_scanned=$((rhosts_scanned + 1))
if [ "$rhosts_scanned" -gt 64 ]; then
echo " [!] .rhosts discovery limited to first 64 files; coverage is partial"
break
fi
any_rhosts=true
perms=$(stat -c %a "$rfile" 2>/dev/null)
owner=$(stat -c %U "$rfile" 2>/dev/null)
echo " $rfile (perm $perms, owner $owner)"
awk 'NF && $0 !~ /^\s*#/ {print " " $0}' "$rfile" 2>/dev/null
metadata=$(rcommands_trust_metadata "$rfile")
if [ -n "$metadata" ]; then
perms=${metadata%% *}
metadata_rest=${metadata#* }
owner=${metadata_rest%% *}
size=${metadata_rest##* }
echo " $rfile (perm $perms, owner $owner)"
else
perms=
size=
echo " $rfile (metadata unavailable)"
fi
rcommands_trust_entries "$rfile" "$size"
# Warn on insecure perms (group/other write)
g=$(printf "%s" "$perms" | cut -c2)
o=$(printf "%s" "$perms" | cut -c3)
if [ "${g:-0}" -ge 2 ] || [ "${o:-0}" -ge 2 ]; then
if rcommands_trust_group_other_writable "$perms"; then
echo " [!] Insecure permissions (group/other write)"
fi
fi
@@ -136,4 +190,3 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
echo ""
fi
@@ -1,127 +1,157 @@
# Title: Processes & Cron & Services & Timers - Crontab UI (root) Misconfiguration
# ID: PR_Crontab_UI_misconfig
# Author: HT Bot
# Last Update: 2025-09-13
# Description: Detect Crontab UI service and risky configurations that can lead to privesc:
# - Root-run Crontab UI exposed on localhost
# - Basic-Auth credentials in systemd Environment= (BASIC_AUTH_USER/PWD)
# - Cron DB path (CRON_DB_PATH) and weak permissions / embedded secrets in jobs
# Last Update: 2026-10-09
# Description: Correlate Crontab UI service/process evidence with a bounded, redacted job-store check.
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1053.003
# Functions Used: print_2title, print_info, print_list, echo_not_found
# Global Variables: $SEARCH_IN_FOLDER, $SED_RED, $SED_RED_YELLOW, $NC
# Global Variables: $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables: $svc, $state, $user, $envvals, $port, $dbpath, $dbfile, $candidates, $procs, $perms, $basic_user, $basic_pwd, $uprint, $pprint, $dir, $found
# Generated Global Variables: $candidates, $dir, $found, $unit_file, $svc, $state, $loadstate, $user, $envvals, $dbpath, $dbfile, $dbsize, $dbsummary, $jobcount, $credcount, $processes, $has_service, $has_process, $has_binary
# Fat linpeas: 0
# Small linpeas: 1
if ! [ "$SEARCH_IN_FOLDER" ]; then
print_2title "Crontab UI (root) misconfiguration checks" "T1053.003"
print_2title "Crontab UI scheduler checks" "T1053.003"
print_info "https://book.hacktricks.wiki/en/linux-hardening/processes-crontab-systemd-dbus/cron-and-systemd-timers.html#enumerate-schedules"
# Collect candidate services referencing crontab-ui
candidates=""
has_service=""
has_process=""
has_binary=""
if command -v systemctl >/dev/null 2>&1; then
candidates=$(systemctl list-units --type=service --all 2>/dev/null | awk '{print $1}' | grep -Ei '^crontab-ui\.service$' 2>/dev/null)
candidates=$(systemctl list-units --type=service --all --no-legend 2>/dev/null | awk '$1 ~ /[Cc]rontab[-_][Uu][Ii].*\.service$/ { print $1 }')
found=$(systemctl list-unit-files --type=service --no-legend 2>/dev/null | awk '$1 ~ /[Cc]rontab[-_][Uu][Ii].*\.service$/ { print $1 }')
candidates=$(printf '%s\n%s\n' "$candidates" "$found" | sed '/^$/d' | sort -u)
fi
# Fallback: grep service files for ExecStart containing crontab-ui
if [ -z "$candidates" ]; then
for dir in /etc/systemd/system /lib/systemd/system; do
[ -d "$dir" ] || continue
found=$(grep -RIl "^Exec(Start|StartPre|StartPost)=.*crontab-ui" "$dir" 2>/dev/null | xargs -r -I{} basename {} 2>/dev/null)
if [ -n "$found" ]; then
candidates=$(printf "%s\n%s" "$candidates" "$found" | sort -u)
fi
done
fi
# Search only service units; grep -E is required for the alternation below.
for dir in /etc/systemd/system /lib/systemd/system /usr/lib/systemd/system; do
[ -d "$dir" ] || continue
found=$(find "$dir" -type f -name '*.service' -exec grep -Eil -e '^[[:space:]]*ExecStart(Pre|Post)?[[:space:]]*=[^#]*crontab-ui' {} + 2>/dev/null | while IFS= read -r unit_file; do basename "$unit_file"; done)
candidates=$(printf '%s\n%s\n' "$candidates" "$found" | sed '/^$/d' | sort -u)
done
[ -n "$candidates" ] && has_service=1
# Also flag if the binary exists or a process seems to be running
if command -v crontab-ui >/dev/null 2>&1; then
print_list "crontab-ui binary found at: $(command -v crontab-ui)"$NC
has_binary=1
print_list "crontab-ui binary found"
else
echo_not_found "crontab-ui"
fi
procs=$(ps aux 2>/dev/null | grep -E "(crontab-ui|node .*crontab-ui)" | grep -v grep)
if [ -n "$procs" ]; then
print_list "Processes matching crontab-ui? ..................... "$NC
printf "%s\n" "$procs"
echo ""
# Use the executable name, not arbitrary arguments containing 'crontab-ui'.
# Never print full process arguments: they may contain credentials.
processes=$(ps -eo user=,comm=,args= 2>/dev/null | awk '
{
user=$1; comm=$2; exe=$3; argpath=$4; arg=$4
sub(/^.*\//, "", comm)
sub(/^.*\//, "", exe)
sub(/^.*\//, "", arg)
if (comm == "crontab-ui" || (comm == "node" && exe == "node" && (arg == "crontab-ui" || argpath ~ /(^|\/)crontab-ui(\/|$)/)))
print user " crontab-ui"
}' | sort -u)
if [ -n "$processes" ]; then
has_process=1
printf 'Process evidence (owner and executable only): %s\n' "$processes"
fi
# If no candidates detected, exit quietly
if [ "$candidates" ]; then
# Iterate candidates and extract interesting data
printf "%s\n" "$candidates" | while read -r svc; do
if [ -n "$candidates" ]; then
printf '%s\n' "$candidates" | head -n 20 | while IFS= read -r svc; do
[ -n "$svc" ] || continue
# Ensure suffix .service if missing
case "$svc" in
*.service) : ;;
*) svc="$svc.service" ;;
esac
state=""
user=""
state=unknown
loadstate=unknown
user=unknown
envvals=""
if command -v systemctl >/dev/null 2>&1; then
state=$(systemctl is-active "$svc" 2>/dev/null)
user=$(systemctl show "$svc" -p User 2>/dev/null | cut -d= -f2)
state=$(systemctl is-active "$svc" 2>/dev/null) || state=unknown
loadstate=$(systemctl show "$svc" -p LoadState 2>/dev/null | cut -d= -f2-)
user=$(systemctl show "$svc" -p User 2>/dev/null | cut -d= -f2-)
envvals=$(systemctl show "$svc" -p Environment 2>/dev/null | cut -d= -f2-)
fi
[ -z "$state" ] && state="unknown"
[ -z "$user" ] && user="unknown"
echo "Service: $svc (state: $state, User: $user)" | sed -${E} "s,root,${SED_RED},g"
# Read Environment from systemd (works even if file unreadable in many setups)
envvals=$(systemctl show "$svc" -p Environment 2>/dev/null | cut -d= -f2-)
if [ -n "$envvals" ]; then
basic_user=$(printf "%s\n" "$envvals" | tr ' ' '\n' | grep -E '^BASIC_AUTH_USER=' | head -n1 | cut -d= -f2-)
basic_pwd=$(printf "%s\n" "$envvals" | tr ' ' '\n' | grep -E '^BASIC_AUTH_PWD=' | head -n1 | cut -d= -f2-)
dbpath=$(printf "%s\n" "$envvals" | tr ' ' '\n' | grep -E '^CRON_DB_PATH=' | head -n1 | cut -d= -f2-)
port=$(printf "%s\n" "$envvals" | tr ' ' '\n' | grep -E '^PORT=' | head -n1 | cut -d= -f2-)
if [ -n "$basic_user" ] || [ -n "$basic_pwd" ]; then
uprint="$basic_user"
pprint="$basic_pwd"
[ -n "$basic_pwd" ] && pprint="$basic_pwd"
echo " └─ Basic-Auth credentials in Environment: user='${uprint}' pwd='${pprint}'" | sed -${E} "s,pwd='[^']*',${SED_RED_YELLOW},g"
fi
if [ -n "$dbpath" ]; then
echo " └─ CRON_DB_PATH: $dbpath"
fi
# Check listener bound to localhost
[ -z "$port" ] && port=8000
if command -v ss >/dev/null 2>&1; then
if ss -ltn 2>/dev/null | grep -qE "127\.0\.0\.1:${port}[[:space:]]"; then
echo " └─ Listener detected on 127.0.0.1:${port} (likely Crontab UI)."
fi
else
if netstat -tnl 2>/dev/null | grep -qE "127\.0\.0\.1:${port}[[:space:]]"; then
echo " └─ Listener detected on 127.0.0.1:${port} (likely Crontab UI)."
fi
fi
# If we know DB path, try to read crontab.db for obvious secrets and check perms
if [ -n "$dbpath" ] && [ -d "$dbpath" ] && [ -r "$dbpath" ]; then
dbfile="$dbpath/crontab.db"
if [ -f "$dbfile" ]; then
perms=$(ls -ld "$dbpath" 2>/dev/null | awk '{print $1, $3, $4}')
echo " └─ DB dir perms: $perms"
if [ -w "$dbpath" ] || [ -w "$dbfile" ]; then
echo " └─ Writable by current user -> potential job injection!" | sed -${E} "s,.*,${SED_RED},g"
fi
echo " └─ Inspecting $dbfile for embedded secrets in commands (zip -P / --password / pass/token/secret)..."
grep -E "-P[[:space:]]+\S+|--password[[:space:]]+\S+|[Pp]ass(word)?|[Tt]oken|[Ss]ecret" "$dbfile" 2>/dev/null | head -n 20 | sed -${E} "s,(${SED_RED_YELLOW}),\1,g"
fi
fi
[ -n "$state" ] || state=unknown
if [ "$loadstate" = loaded ] && [ -z "$user" ]; then
user='root (systemd default; configuration evidence)'
elif [ -z "$user" ]; then
user=unknown
fi
echo ""
printf 'Service evidence: %s (state: %s, User: %s)\n' "$svc" "$state" "$user"
if printf '%s\n' "$envvals" | grep -Eq '(^|[[:space:]])BASIC_AUTH_USER='; then
printf ' BASIC_AUTH_USER is set (value redacted)\n'
fi
if printf '%s\n' "$envvals" | grep -Eq '(^|[[:space:]])BASIC_AUTH_PWD='; then
printf ' BASIC_AUTH_PWD is set (value redacted)\n'
fi
printf '\n'
done
fi
fi
# systemctl Environment may be hidden; the exact conventional store is still
# useful when independent service, binary, or process evidence exists.
if [ -n "$has_service$has_process$has_binary" ]; then
dbpath=""
if [ -n "$candidates" ] && command -v systemctl >/dev/null 2>&1; then
svc=$(printf '%s\n' "$candidates" | head -n 1)
envvals=$(systemctl show "$svc" -p Environment 2>/dev/null | cut -d= -f2-)
dbpath=$(printf '%s\n' "$envvals" | tr ' ' '\n' | sed -n 's/^CRON_DB_PATH=//p' | head -n 1)
fi
if [ -n "$dbpath" ]; then
case "$dbpath" in
*/crontab.db) dbfile=$dbpath ;;
*) dbfile=${dbpath%/}/crontab.db ;;
esac
else
dbfile=/opt/crontabs/crontab.db
fi
if [ -f "$dbfile" ]; then
if [ ! -r "$dbfile" ]; then
printf 'DB candidate: %s (unreadable; contents unknown)\n' "$dbfile"
else
dbsize=$(stat -L -c %s "$dbfile" 2>/dev/null) || dbsize=$(stat -L -f %z "$dbfile" 2>/dev/null)
case "$dbsize" in
''|*[!0-9]*) printf 'DB candidate: %s (size unknown; contents not inspected)\n' "$dbfile" ;;
*)
if [ "$dbsize" -gt 1048576 ]; then
printf 'DB candidate: %s (readable, oversized: %s bytes; contents not inspected)\n' "$dbfile" "$dbsize"
else
printf 'DB candidate: %s (readable, %s bytes; writable by current user: ' "$dbfile" "$dbsize"
if [ -w "$dbfile" ]; then printf 'yes)\n'; else printf 'no)\n'; fi
dbsummary=$(LC_ALL=C awk '
/^[[:space:]]*\{/ && /"command"[[:space:]]*:[[:space:]]*"/ {
jobs++
command=$0
sub(/^.*"command"[[:space:]]*:[[:space:]]*"/, "", command)
sub(/".*$/, "", command)
if (command ~ /(^|[[:space:]])zip[[:space:]]+[^"]*[[:space:]]-P[[:space:]]+[^[:space:]"\\]+/)
credentials++
}
END { printf "%d %d", jobs, credentials }
' "$dbfile" 2>/dev/null)
if [ -n "$dbsummary" ]; then
jobcount=${dbsummary%% *}
credcount=${dbsummary#* }
if [ "$jobcount" -eq 0 ]; then
printf ' Job store shape unrecognized; contents unknown\n'
else
printf ' Jobs with command fields: %s; inline zip -P credential candidates: %s (values redacted)\n' "$jobcount" "$credcount"
fi
else
printf ' Job store inspection failed; contents unknown\n'
fi
fi
;;
esac
fi
elif [ -e "$dbfile" ]; then
printf 'DB candidate: %s (not a regular file; contents unknown)\n' "$dbfile"
else
printf 'DB candidate: %s (not found; contents unknown)\n' "$dbfile"
fi
fi
if [ -n "$has_service$has_process" ]; then
printf 'Review existing Open ports output for a loopback listener; no service binding or credential reuse was verified.\n'
fi
fi
@@ -0,0 +1,94 @@
# Title: Processes & Cron & Services & Timers - Cross-user Xvfb framebuffer files
# ID: PR_Xvfb_fbdir
# Author: PEASS-ng contributors
# Last Update: 08-10-2026
# Description: Report readable framebuffer files from another user's running Xvfb without reading their contents
# License: GNU GPL
# Version: 1.0
# Mitre: T1552.001
# Functions Used: print_2title
# Global Variables: $MACPEAS, $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables: $comm, $current_uid, $cwd, $fbdir, $found, $metadata, $mode, $owner_uid, $pid, $printed, $proc, $process_uid, $proc_root, $screen, $screens
# Fat linpeas: 0
# Small linpeas: 1
lp_xvfb_fbdir_from_cmdline() {
tr '\000' '\n' < "$1" 2>/dev/null | awk '
NR == 1 {
name = $0
sub(/^.*\//, "", name)
if (name != "Xvfb") exit
}
want_dir { if ($0 != "") print; exit }
$0 == "-fbdir" { want_dir = 1 }
'
}
lp_xvfb_candidate_pids() {
local proc_root="$1" proc comm found=0
if [ "$proc_root" = /proc ] && command -v pgrep >/dev/null 2>&1; then
pgrep -x Xvfb 2>/dev/null | head -n 8
return
fi
# Fallback for systems without pgrep. Inspect only comm for other processes;
# stop after eight actual Xvfb candidates, regardless of PID ordering.
for proc in "$proc_root"/[0-9]*; do
[ -r "$proc/comm" ] || continue
IFS= read -r comm < "$proc/comm" || continue
[ "$comm" = Xvfb ] || continue
printf '%s\n' "${proc##*/}"
found=$((found + 1))
[ "$found" -lt 8 ] || break
done
}
lp_check_xvfb_fbdir() {
local proc_root="${1:-/proc}" current_uid="${2:-$(id -u)}"
local proc pid fbdir comm process_uid metadata owner_uid mode screen screens cwd printed=0
[ -d "$proc_root" ] || return 0
# Root can normally read every user's framebuffer, so this is useful for
# unprivileged cross-user access only.
[ "$current_uid" = 0 ] && return 0
for pid in $(lp_xvfb_candidate_pids "$proc_root"); do
proc="$proc_root/$pid"
[ -r "$proc/comm" ] && [ -r "$proc/cmdline" ] && [ -r "$proc/status" ] || continue
IFS= read -r comm < "$proc/comm" || continue
[ "$comm" = Xvfb ] || continue
process_uid=$(awk '$1 == "Uid:" { print $3; exit }' "$proc/status" 2>/dev/null)
case "$process_uid" in ''|*[!0-9]*) continue ;; esac
[ "$process_uid" != "$current_uid" ] || continue
fbdir=$(lp_xvfb_fbdir_from_cmdline "$proc/cmdline")
[ -n "$fbdir" ] || continue
case "$fbdir" in
/*) ;;
*) cwd=$(readlink "$proc/cwd" 2>/dev/null) || continue
[ -n "$cwd" ] || continue
fbdir="$cwd/$fbdir" ;;
esac
[ -d "$fbdir" ] || continue
screens=$(find "$fbdir" -maxdepth 1 -type f -name 'Xvfb_screen*' -print 2>/dev/null | head -n 8)
[ -n "$screens" ] || continue
while IFS= read -r screen; do
[ -f "$screen" ] && [ -r "$screen" ] || continue
metadata=$(stat -c '%u %a' -- "$screen" 2>/dev/null) || continue
owner_uid=${metadata%% *}
mode=${metadata#* }
[ "$owner_uid" = "$process_uid" ] || continue
[ "$owner_uid" != "$current_uid" ] || continue
if [ "$printed" -eq 0 ]; then
print_2title "Readable cross-user Xvfb framebuffer files" "T1552.001"
printed=1
fi
printf 'Review: running Xvfb PID %s exposes readable framebuffer: %s (owner UID %s, mode 0%s)\n' "$pid" "$screen" "$owner_uid" "$mode"
done <<EOF
$screens
EOF
done
[ "$printed" -eq 0 ] || echo ""
}
if ! [ "$SEARCH_IN_FOLDER" ] && ! [ "$MACPEAS" ] && [ -d /proc ]; then
lp_check_xvfb_fbdir
fi
@@ -0,0 +1,155 @@
# Title: Processes & Cron & Services & Timers - Erlang SSH daemons
# ID: PR_Erlang_ssh_daemons
# Author: PEASS-ng contributors
# Last Update: 2026-10-09
# Description: Correlate running Erlang source files with OTP SSH markers without disclosing authentication values
# License: GNU GPL
# Version: 1.0
# Mitre: T1552.001
# Functions Used: print_2title
# Global Variables: $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables: $snapshot, $proc_root, $ps_rows, $pid, $uid, $command, $source, $physical_dir, $canonical, $size, $markers, $auth, $shell, $candidates, $files, $printed, $unknowns, $seen_files
# Fat linpeas: 0
# Small linpeas: 1
lp_erlang_ssh_source_from_ps() {
printf '%s\n' "$1" | awk '
{
n = split($0, a, /[[:space:]]+/)
executable = a[1]
sub(/^.*\//, "", executable)
if (executable != "escript") exit
for (i = 2; i <= n; i++)
if (a[i] ~ /^\/[^[:space:]]+\.(escript|erl)$/) { print a[i]; exit }
}'
}
lp_erlang_ssh_source_from_proc() {
[ -r "$1" ] || return 1
tr '\000' '\n' < "$1" 2>/dev/null | awk '
NR == 1 {
executable = $0
sub(/^.*\//, "", executable)
if (executable != "escript") exit
next
}
/^\/.*\.(escript|erl)$/ { print; exit }'
}
lp_erlang_ssh_markers() {
awk '
NR > 4096 { exit }
{
# Ignore comments and quoted strings, including markers placed in examples.
line = $0 "\n"
for (i = 1; i <= length(line); i++) {
c = substr(line, i, 1)
if (comment) { if (c == "\n") comment = 0; continue }
if (quoted) {
if (escape) { escape = 0; continue }
if (c == "\\") { escape = 1; continue }
if (c == quote) quoted = 0
continue
}
if (c == "%") { comment = 1; continue }
if (c == "\"" || c == "\047") { quoted = 1; quote = c; continue }
code = code c
}
}
END {
gsub(/[[:space:]]+/, " ", code)
if (code !~ /ssh[[:space:]]*:[[:space:]]*daemon[[:space:]]*\(/) exit
auth = "none"
if (code ~ /[{][[:space:]]*user_passwords[[:space:]]*,[[:space:]]*\[/) auth = "literal_user_passwords"
else if (code ~ /[{][[:space:]]*(user_passwords|password|pwdfun|user_dir_fun|publickey|public_key)[[:space:]]*,/) auth = "auth_option"
shell = "unknown"
if (code ~ /[{][[:space:]]*shell[[:space:]]*,[[:space:]]*disabled/ || code ~ /[{][[:space:]]*ssh_cli[[:space:]]*,[[:space:]]*no_cli/) shell = "disabled_marker"
else if (code ~ /[{][[:space:]]*(shell|ssh_cli)[[:space:]]*,/) shell = "explicit_option"
print auth, shell
}' "$1" 2>/dev/null
}
lp_check_erlang_ssh_daemons() {
# Optional arguments let focused fixtures supply a ps snapshot and proc root.
local snapshot="$1" proc_root="${2:-/proc}" ps_rows pid uid command source physical_dir canonical size markers auth shell
local candidates=0 files=0 printed=0 unknowns=0 seen_files='|'
if [ -n "$snapshot" ]; then
[ -r "$snapshot" ] || return 0
ps_rows=$(cat "$snapshot" 2>/dev/null)
else
ps_rows=$(ps -eo pid=,uid=,args= 2>/dev/null) || ps_rows=$(ps -axo pid=,uid=,command= 2>/dev/null) || return 0
fi
[ -n "$ps_rows" ] || return 0
while read -r pid uid command; do
case "$pid:$uid" in *[!0-9:]*|:*|*:) continue ;; esac
case "$command" in
erl|erlang|escript|beam|beam.smp|*/erl|*/erlang|*/escript|*/beam|*/beam.smp|erl\ *|erlang\ *|escript\ *|beam\ *|beam.smp\ *|*/erl\ *|*/erlang\ *|*/escript\ *|*/beam\ *|*/beam.smp\ *) ;;
*) continue ;;
esac
candidates=$((candidates + 1))
[ "$candidates" -le 64 ] || break
source=''
if [ -r "$proc_root/$pid/cmdline" ]; then
source=$(lp_erlang_ssh_source_from_proc "$proc_root/$pid/cmdline")
fi
[ -n "$source" ] || source=$(lp_erlang_ssh_source_from_ps "$command")
if [ -z "$source" ]; then
if [ "$unknowns" -lt 8 ]; then
[ "$printed" -eq 1 ] || { print_2title 'Running Erlang SSH source candidates' 'T1552.001'; printed=1; }
printf 'Candidate: Erlang PID %s UID %s; absolute launched source unavailable (SSH status unknown).\n' "$pid" "$uid"
unknowns=$((unknowns + 1))
fi
continue
fi
# Resolve parent directories physically; refuse a symlink at the final component.
case "$source" in /*.escript|/*.erl) ;; *) continue ;; esac
if [ -L "$source" ]; then
canonical=''
else
physical_dir=$(cd -P "${source%/*}" 2>/dev/null && pwd -P) || physical_dir=''
canonical="$physical_dir/${source##*/}"
fi
if [ -z "$physical_dir" ] || [ ! -f "$canonical" ] || [ ! -r "$canonical" ]; then
if [ "$unknowns" -lt 8 ]; then
[ "$printed" -eq 1 ] || { print_2title 'Running Erlang SSH source candidates' 'T1552.001'; printed=1; }
printf 'Candidate: Erlang PID %s UID %s; launched source inaccessible or symlinked: %s (SSH status unknown).\n' "$pid" "$uid" "$source"
unknowns=$((unknowns + 1))
fi
continue
fi
case "$seen_files" in
*"|$canonical|"*) ;;
*) seen_files="$seen_files$canonical|"
files=$((files + 1))
[ "$files" -le 32 ] || break ;;
esac
size=$(wc -c < "$canonical" 2>/dev/null) || continue
case "$size" in ''|*[!0-9]*) continue ;; esac
if [ "$size" -gt 131072 ]; then
if [ "$unknowns" -lt 8 ]; then
[ "$printed" -eq 1 ] || { print_2title 'Running Erlang SSH source candidates' 'T1552.001'; printed=1; }
printf 'Candidate: Erlang PID %s UID %s; launched source exceeds 128 KiB: %s (SSH status unknown).\n' "$pid" "$uid" "$canonical"
unknowns=$((unknowns + 1))
fi
continue
fi
markers=$(lp_erlang_ssh_markers "$canonical")
[ -n "$markers" ] || continue
auth=${markers%% *}
shell=${markers#* }
[ "$auth" != none ] || [ "$shell" != unknown ] || continue
[ "$printed" -eq 1 ] || { print_2title 'Running Erlang SSH source candidates' 'T1552.001'; printed=1; }
printf 'Candidate: Erlang SSH PID %s UID %s source %s; auth=%s, shell=%s.\n' "$pid" "$uid" "$canonical" "$auth" "$shell"
done <<EOF
$ps_rows
EOF
if [ "$printed" -eq 1 ]; then
printf '%s\n' 'Review the live daemon options and OTP version: readable source markers do not prove accepted credentials or an Erlang shell. No authentication was attempted.'
echo ''
fi
}
if ! [ "$SEARCH_IN_FOLDER" ]; then
lp_check_erlang_ssh_daemons
fi
@@ -6,10 +6,10 @@
# License: GNU GPL
# Version: 1.4
# Mitre: T1057
# Functions Used: print_2title, print_info, print_ps
# Functions Used: print_2title, print_info, print_ps, checkRootWritableProcessPaths
# Global Variables: $capsB, $knw_usrs, $nosh_usrs, $NOUSEPS, $processesB, $processesDump, $processesVB, $rootcommon, $SEARCH_IN_FOLDER, $sh_usrs, $USER, $Wfolders
# Initial Functions:
# Generated Global Variables: $pslist, $cpid, $caphex, $psline, $pid, $selinux_ctx, $current_env_vars, $env_findings, $apparmor_profile, $mount, $mount_findings, $fd_findings, $proc_cmd, $proc_user, $mount_point, $current_mounts, $fd_target, $var, $findings, $sec_findings, $proc_env_vars, $fd_count, $proc_mounts, $$escaped_var
# Generated Global Variables: $pslist, $cpid, $caphex, $psline, $pid, $selinux_ctx, $current_env_vars, $env_findings, $apparmor_profile, $mount, $mount_findings, $fd_findings, $proc_cmd, $proc_user, $mount_point, $current_mounts, $fd_target, $var, $findings, $sec_findings, $proc_env_vars, $fd_count, $proc_mounts, $office_uno_sockets, $spice_console_endpoints, $spice_exec_field, $$escaped_var
# Fat linpeas: 0
# Small linpeas: 1
@@ -215,6 +215,74 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
fi
}
# Inspect the already collected process list; do not connect to UNO or probe ports.
check_privileged_office_uno_sockets() {
[ -n "$pslist" ] || return
printf '%s\n' "$pslist" | awk '
NR > 5000 { exit }
length($0) > 4096 { next }
$1 == "root" &&
/(^|[[:space:]\/])(soffice(\.bin)?|libreoffice|openoffice)([[:space:]]|$)/ &&
/--accept=/ {
if (!match($0, /--accept=[^[:space:]]+/)) next
accept = substr($0, RSTART, RLENGTH)
sub(/^--accept=/, "", accept)
sub(/^"/, "", accept)
sub(/"$/, "", accept)
if (accept !~ /^socket,host=/ || accept !~ /;urp(;|$)/) next
if (!match(accept, /host=[^,;[:space:]]+/)) next
host = substr(accept, RSTART + 5, RLENGTH - 5)
if (!match(accept, /port=[0-9]+/)) next
port = substr(accept, RSTART + 5, RLENGTH - 5)
if (port + 0 < 1 || port + 0 > 65535) next
endpoint = host ":" port
if (!seen[endpoint]++) {
print endpoint
if (++count >= 5) exit
}
}'
}
# Reuse the process snapshot; a loopback SPICE option is a guest-console
# lead, not evidence of a live listener or privilege on the host.
check_unauthenticated_spice_console() {
[ -n "$pslist" ] || return
# The two cached snapshots have different executable columns. Select the
# actual producer format so argument text cannot impersonate an executable.
if [ -n "$NOUSEPS" ]; then spice_exec_field=3; else spice_exec_field=11; fi
printf '%s\n' "$pslist" | awk -v exec_field="$spice_exec_field" '
NR > 5000 { exit }
length($0) > 8192 || NF <= exec_field { next }
{
if ($(exec_field) !~ /(^|\/)qemu-system-[A-Za-z0-9_]+$/) next
first_arg = exec_field + 1
for (i = first_arg; i < NF && i < first_arg + 116; i++) {
if ($i != "-spice") continue
spec = $(i + 1)
gsub(/^"|"$/, "", spec)
if (length(spec) > 512) continue
n = split(spec, fields, ",")
if (n > 32) continue
addr = port = ""
noauth = conflicting_auth = 0
for (j = 1; j <= n; j++) {
if (fields[j] == "disable-ticketing" || fields[j] == "disable-ticketing=on") noauth = 1
else if (fields[j] ~ /^disable-ticketing=/ || fields[j] == "sasl=on" ||
fields[j] ~ /^password-secret=/ || fields[j] ~ /^x509-/) conflicting_auth = 1
else if (fields[j] ~ /^addr=/) addr = substr(fields[j], 6)
else if (fields[j] ~ /^port=/) port = substr(fields[j], 6)
}
if (!noauth || conflicting_auth || (addr != "127.0.0.1" && addr != "::1")) continue
if (port !~ /^[0-9]+$/ || port + 0 < 1 || port + 0 > 65535) continue
endpoint = (addr == "::1" ? "[::1]" : addr) ":" (port + 0)
if (!seen[endpoint]++) {
print endpoint
if (++count >= 5) exit
}
}
}'
}
if [ "$NOUSEPS" ]; then
print_ps | grep -v 'sed-Es' | sed -${E} "s,$Wfolders,${SED_RED},g" | sed -${E} "s,$sh_usrs,${SED_LIGHT_CYAN}," | sed -${E} "s,$nosh_usrs,${SED_BLUE}," | sed -${E} "s,$rootcommon,${SED_GREEN}," | sed -${E} "s,$knw_usrs,${SED_GREEN}," | sed "s,$USER,${SED_LIGHT_MAGENTA}," | sed "s,root,${SED_RED}," | sed -${E} "s,$processesVB,${SED_RED_YELLOW},g" | sed "s,$processesB,${SED_RED}," | sed -${E} "s,$processesDump,${SED_RED},"
pslist=$(print_ps)
@@ -233,6 +301,22 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
echo ""
fi
office_uno_sockets=$(check_privileged_office_uno_sockets)
if [ -n "$office_uno_sockets" ]; then
print_2title "Privileged office automation sockets" "T1068"
printf 'Root-owned process command advertises a LibreOffice/OpenOffice UNO acceptor at:\n%s\n' "$office_uno_sockets"
print_info "Confirm the listener and local access; a reachable UNO API can act with the office process identity: https://book.hacktricks.wiki/en/linux-hardening/processes-crontab-systemd-dbus/process-enumeration-and-service-paths.html#privileged-office-automation-sockets"
fi
spice_console_endpoints=$(check_unauthenticated_spice_console)
if [ -n "$spice_console_endpoints" ]; then
print_2title "Loopback guest-console review candidates" "T1057"
printf 'QEMU command line advertises SPICE without ticket authentication at:\n%s\n' "$spice_console_endpoints"
print_info "Verify the live listener, local access, and guest login/boot policy. Guest-console control is not host-root access: https://book.hacktricks.wiki/en/linux-hardening/processes-crontab-systemd-dbus/process-enumeration-and-service-paths.html"
fi
checkRootWritableProcessPaths
# Additional checks for each process
print_2title "Processes with unusual configurations" "T1057"
for pid in $(find /proc -maxdepth 1 -regex '/proc/[0-9]+' -printf "%f\n" 2>/dev/null); do
@@ -0,0 +1,61 @@
# Title: Processes & Cron & Services & Timers - System V Shared Memory
# ID: PR_Sysv_shared_memory
# Author: Carlos Polop
# Last Update: 2026-10-09
# Description: Report root-owned, other-writable System V shared-memory segments for passive review
# License: GNU GPL
# Version: 1.0
# Mitre: T1559
# Functions Used: print_2title, print_info
# Global Variables: $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables: $sysv_shm_report
# Fat linpeas: 0
# Small linpeas: 0
# This is a single snapshot. Short-lived segments can disappear before it runs;
# absence is not evidence that a privileged IPC consumer is safe. Never attach
# to a segment or launch a privileged helper to make one appear.
if [ ! "$SEARCH_IN_FOLDER" ] && command -v ipcs >/dev/null 2>&1; then
sysv_shm_report=$(LC_ALL=C ipcs -m 2>/dev/null | awk '
NR > 512 { limited = 1; exit }
{
if (tolower($1) == "key" && tolower($2) == "shmid" &&
tolower($3) == "owner" && tolower($4) == "perms") {
format = "linux"
next
}
if (toupper($1) == "T" && toupper($2) == "ID" &&
toupper($3) == "KEY" && toupper($4) == "MODE" &&
toupper($5) == "OWNER") {
format = "bsd"
next
}
if (format == "linux" && NF >= 5) {
id = $2; key = $1; owner = $3; perms = $4; size = $5
} else if (format == "bsd" && NF >= 6 && $1 == "m") {
id = $2; key = $3; perms = $4; owner = $5; size = "unknown"
} else next
if (owner != "root" && owner != "0") next
other_writable = 0
if (perms ~ /^[0-7]+$/ && length(perms) >= 3) {
last = substr(perms, length(perms), 1)
if (last ~ /[2367]/) other_writable = 1
} else if (perms ~ /^[-a-zA-Z][-a-zA-Z][r-][w-][xstST-][r-][w-][xstST-][r-][w-][xstST-]$/) {
if (substr(perms, length(perms) - 1, 1) == "w") other_writable = 1
}
if (!other_writable) next
if (shown < 20) {
printf "Root-owned System V shared memory writable by others: id=%s key=%s mode=%s size=%s\n", id, key, perms, size
shown++
} else limited = 1
}
END { if (limited) print "System V shared-memory review incomplete (512-line/20-candidate limit)" }
')
if [ "$sysv_shm_report" ]; then
print_2title "Writable privileged System V shared memory (review candidates)" "T1559"
print_info "https://book.hacktricks.wiki/en/linux-hardening/processes-crontab-systemd-dbus/process-enumeration-and-service-paths.html#system-v-shared-memory-consumed-by-privileged-processes"
printf '%s\n' "$sysv_shm_report"
printf '%s\n' "A writable segment needs a privileged consumer that trusts it to be exploitable; this snapshot can miss transient segments."
fi
fi
@@ -6,13 +6,651 @@
# License: GNU GPL
# Version: 1.2
# Mitre: T1053.003
# Functions Used: echo_not_found, print_2title, print_info
# Global Variables: $cronjobsG, $nosh_usrs, $SEARCH_IN_FOLDER, $sh_usrs, $USER, $Wfolders, $cronjobsB, $PATH
# Functions Used: check_pg_basebackup_boundary, echo_not_found, print_2title, print_3title, print_info
# Global Variables: $cronjobsG, $nosh_usrs, $SEARCH_IN_FOLDER, $sh_usrs, $USER, $Wfolders, $cronjobsB, $PATH, $PG_BASEBACKUP_DESTS
# Initial Functions:
# Generated Global Variables: $cmd, $VAR, $file, $path, $user_crontab, $username, $job_id, $cron_dir, $crontab, $findings, $line, $finding, $bin
# Generated Global Variables: $cmd, $VAR, $file, $path, $user_crontab, $username, $job_id, $cron_dir, $crontab, $findings, $line, $finding, $bin, $cron_log_timeout, $cron_log_status, $files, $cron_file, $prefix, $spool, $bash, $script, $log, $parent, $safe, $candidate, $rest, $part, $route, $mode, $sticky, $cron_tar_timeout, $cron_tar_status, $current_uid, $schedule, $spool_owner, $runas, $helper, $schedule_line, $owner_uid, $helper_text, $dir, $tar_cmd, $helper_line, $version, $cron_process_timeout, $cron_process_status, $magick_cwd, $magick_bin, $magick_cd_line, $magick_exec_line, $magick_marker, $cron_replace_status, $cron_replace_timeout, $cron_ansible_status, $cron_ansible_timeout, $glob, $depth, $kind, $wrapper_text, $chkrootkit_file, $chkrootkit_size, $chkrootkit_match, $file_port
# Fat linpeas: 0
# Small linpeas: 1
# Inspect only conventional installed script paths for the old slapper loop
# assignment defect. Never run the script or inspect its temporary-file inputs.
cron_chkrootkit_static_probe() (
if [ "$#" -eq 0 ]; then
set -- /usr/sbin/chkrootkit /usr/bin/chkrootkit
fi
for chkrootkit_file do
[ ! -L "$chkrootkit_file" ] && [ -f "$chkrootkit_file" ] && [ -r "$chkrootkit_file" ] || continue
chkrootkit_size=$(dd if="$chkrootkit_file" bs=65537 count=1 2>/dev/null | wc -c | tr -d '[:space:]') || continue
case "$chkrootkit_size" in ''|*[!0-9]*) continue ;; esac
[ "$chkrootkit_size" -gt 0 ] && [ "$chkrootkit_size" -le 65536 ] || continue
chkrootkit_match=$(dd if="$chkrootkit_file" bs=65536 count=1 2>/dev/null | LC_ALL=C awk '
NR > 256 { exit }
/^[[:space:]]*slapper[[:space:]]*\([[:space:]]*\)[[:space:]]*\{/ {
inside = 1; start = NR; files = loop = guard_line = 0; next
}
inside && NR - start > 80 { inside = 0 }
inside {
if ($0 ~ /^[[:space:]]*}[[:space:]]*$/) inside = 0
if (loop && $0 ~ /^[[:space:]]*done([[:space:]]|$)/) { loop = guard_line = 0 }
if ($0 ~ /^[[:space:]]*SLAPPER_FILES=/ && $0 ~ /tmp\//) files = 1
if ($0 ~ /^[[:space:]]*for[[:space:]]+i[[:space:]]+in[[:space:]]+\$\{SLAPPER_FILES\};[[:space:]]*do/) loop = 1
if (loop && $0 ~ /^[[:space:]]*if[[:space:]]+\[[[:space:]]+-f[[:space:]]+\$\{i\}[[:space:]]+\][[:space:]]*;?[[:space:]]*then/) guard_line = NR
if (files && loop && guard_line && NR - guard_line <= 3 &&
$0 ~ /^[[:space:]]*file_port=\$file_port[[:space:]]+\$i[[:space:]]*(#.*)?$/) {
print "yes"; exit
}
}
' 2>/dev/null) || continue
[ "$chkrootkit_match" = yes ] || continue
printf 'Checker script review candidate: %s has an unquoted slapper-loop assignment; verify root scheduling, writable executable temporary path, mount policy and installed patch state.\n' "$chkrootkit_file"
break
done
)
# Correlate a literal root cron playbook glob with a directory the current
# user can populate. Never expand the glob, inspect playbooks, or run Ansible.
cron_ansible_glob_probe() {
cron_ansible_timeout=$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
[ -n "$cron_ansible_timeout" ] || return 0
"$cron_ansible_timeout" 3 sh -c '
LC_ALL=C; export LC_ALL
[ "$(id -u 2>/dev/null)" != 0 ] || exit 0
files=0
for cron_file do
files=$((files + 1))
if [ "$files" -gt 12 ]; then
echo "Cron Ansible playbook correlation: incomplete (12-file limit)."
break
fi
[ -f "$cron_file" ] && [ -r "$cron_file" ] && [ ! -L "$cron_file" ] || continue
schedule=$(dd if="$cron_file" bs=8193 count=1 2>/dev/null) || continue
if [ "${#schedule}" -ge 8192 ]; then
echo "Cron Ansible playbook correlation: incomplete (8 KiB schedule limit): $cron_file"
continue
fi
case "$schedule" in *ansible-parallel*) ;; *) continue ;; esac
case "$cron_file" in */spool/cron/root|*/spool/cron/crontabs/root) spool=1 ;; *) spool=0 ;; esac
printf "%s\n" "$schedule" | awk -v spool="$spool" '\''
NR > 32 || length($0) > 512 { partial = 1; next }
/^[[:space:]]*(#|$)/ { next }
{
for (i = 1; i <= 5; i++) if ($i !~ /^[0-9*,\/-]+$/) next
if (spool) {
if (NF != 7) next
helper = $6; glob = $7
} else {
if (NF != 8 || $6 != "root") next
helper = $7; glob = $8
}
if (helper !~ /^\/[A-Za-z0-9_.\/+\-]+\/ansible-parallel$/ ||
glob !~ /^\/[A-Za-z0-9_.\/+\-]+\/\*\.(yml|yaml)$/) next
print glob "|" NR
}
END { if (partial) print "#PARTIAL" }
'\'' | while IFS="|" read -r glob schedule_line; do
if [ "$glob" = "#PARTIAL" ]; then
echo "Cron Ansible playbook correlation: incomplete (line/column limit): $cron_file"
continue
fi
case "$glob" in *"/../"*|*"/./"*|*"//"*) continue ;; esac
dir=${glob%/*}
[ -d "$dir" ] && [ -w "$dir" ] && [ -x "$dir" ] || continue
path=; rest=${dir#/}; safe=1; depth=0
while [ -n "$rest" ]; do
depth=$((depth + 1))
[ "$depth" -le 16 ] || { safe=0; break; }
part=${rest%%/*}; path=$path/$part
if [ -L "$path" ]; then safe=0; break; fi
case "$rest" in */*) rest=${rest#*/} ;; *) rest= ;; esac
done
[ "$safe" -eq 1 ] || continue
mode=$(ls -ld "$dir" 2>/dev/null) || continue
mode=${mode%% *}
case $(printf "%s" "$mode" | cut -c 10) in t|T) continue ;; esac
echo "Cron Ansible playbook review candidate: $cron_file:$schedule_line (root cron passes $glob to helper; current user can create entries in $dir; verify helper behavior, ACLs, mount policy, and scheduler state)"
done
done
' sh "$@"
cron_ansible_status=$?
case "$cron_ansible_status" in
124|137) echo "Cron Ansible playbook correlation: incomplete (3-second timeout)." ;;
0) ;;
*) echo "Cron Ansible playbook correlation: incomplete (metadata error)." ;;
esac
}
# A read-only cron script may still be replaceable through its parent. Inspect
# only literal script paths in a few visible cron files; no job is run.
cron_replaceable_script_probe() {
cron_replace_timeout=$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
[ -n "$cron_replace_timeout" ] || return 0
"$cron_replace_timeout" 3 sh -c '
LC_ALL=C; export LC_ALL
current_uid=$(id -u 2>/dev/null) || exit 1
files=0
for cron_file do
files=$((files + 1))
if [ "$files" -gt 12 ]; then
echo "Cron script replacement review incomplete (12-file limit)."
break
fi
[ -f "$cron_file" ] && [ -r "$cron_file" ] && [ ! -L "$cron_file" ] || continue
schedule=$(dd if="$cron_file" bs=8193 count=1 2>/dev/null) || continue
if [ "${#schedule}" -ge 8192 ]; then
echo "Cron script replacement review incomplete (8 KiB schedule limit): $cron_file"
continue
fi
spool_owner=
case "$cron_file" in */spool/cron/root|*/spool/cron/crontabs/root) spool_owner=root ;; esac
printf "%s\n" "$schedule" | awk -v owner="$spool_owner" '\''
NR > 32 || length($0) > 512 { partial = 1; next }
/^[[:space:]]*(#|$)/ { next }
{
for (i = 1; i <= 5; i++) if ($i !~ /^[0-9*,\/-]+$/) next
n = NF
if (n >= 10 && $(n-2) == ">>" && $(n-1) == "/dev/null" && $n == "2>&1") n -= 3
if (owner == "") {
runas = $6; shell = $7; script = $8
kind = (NF == 8 ? "shell" : (NF == 7 ? "php" : ""))
if (kind == "php") script = $7
if ((shell == "php" || shell == "/usr/bin/php" || shell == "/bin/php") &&
(n == 8 || n == 9) && (n == 8 || $9 ~ /^[A-Za-z0-9_.:-]+$/)) {
kind = "php_cli"; script = $8
}
} else {
runas = owner; shell = $6; script = $7
kind = (NF == 7 ? "shell" : (NF == 6 ? "php" : ""))
if (kind == "php") script = $6
if ((shell == "php" || shell == "/usr/bin/php" || shell == "/bin/php") &&
(n == 7 || n == 8) && (n == 7 || $8 ~ /^[A-Za-z0-9_.:-]+$/)) {
kind = "php_cli"; script = $7
}
}
if (runas !~ /^[A-Za-z_][A-Za-z0-9_-]*$/ || kind == "" ||
(kind == "shell" && shell != "/bin/sh" && shell != "/usr/bin/sh" &&
shell != "/bin/bash" && shell != "/usr/bin/bash") ||
script !~ /^\/[A-Za-z0-9_\/.+-]+$/) next
print runas "|" script "|" NR "|" kind
}
END { if (partial) print "#PARTIAL" }
'\'' | while IFS="|" read -r runas script schedule_line kind; do
if [ "$runas" = "#PARTIAL" ]; then
echo "Cron script replacement review incomplete (line/column limit): $cron_file"
continue
fi
case "$script" in *"/../"*|*"/.."|*"/./"*|*"/."|*"//"*) continue ;; esac
owner_uid=$(id -u "$runas" 2>/dev/null) || continue
[ "$owner_uid" != "$current_uid" ] || continue
if [ "$kind" = php_cli ]; then
[ "$owner_uid" = 0 ] && [ -f "$script" ] && [ -r "$script" ] &&
[ -w "$script" ] && [ ! -L "$script" ] || continue
path=; rest=${script#/}; safe=1
while [ -n "$rest" ]; do
part=${rest%%/*}; path=$path/$part
if [ -L "$path" ]; then safe=0; break; fi
case "$rest" in */*) rest=${rest#*/} ;; *) rest= ;; esac
done
[ "$safe" -eq 1 ] || continue
echo "Cron PHP script review candidate: $cron_file:$schedule_line (root PHP runs current-user-writable $script; verify scheduler state, script identity, ACLs, and mount policy)"
continue
fi
if [ "$kind" = php ]; then
[ "$owner_uid" = 0 ] && [ -f "$script" ] && [ -r "$script" ] && [ ! -L "$script" ] || continue
path=; rest=${script#/}; safe=1
while [ -n "$rest" ]; do
part=${rest%%/*}; path=$path/$part
if [ -L "$path" ]; then safe=0; break; fi
case "$rest" in */*) rest=${rest#*/} ;; *) rest= ;; esac
done
[ "$safe" -eq 1 ] || continue
wrapper_text=$(dd if="$script" bs=4097 count=1 2>/dev/null) || continue
[ "${#wrapper_text}" -lt 4096 ] || continue
printf "%s\n" "$wrapper_text" | awk "NR == 1 { exit !(/^#!\\/(usr\\/bin|bin)\\/php([0-9.]*)?([[:space:]]|$)/) }" || continue
printf "%s\n" "$wrapper_text" | awk '\''
NR > 32 || length($0) > 512 { next }
{
line = $0
sub(/^[ \t]*/, "", line)
if (line !~ /^exec[ \t]*\(/) next
sub(/^exec[ \t]*\([ \t]*/, "", line)
quote = substr(line, 1, 1)
if (quote != sprintf("%c", 39) && quote != "\"") next
line = substr(line, 2)
if (match(line, /^\/[A-Za-z0-9_\/.+-]+/) == 0) next
helper = substr(line, 1, RLENGTH)
tail = substr(line, RLENGTH + 1)
if (substr(tail, 1, 1) != quote) next
tail = substr(tail, 2)
if (tail !~ /^[ \t]*\)[ \t]*;[ \t]*(\/\/.*)?$/) next
if (++found > 2) exit
print helper
}
'\'' | while IFS= read -r helper; do
case "$helper" in *"/../"*|*"/.."|*"/./"*|*"/."|*"//"*) continue ;; esac
[ ! -e "$helper" ] && [ ! -L "$helper" ] || continue
parent=${helper%/*}; [ -n "$parent" ] || parent=/
[ -d "$parent" ] && [ -w "$parent" ] && [ -x "$parent" ] || continue
path=; rest=${helper#/}; safe=1
while [ -n "$rest" ]; do
part=${rest%%/*}; path=$path/$part
if [ -L "$path" ]; then safe=0; break; fi
case "$rest" in */*) rest=${rest#*/} ;; *) rest= ;; esac
done
[ "$safe" -eq 1 ] || continue
mode=$(ls -ld "$parent" 2>/dev/null) || continue
mode=${mode%% *}
case $(printf "%s" "$mode" | cut -c 10) in t|T) continue ;; esac
echo "Cron PHP helper review candidate: $cron_file:$schedule_line (root script $script calls absent $helper in current-user-writable $parent; verify scheduler state, PHP policy, ACLs, and mount policy)"
done
continue
fi
[ -f "$script" ] && [ ! -L "$script" ] && [ ! -w "$script" ] || continue
parent=${script%/*}; [ -n "$parent" ] || parent=/
[ -d "$parent" ] && [ -w "$parent" ] && [ -x "$parent" ] || continue
path=; rest=${script#/}; safe=1
while [ -n "$rest" ]; do
part=${rest%%/*}; path=$path/$part
if [ -L "$path" ]; then safe=0; break; fi
case "$rest" in */*) rest=${rest#*/} ;; *) rest= ;; esac
done
[ "$safe" -eq 1 ] || continue
mode=$(ls -ld "$parent" 2>/dev/null) || continue
mode=${mode%% *}
sticky=$(printf "%s" "$mode" | cut -c 10)
case "$sticky" in t|T) continue ;; esac
echo "Cron script replacement review candidate: $cron_file:$schedule_line ($runas runs $script; current user can replace its directory entry in $parent; verify ACLs, mount policy, and scheduler state)"
done
done
' sh "$@"
cron_replace_status=$?
case "$cron_replace_status" in
124|137) echo 'Cron script replacement review incomplete (3-second timeout).' ;;
0) ;;
*) echo 'Cron script replacement review incomplete (metadata error).' ;;
esac
}
# Inspect only literal, visible root cron commands. The whole probe has a wall-clock
# limit; without timeout, filesystem metadata on an unresponsive mount is unbounded.
cron_log_input_probe() {
cron_log_timeout=$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
if [ -z "$cron_log_timeout" ]; then
echo "Cron log input correlation: unknown (timeout unavailable)."
return
fi
"$cron_log_timeout" 3 sh -c '
LC_ALL=C; export LC_ALL
files=0
for cron_file do
files=$((files + 1))
if [ "$files" -gt 8 ]; then
echo "Cron log input correlation: truncated at 8 cron files."
break
fi
if [ -L "$cron_file" ] || [ ! -f "$cron_file" ] || [ ! -r "$cron_file" ]; then
continue
fi
prefix=$(dd if="$cron_file" bs=8193 count=1 2>/dev/null) || continue
if [ "${#prefix}" -ge 8192 ]; then
echo "Cron log input correlation: skipped oversized cron file: $cron_file"
continue
fi
case "$cron_file" in
*/root) spool=1 ;;
*) spool=0 ;;
esac
printf "%s\n" "$prefix" | awk -v spool="$spool" '\''NR <= 16 && length($0) <= 512 && $0 !~ /^[[:space:]]*(#|$)/ {
for (i=1; i<=5; i++) if ($i !~ /^[0-9*,\/-]+$/) next
if (spool == 0) {
if (NF != 9 || $6 != "root") next
bash=$7; script=$8; input=$9
} else {
if (NF != 8) next
bash=$6; script=$7; input=$8
}
if (bash != "/bin/bash" && bash != "/usr/bin/bash") next
if (script !~ /^\/[A-Za-z0-9_.\/-]+$/ || input !~ /^\/[A-Za-z0-9_.\/-]+\.log$/) next
print bash "|" script "|" input
}
END { if (NR > 16) print "#TRUNCATED" }'\'' | while IFS="|" read -r bash script log; do
if [ "$bash" = "#TRUNCATED" ]; then
echo "Cron log input correlation: truncated at 16 lines: $cron_file"
continue
fi
case "$script:$log" in *"/../"*|*"/..:"*|*"/./"*|*"//"*) continue ;; esac
[ -L "$script" ] && continue
[ -f "$script" ] && [ -r "$script" ] || continue
[ -L "$log" ] && continue
[ -f "$log" ] || continue
parent=${log%/*}; [ -n "$parent" ] || parent=/
[ -d "$parent" ] || continue
# Reject symlinks in every ancestor; metadata otherwise describes a
# different path than the one the privileged command would resolve.
safe=1
for candidate in "$script" "$log"; do
path=; rest=${candidate#/}
while [ -n "$rest" ]; do
part=${rest%%/*}; path=$path/$part
if [ -L "$path" ]; then safe=0; break; fi
case "$rest" in */*) rest=${rest#*/} ;; *) rest= ;; esac
done
[ "$safe" -eq 1 ] || break
done
[ "$safe" -eq 1 ] || continue
route=
if [ -w "$log" ]; then route="current user can write input file"; fi
if [ -w "$parent" ] && [ -x "$parent" ]; then
mode=$(ls -ld "$parent" 2>/dev/null) || continue
mode=${mode%% *}
sticky=$(printf "%s" "$mode" | cut -c 10)
case "$sticky" in t|T) ;; *) route="current user can replace directory entry" ;; esac
fi
[ -n "$route" ] || continue
echo "Cron log input review candidate: $cron_file"
echo " Root command: $bash $script $log"
echo " Input: $log ($route)"
ls -ld "$parent" "$log" 2>/dev/null
echo " Metadata only; parser behavior and ACL/mount policy need review."
done
done
' sh "$@"
cron_log_status=$?
case "$cron_log_status" in
124|137) echo "Cron log input correlation: timed out; visibility unknown." ;;
0) ;;
*) echo "Cron log input correlation: incomplete; visibility unknown." ;;
esac
}
# Correlate a visible cron identity with one literal helper and a GNU tar glob.
# This intentionally recognizes only simple, adjacent `cd DIR` and `tar -cf
# ARCHIVE *` lines. Shell expansion, other archivers, and nested helpers need
# manual review; no scheduled command or archive operation is run here.
cron_tar_wildcard_probe() {
cron_tar_timeout=$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
if [ -z "$cron_tar_timeout" ]; then
echo "Cron tar wildcard correlation: unknown (timeout unavailable)."
return
fi
"$cron_tar_timeout" 5 sh -c '
LC_ALL=C; export LC_ALL
current_uid=$(id -u 2>/dev/null) || exit 1
[ "$current_uid" -ne 0 ] || exit 0
files=0
for cron_file do
files=$((files + 1))
if [ "$files" -gt 12 ]; then
echo "Cron tar wildcard correlation: unknown beyond 12 schedule files."
break
fi
[ -f "$cron_file" ] && [ -r "$cron_file" ] && [ ! -L "$cron_file" ] || continue
schedule=$(dd if="$cron_file" bs=8193 count=1 2>/dev/null) || continue
if [ "${#schedule}" -ge 8192 ]; then
echo "Cron tar wildcard correlation: unknown (oversized schedule: $cron_file)."
continue
fi
spool_owner=
case "$cron_file" in */spool/cron/*) spool_owner=${cron_file##*/} ;; esac
printf "%s\n" "$schedule" | awk -v owner="$spool_owner" '\''
NR > 32 { truncated=1; next }
length($0) > 512 { truncated=1; next }
/^[[:space:]]*(#|$)/ { next }
{
for (i=1; i<=5; i++) if ($i !~ /^[0-9*,\/-]+$/) next
if (owner == "") {
if (NF != 8) next
runas=$6; interpreter=$7; helper=$8
} else {
if (NF != 7) next
runas=owner; interpreter=$6; helper=$7
}
if (interpreter != "/bin/sh" && interpreter != "/usr/bin/sh" &&
interpreter != "/bin/bash" && interpreter != "/usr/bin/bash") next
if (helper ~ /^\/[A-Za-z0-9_.\/-]+$/) print runas "|" helper "|" NR
}
END { if (truncated) print "#TRUNCATED" }
'\'' | while IFS="|" read -r runas helper schedule_line; do
if [ "$runas" = "#TRUNCATED" ]; then
echo "Cron tar wildcard correlation: unknown beyond schedule line/length cap: $cron_file."
continue
fi
case "$runas" in ""|*[!A-Za-z0-9_-]*) continue ;; esac
case "$helper" in *"/../"*|*"/.."|*"/./"*|*"/."|*"//"*) continue ;; esac
owner_uid=$(id -u "$runas" 2>/dev/null) || continue
[ "$owner_uid" != "$current_uid" ] || continue
[ -f "$helper" ] && [ -r "$helper" ] && [ ! -L "$helper" ] || continue
helper_text=$(dd if="$helper" bs=4097 count=1 2>/dev/null) || continue
if [ "${#helper_text}" -ge 4096 ]; then
echo "Cron tar wildcard correlation: unknown (oversized helper: $helper)."
continue
fi
printf "%s\n" "$helper_text" | awk '\''
NR > 24 { truncated=1; next }
length($0) > 512 { truncated=1; next }
/^[[:space:]]*(#|$)/ { next }
{
if (NF == 2 && $1 == "cd" && $2 ~ /^\/[A-Za-z0-9_.\/-]+$/) {
dir=$2; next
}
if (dir != "" && NF == 4 &&
($1 == "tar" || $1 == "/usr/bin/tar" || $1 == "/bin/tar" || $1 == "gtar") &&
$2 ~ /^-[A-Za-z]+$/ && $2 ~ /c/ && $2 ~ /f/ &&
$3 ~ /^[A-Za-z0-9_.\/-]+$/ && $4 == "*") {
print dir "|" $1 "|" NR
}
dir=""
}
END { if (truncated) print "#TRUNCATED" }
'\'' | while IFS="|" read -r dir tar_cmd helper_line; do
if [ "$dir" = "#TRUNCATED" ]; then
echo "Cron tar wildcard correlation: unknown beyond helper line/length cap: $helper."
continue
fi
case "$dir" in *"/../"*|*"/.."|*"/./"*|*"/."|*"//"*) continue ;; esac
[ -d "$dir" ] && [ -w "$dir" ] && [ -x "$dir" ] && [ ! -L "$dir" ] || continue
version=$($tar_cmd --version 2>/dev/null | awk '\''NR == 1 { print; exit }'\'')
case "$version" in *"GNU tar"*) ;; *) continue ;; esac
echo "Cron GNU tar wildcard review candidate: $cron_file:$schedule_line"
echo " Schedule owner: $runas (uid $owner_uid); helper: $helper:$helper_line"
echo " Working directory: $dir (current user can write and enter)"
echo " Command: $tar_cmd archive * (unquoted glob, no --); GNU tar version observed"
echo " Candidate only; execution context and file ownership need review."
done
done
done
' sh "$@"
cron_tar_status=$?
case "$cron_tar_status" in
124|137) echo "Cron tar wildcard correlation: timed out; visibility unknown." ;;
0) ;;
*) echo "Cron tar wildcard correlation: incomplete; visibility unknown." ;;
esac
}
# Read only literal shell helpers in visible root cron entries. This is a
# conservative text correlation, not a shell parser or proof of exploitability.
cron_process_args_probe() {
cron_process_timeout=$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
if [ -z "$cron_process_timeout" ]; then
echo "Cron process arguments correlation: unknown (timeout unavailable)."
return
fi
"$cron_process_timeout" 5 sh -c '
LC_ALL=C; export LC_ALL
files=0
for cron_file do
files=$((files + 1))
if [ "$files" -gt 6 ]; then
echo "Cron process arguments correlation: unknown beyond 6 schedule files."
break
fi
[ -e "$cron_file" ] || continue
if [ -L "$cron_file" ] || [ ! -f "$cron_file" ] || [ ! -r "$cron_file" ]; then
echo "Cron process arguments correlation: unknown (unreadable schedule: $cron_file)."
continue
fi
schedule=$(dd if="$cron_file" bs=8193 count=1 2>/dev/null) || {
echo "Cron process arguments correlation: unknown (unreadable schedule: $cron_file)."
continue
}
if [ "${#schedule}" -ge 8192 ]; then
echo "Cron process arguments correlation: unknown (oversized schedule: $cron_file)."
continue
fi
spool_owner=
case "$cron_file" in */spool/cron/root|*/spool/cron/crontabs/root) spool_owner=root ;; esac
printf "%s\n" "$schedule" | awk -v owner="$spool_owner" '\''
NR > 32 { truncated=1; next }
length($0) > 512 { truncated=1; next }
/^[[:space:]]*(#|$)/ { next }
{
for (i=1; i<=5; i++) if ($i !~ /^[0-9*,\/-]+$/) next
if (owner == "") {
if (NF != 8 || $6 != "root") next
interpreter=$7; helper=$8
} else {
if (NF != 7) next
interpreter=$6; helper=$7
}
if (interpreter != "/bin/sh" && interpreter != "/usr/bin/sh" &&
interpreter != "/bin/bash" && interpreter != "/usr/bin/bash") next
if (helper !~ /^\/[A-Za-z0-9_.\/-]+$/ ||
helper ~ /\/\.\.?($|\/)/ || helper ~ /\/\//) next
if (++helpers > 2) { capped=1; next }
print helper "|" NR
}
END {
if (truncated) print "#LINES"
if (capped) print "#HELPERS"
}
'\'' | while IFS="|" read -r helper schedule_line; do
case "$helper" in
"#LINES") echo "Cron process arguments correlation: unknown beyond schedule line/length cap: $cron_file."; continue ;;
"#HELPERS") echo "Cron process arguments correlation: unknown beyond 2 helpers: $cron_file."; continue ;;
esac
if [ -L "$helper" ] || [ ! -f "$helper" ] || [ ! -r "$helper" ]; then
echo "Cron process arguments correlation: unknown (unreadable helper: $helper)."
continue
fi
helper_text=$(dd if="$helper" bs=4097 count=1 2>/dev/null) || {
echo "Cron process arguments correlation: unknown (unreadable helper: $helper)."
continue
}
if [ "${#helper_text}" -ge 4096 ]; then
echo "Cron process arguments correlation: unknown (oversized helper: $helper)."
continue
fi
printf "%s\n" "$helper_text" | awk -v cron="$cron_file" -v schedule_line="$schedule_line" -v helper="$helper" '\''
NR > 32 { truncated=1; next }
length($0) > 512 { truncated=1; next }
/^[[:space:]]*(#|$)/ { next }
{
line=$0
if (line ~ /pgrep[[:space:]]+(-[A-Za-z]*f[A-Za-z]*|--full)([[:space:]]|$)/ &&
line ~ /[[:space:]]read[[:space:]]/) {
capture=line
sub(/^.*[[:space:]]read[[:space:]]+/, "", capture)
sub(/^-r[[:space:]]+/, "", capture)
sub(/[;|].*$/, "", capture)
n=split(capture, fields, /[[:space:]]+/)
if (fields[n] ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
input=fields[n]; select_line=NR
derived=""; derive_line=0; execute_line=0; apache=0; config=0
}
}
if (input != "" && line ~ /^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=/ &&
index(line, "$" input) && line ~ /sed|\/\//) {
derived=line
sub(/^[[:space:]]*/, "", derived)
sub(/=.*/, "", derived)
if (derived ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
derive_line=NR
if (line ~ /apache2ctl|apachectl|httpd/) apache=1
if (line ~ /[[:space:]]-t([^A-Za-z0-9_-]|$)/) config=1
}
}
if (derived != "" && index(line, "$" derived) &&
line ~ /[[:space:]]-t([^A-Za-z0-9_-]|$)/) config=1
if (derived != "" && line ~ /^[[:space:]]*\$[A-Za-z_][A-Za-z0-9_]*/ && NR > derive_line) {
executed=line
sub(/^[[:space:]]*\$/, "", executed)
sub(/[^A-Za-z0-9_].*$/, "", executed)
if (executed == derived) execute_line=NR
}
}
END {
if (truncated) {
print "Cron process arguments correlation: unknown beyond helper line/length cap: " helper "."
} else if (select_line && derive_line && execute_line) {
print "Cron process arguments review candidate: " cron ":" schedule_line
print " Schedule owner: root; helper: " helper
print " Full-args pgrep/read: helper line " select_line "; derived command: line " derive_line "; execution: line " execute_line
if (apache && config) print " Apache config-test option flow observed; review process identity and config inputs."
print " Candidate only; process arguments are unauthenticated input."
}
}
'\''
# Reuse the same bounded helper text for the Linux AppImage search-path
# lead. No image tool is executed: a crafted CWD could load code on run.
if [ "$(uname -s 2>/dev/null)" = Linux ]; then
printf "%s\n" "$helper_text" | awk '\''
NR > 32 || length($0) > 512 { exit }
/^[[:space:]]*(#|$)/ { next }
{
line=$0
sub(/^[[:space:]]+/, "", line)
sub(/[[:space:]]+#.*$/, "", line)
if (line ~ /^cd[[:space:]]+/) {
cwd=line
sub(/^cd[[:space:]]+/, "", cwd)
sub(/[[:space:]]+$/, "", cwd)
if (cwd ~ /^\/[A-Za-z0-9_.\/-]+$/ &&
cwd !~ /\/\.\.?($|\/)/ && cwd !~ /\/\//) {
cd_line=NR
} else { cwd=""; cd_line=0 }
next
}
if (!cwd) next
n=split(line, words, /[[:space:]]+/)
for (i=1; i<n; i++) {
if (words[i] ~ /^\/[A-Za-z0-9_.\/-]+\/magick$/ &&
words[i+1] == "identify" &&
(i == 1 || words[i-1] == "xargs")) {
print cwd "|" words[i] "|" cd_line "|" NR
exit
}
}
}
'\'' | while IFS="|" read -r magick_cwd magick_bin magick_cd_line magick_exec_line; do
[ -d "$magick_cwd" ] && [ -w "$magick_cwd" ] && [ -x "$magick_cwd" ] || continue
[ -f "$magick_bin" ] && [ -x "$magick_bin" ] || continue
magick_marker=""
if [ -r "$magick_bin" ] && command -v od >/dev/null 2>&1; then
magick_marker=$(dd if="$magick_bin" bs=1 skip=8 count=3 2>/dev/null | od -An -tx1 2>/dev/null | tr -d "[:space:]")
fi
echo "Cron ImageMagick working-directory review candidate: $cron_file:$schedule_line"
echo " Schedule owner: root; helper: $helper (cd line $magick_cd_line; identify line $magick_exec_line)"
echo " Caller-writable working directory: $magick_cwd; executable: $magick_bin"
if [ "$magick_marker" = 414902 ]; then
echo " AppImage type-2 marker found; verify affected ImageMagick AppRun search paths and version."
else
echo " AppImage format not confirmed; native builds and safe search paths may be unaffected."
fi
echo " Passive correlation only; cron execution, loader paths, and mount/ACL policy need review."
done
fi
done
done
' sh "$@"
cron_process_status=$?
case "$cron_process_status" in
124|137) echo "Cron process arguments correlation: timed out; visibility unknown." ;;
0) ;;
*) echo "Cron process arguments correlation: incomplete; visibility unknown." ;;
esac
}
if ! [ "$SEARCH_IN_FOLDER" ]; then
print_2title "Check for vulnerable cron jobs" "T1053.003"
print_info "https://book.hacktricks.wiki/en/linux-hardening/processes-crontab-systemd-dbus/cron-and-systemd-timers.html#enumerate-schedules"
@@ -26,6 +664,20 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
cat /etc/cron* /etc/at* /etc/anacrontab /var/spool/cron/crontabs/* /etc/incron.d/* /var/spool/incron/* 2>/dev/null | tr -d "\r" | grep -v "^#" | sed -${E} "s,$Wfolders,${SED_RED_YELLOW},g" | sed -${E} "s,$sh_usrs,${SED_LIGHT_CYAN}," | sed "s,$USER,${SED_LIGHT_MAGENTA}," | sed -${E} "s,$nosh_usrs,${SED_BLUE}," | sed "s,root,${SED_RED},"
grep -Hn '^PATH=' /etc/crontab /etc/cron.d/* 2>/dev/null | sed -${E} "s,$Wfolders,${SED_RED_YELLOW},g"
grep -RInE 'pg_basebackup|run-parts|crontab-ui' /etc/crontab /etc/cron.d /etc/anacrontab /var/spool/cron/crontabs /etc/incron.d /var/spool/incron 2>/dev/null | sed -${E} "s,$cronjobsB,${SED_RED},g" | sed -${E} "s,$Wfolders,${SED_RED_YELLOW},g"
print_3title "Root cron literal log inputs (passive review)" "T1053.003"
cron_log_input_probe /etc/crontab /etc/cron.d/* /var/spool/cron/crontabs/root /var/spool/cron/root
cron_replaceable_script_probe /etc/crontab /etc/cron.d/* /var/spool/cron/crontabs/root /var/spool/cron/root
cron_ansible_glob_probe /etc/crontab /etc/cron.d/* /var/spool/cron/crontabs/root /var/spool/cron/root
echo "Only readable root entries were inspected; private crontabs and other schedules may be invisible."
print_3title "GNU tar cron wildcard inputs (passive review)" "T1053.003"
cron_tar_wildcard_probe /etc/crontab /etc/cron.d/* /var/spool/cron/crontabs/* /var/spool/cron/*
echo "Only readable cron schedules were inspected; private tasks remain unknown."
print_3title "Root cron process arguments (passive review)" "T1053.003"
cron_process_args_probe /etc/crontab /etc/cron.d/* /var/spool/cron/crontabs/root /var/spool/cron/root
echo "Only literal, readable root cron helpers were inspected; private tasks remain unknown."
cron_chkrootkit_static_probe
PG_BASEBACKUP_DESTS=
check_pg_basebackup_boundary
crontab -l -u "$USER" 2>/dev/null | tr -d "\r"
ls -lR /usr/lib/cron/tabs/ /private/var/at/jobs /var/at/tabs/ /etc/periodic/ 2>/dev/null | sed -${E} "s,$cronjobsG,${SED_GREEN},g" | sed "s,$cronjobsB,${SED_RED},g" #MacOS paths
atq 2>/dev/null
@@ -9,7 +9,7 @@
# Functions Used: echo_not_found, print_2title, print_info, print_3title
# Global Variables: $SEARCH_IN_FOLDER, $timersG
# Initial Functions:
# Generated Global Variables: $timer_unit, $timer_path, $timer_content, $exec_path, $timer_file, $line, $findings, $unit_path, $finding, $service_unit, $timer, $target_unit, $target_file
# Generated Global Variables: $timer_unit, $timer_path, $exec_path, $timer_file, $line, $findings, $unit_path, $finding, $service_unit, $timer, $target_unit, $target_file, $timer_count, $timer_unit_property, $service_properties, $service_user, $service_user_seen, $dynamic_user, $exec_start, $property, $timer_restart_timeout, $timer_restart_queries, $timer_restart_targets, $restart_target, $restart_properties, $restart_user, $restart_user_seen, $restart_dynamic_user, $restart_root_dir, $restart_root_dir_seen, $restart_root_image, $restart_root_image_seen, $restart_exec, $restart_exec_path, $restart_script
# Fat linpeas: 0
# Small linpeas: 1
@@ -21,17 +21,47 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
check_timer_content() {
local timer="$1"
local findings=""
local restart_target restart_properties restart_user restart_user_seen
local restart_dynamic_user restart_root_dir restart_root_dir_seen
local restart_root_image restart_root_image_seen restart_exec
local restart_exec_path restart_script
# Get the service unit this timer activates
local service_unit=$(systemctl show "$timer" -p Unit 2>/dev/null | cut -d= -f2)
local timer_unit_property=$(systemctl show "$timer" -p Unit 2>/dev/null)
case "$timer_unit_property" in Unit=*) ;; *) return ;; esac
local service_unit=${timer_unit_property#Unit=}
if [ -n "$service_unit" ]; then
# Read the service properties once per timer to keep the check cheap.
local service_properties=$(systemctl show "$service_unit" -p User -p DynamicUser -p ExecStart 2>/dev/null)
local service_user="" service_user_seen="" dynamic_user="" exec_start="" property
while IFS= read -r property; do
case "$property" in
User=*) service_user=${property#User=}; service_user_seen=1 ;;
DynamicUser=*) dynamic_user=${property#DynamicUser=} ;;
ExecStart=*) exec_start=${property#ExecStart=} ;;
esac
done <<EOF
$service_properties
EOF
# Check if the service runs with elevated privileges
if systemctl show "$service_unit" -p User 2>/dev/null | grep -q "root"; then
findings="${findings}RUNS_AS_ROOT: Service runs as root\n"
if [ -n "$service_user_seen" ]; then
case "$service_user" in
""|root|0)
if [ -n "$service_user" ] || [ "$dynamic_user" != "yes" ]; then
findings="${findings}RUNS_AS_ROOT: Service runs as root\n"
fi ;;
esac
fi
# Get the executable path
local exec_path=$(systemctl show "$service_unit" -p ExecStart 2>/dev/null | cut -d= -f2 | cut -d' ' -f1)
# systemctl show commonly formats ExecStart as { path=... ; argv[]=... }.
local exec_path
case "$exec_start" in
*path=*) exec_path=${exec_start#*path=}
exec_path=${exec_path%%[[:space:];]*}
exec_path=${exec_path%\}} ;;
*) exec_path=${exec_start%% *} ;;
esac
if [ -n "$exec_path" ]; then
if [ -w "$exec_path" ]; then
findings="${findings}WRITABLE_EXEC: Executable is writable: $exec_path\n"
@@ -44,7 +74,7 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
fi
# Check for unsafe configurations
if systemctl show "$service_unit" -p ExecStart 2>/dev/null | grep -qE '(chmod|chown|mount|sudo|su)'; then
if printf '%s\n' "$exec_start" | grep -qE '(chmod|chown|mount|sudo|su)'; then
findings="${findings}UNSAFE_CMD: Uses potentially dangerous commands\n"
fi
@@ -52,6 +82,98 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
if [ -e "$exec_path" ] && [ "$(stat -c %a "$exec_path" 2>/dev/null)" = "777" ]; then
findings="${findings}WEAK_PERMS: Executable has 777 permissions\n"
fi
# Follow only a literal root timer service -> systemctl restart -> root
# shell-script edge. Reuse the direct service properties above; query no
# more than four distinct restart targets, with a one-second deadline.
if [ -n "$timer_restart_timeout" ] && [ "$timer_restart_queries" -lt 4 ] &&
[ -n "$service_user_seen" ]; then
case "$service_user" in
''|root|0)
if [ -n "$service_user" ] || [ "$dynamic_user" != yes ]; then
case "$exec_path" in
/bin/systemctl|/usr/bin/systemctl)
restart_target=$(printf '%s\n' "$exec_start" | awk -v exe="$exec_path" '
{
i = index($0, "argv[]=")
if (!i) exit
args = substr($0, i + 7)
if (args !~ /[[:space:]];[[:space:]]/) exit
sub(/[[:space:]];[[:space:]].*/, "", args)
if (index(args, ";")) exit
sub(/^[[:space:]]+/, "", args)
sub(/[[:space:]]+$/, "", args)
n = split(args, word, /[[:space:]]+/)
if (n == 3 && word[1] == exe && word[2] == "restart" &&
word[3] ~ /^[A-Za-z0-9_.@-]+\.service$/) print word[3]
}
')
if [ -n "$restart_target" ]; then
case " $timer_restart_targets" in
*" $restart_target "*) ;;
*)
timer_restart_targets="$timer_restart_targets$restart_target "
timer_restart_queries=$((timer_restart_queries + 1))
if restart_properties=$("$timer_restart_timeout" 1 systemctl show "$restart_target" \
-p User -p DynamicUser -p RootDirectory -p RootImage -p ExecStart 2>/dev/null); then
restart_user='' restart_user_seen='' restart_dynamic_user=''
restart_root_dir='' restart_root_dir_seen=''
restart_root_image='' restart_root_image_seen='' restart_exec=''
while IFS= read -r property; do
case "$property" in
User=*) restart_user=${property#User=}; restart_user_seen=1 ;;
DynamicUser=*) restart_dynamic_user=${property#DynamicUser=} ;;
RootDirectory=*) restart_root_dir=${property#RootDirectory=}; restart_root_dir_seen=1 ;;
RootImage=*) restart_root_image=${property#RootImage=}; restart_root_image_seen=1 ;;
ExecStart=*) restart_exec=${property#ExecStart=} ;;
esac
done <<EOF
$restart_properties
EOF
if [ -n "$restart_user_seen" ] && [ -n "$restart_root_dir_seen" ] &&
[ -n "$restart_root_image_seen" ] && [ "$restart_dynamic_user" != yes ] &&
[ -z "$restart_root_image" ] &&
{ [ -z "$restart_root_dir" ] || [ "$restart_root_dir" = / ]; }; then
case "$restart_user" in
''|root|0)
restart_exec_path=${restart_exec#*path=}
restart_exec_path=${restart_exec_path%%[[:space:];]*}
case "$restart_exec_path" in
/bin/bash|/usr/bin/bash|/bin/sh|/usr/bin/sh)
restart_script=$(printf '%s\n' "$restart_exec" | awk -v exe="$restart_exec_path" '
{
i = index($0, "argv[]=")
if (!i) exit
args = substr($0, i + 7)
if (args !~ /[[:space:]];[[:space:]]/) exit
sub(/[[:space:]];[[:space:]].*/, "", args)
if (index(args, ";")) exit
sub(/^[[:space:]]+/, "", args)
sub(/[[:space:]]+$/, "", args)
n = split(args, word, /[[:space:]]+/)
if (n == 2 && word[1] == exe &&
word[2] ~ /^\/[A-Za-z0-9_.\/-]+$/) print word[2]
}
')
if [ -n "$restart_script" ] && [ -f "$restart_script" ] &&
[ ! -L "$restart_script" ] && [ -w "$restart_script" ]; then
findings="${findings}WRITABLE_TIMER_SCRIPT: $restart_target invokes $restart_script through $restart_exec_path (root review candidate)\n"
fi
;;
esac
;;
esac
fi
fi
;;
esac
fi
;;
esac
fi
;;
esac
fi
fi
# If any findings, print them
@@ -90,7 +212,7 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
fi
# Check for writable executables in Unit directive (following symlinks)
local unit_path=$(grep -Po '^Unit=*(.*?$)' "$timer_file" 2>/dev/null | cut -d '=' -f2)
local unit_path=$(sed -n 's/^Unit=//p' "$timer_file" 2>/dev/null | head -n 1)
if [ -n "$unit_path" ]; then
if [ -L "$unit_path" ]; then
local target_unit=$(readlink -f "$unit_path")
@@ -113,32 +235,47 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
# List all timers and check for privilege escalation vectors
print_3title "Active timers:" "T1053.003"
systemctl list-timers --all 2>/dev/null | grep -Ev "(^$|timers listed)" | while read -r line; do
# Extract timer unit name
timer_unit=$(echo "$line" | awk '{print $1}')
timer_count=0
timer_restart_queries=0
timer_restart_targets=''
timer_restart_timeout=''
if command -v timeout >/dev/null 2>&1; then
timer_restart_timeout=$(command -v timeout)
elif command -v gtimeout >/dev/null 2>&1; then
timer_restart_timeout=$(command -v gtimeout)
fi
systemctl list-timers --all --no-pager --full 2>/dev/null | grep -Ev "(^$|timers listed)" | while IFS= read -r line; do
# UNIT is the next-to-last column; NEXT can contain several date fields.
timer_unit=$(printf '%s\n' "$line" | awk '$(NF-1) ~ /\.timer$/ { print $(NF-1) }')
if [ -n "$timer_unit" ]; then
# Check if timer file is writable
timer_path=$(systemctl show "$timer_unit" -p FragmentPath 2>/dev/null | cut -d= -f2)
if [ -n "$timer_path" ]; then
check_timer_file "$timer_path"
timer_count=$(( ${timer_count:-0} + 1 ))
if [ "$timer_count" -le 200 ]; then
# Check if timer file is writable
timer_path=$(systemctl show "$timer_unit" -p FragmentPath 2>/dev/null | cut -d= -f2)
if [ -n "$timer_path" ]; then
check_timer_file "$timer_path"
fi
# Check timer content for privilege escalation vectors
check_timer_content "$timer_unit"
fi
# Check timer content for privilege escalation vectors
check_timer_content "$timer_unit"
# Print the timer line with highlighting
echo "$line" | sed -${E} "s,$timersG,${SED_GREEN},"
fi
# Keep the list output, including its heading, in the existing format.
echo "$line" | sed -${E} "s,$timersG,${SED_GREEN},"
done || echo_not_found
# Check for disabled but available timers
print_3title "Disabled timers:" "T1053.003"
systemctl list-unit-files --type=timer --state=disabled 2>/dev/null | grep -v "UNIT FILE" | while read -r line; do
timer_unit=$(echo "$line" | awk '{print $1}')
timer_count=0
systemctl list-unit-files --type=timer --state=disabled 2>/dev/null | grep -v "UNIT FILE" | while IFS= read -r line; do
timer_unit=$(printf '%s\n' "$line" | awk '$1 ~ /\.timer$/ { print $1 }')
if [ -n "$timer_unit" ]; then
timer_path=$(systemctl show "$timer_unit" -p FragmentPath 2>/dev/null | cut -d= -f2)
if [ -n "$timer_path" ]; then
check_timer_file "$timer_path"
timer_count=$(( ${timer_count:-0} + 1 ))
if [ "$timer_count" -le 200 ]; then
timer_path=$(systemctl show "$timer_unit" -p FragmentPath 2>/dev/null | cut -d= -f2)
if [ -n "$timer_path" ]; then
check_timer_file "$timer_path"
fi
fi
fi
done || echo_not_found
@@ -154,4 +291,4 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
fi
echo ""
fi
fi
@@ -9,7 +9,7 @@
# Functions Used: print_2title, print_3title
# Global Variables: $E, $SED_RED_YELLOW
# Initial Functions:
# Generated Global Variables: $iface, $state, $mac, $ip_file, $line
# Generated Global Variables: $iface, $state, $mac, $ip_file, $line, $wireless_modes
# Fat linpeas: 0
# Small linpeas: 1
@@ -74,6 +74,15 @@ else
parse_network_interfaces
fi
# A local nl80211 query shows AP/managed/monitor modes without scanning the air.
if [ -d /sys/class/ieee80211 ] && command -v iw >/dev/null 2>&1; then
wireless_modes="$(iw dev 2>/dev/null | head -n 100)"
if [ -n "$wireless_modes" ]; then
print_3title "Wireless interface modes (local inventory)" "T1016"
printf '%s\n' "$wireless_modes"
fi
fi
if command -v ip >/dev/null 2>&1; then
print_3title "Routing & policy quick view" "T1016"
ip route 2>/dev/null
@@ -36,6 +36,9 @@ get_hostname_info() {
get_hosts_info() {
print_3title "Hosts File Information" "T1016,T1018"
if [ -f "/etc/hosts" ]; then
if test -w /etc/hosts; then
echo "Writable /etc/hosts review candidate: current user can change local hostname resolution; verify a higher-privileged job resolves an affected hostname and executes or trusts the fetched result."
fi
echo "Contents of /etc/hosts:"
grep -v "^#" /etc/hosts 2>/dev/null | grep -v "^$" | while read -r line; do
echo " $line"
@@ -103,4 +106,4 @@ print_2title "Hostname, hosts and DNS" "T1016,T1018"
# Execute all information gathering functions
get_hostname_info
get_hosts_info
get_dns_info
get_dns_info
@@ -1,15 +1,15 @@
# Title: Network Information - Open ports
# ID: NT_Open_ports
# Author: Carlos Polop
# Last Update: 22-08-2023
# Last Update: 09-10-2026
# Description: Enumerate open ports
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1049
# Functions Used: print_2title, print_3title, print_info
# Global Variables: $E, $SED_RED, $SED_RED_YELLOW
# Initial Functions:
# Generated Global Variables: $pid_dir, $tx_queue, $pid, $rem_port, $proc_file, $rem_ip, $local_ip, $rx_queue, $proto, $rem_addr, $program, $state, $header_sep, $proc_info, $inode, $header, $line, $local_addr, $local_port
# Generated Global Variables: $pid_dir, $tx_queue, $pid, $rem_port, $proc_file, $rem_ip, $local_ip, $rx_queue, $proto, $rem_addr, $program, $state, $header_sep, $proc_info, $inode, $header, $line, $local_addr, $local_port, $column, $lp_freebsd_netstat, $lp_freebsd_listeners
# Fat linpeas: 0
# Small linpeas: 1
@@ -105,13 +105,49 @@ parse_proc_net_ports() {
echo ""
}
lp_loopback_listeners() {
awk -v column="$1" '
{
address = tolower($column)
if (address ~ /^127\.[0-9]+\.[0-9]+\.[0-9]+[.:][0-9]+$/ ||
address ~ /^\[?::1\]?[.:][0-9]+$/ ||
address ~ /^\[?::ffff:127\.[0-9]+\.[0-9]+\.[0-9]+\]?[.:][0-9]+$/)
print
}'
}
# FreeBSD netstat uses a dot before the port and does not accept Linux's
# -punta option set. Keep only TCP LISTEN records and flag partial output.
lp_freebsd_tcp_listeners() {
netstat -an -p tcp 2>/dev/null | awk '
tolower($1) ~ /^tcp(4|6)?$/ && toupper($NF) == "LISTEN" {
if (++seen <= 256) print
else if (seen == 257) {
print "__LP_FREEBSD_PARTIAL__"
exit
}
}'
}
# Function to get open ports information
get_open_ports() {
print_2title "Active Ports" "T1049"
print_info "https://book.hacktricks.wiki/en/linux-hardening/network-information/local-network-and-socket-triage.html#loopback-and-local-service-enumeration"
lp_freebsd_netstat=
if command -v netstat >/dev/null 2>&1 && [ "$(uname -s 2>/dev/null)" = FreeBSD ]; then
lp_freebsd_netstat=1
lp_freebsd_listeners=$(lp_freebsd_tcp_listeners)
fi
# Try standard tools first
if command -v netstat >/dev/null 2>&1; then
if [ "$lp_freebsd_netstat" ]; then
print_3title "Active Ports (FreeBSD netstat, TCP)" "T1049"
printf '%s\n' "$lp_freebsd_listeners" | grep -v '^__LP_FREEBSD_PARTIAL__$' | sed -${E} "s,127\.[0-9]+\.[0-9]+\.[0-9]+|::1|0\.0\.0\.0,${SED_RED},g"
case "$lp_freebsd_listeners" in
*"__LP_FREEBSD_PARTIAL__"*) echo "FreeBSD TCP listener inventory incomplete (256-entry cap)." ;;
esac
elif command -v netstat >/dev/null 2>&1; then
print_3title "Active Ports (netstat)" "T1049"
netstat -punta 2>/dev/null | grep -i listen | sed -${E} "s,127.0.[0-9]+.[0-9]+|:::|::1:|0\.0\.0\.0,${SED_RED},g"
elif command -v ss >/dev/null 2>&1; then
@@ -125,14 +161,22 @@ get_open_ports() {
# Focused local service exposure view
print_3title "Local-only listeners (loopback)" "T1049"
if command -v ss >/dev/null 2>&1; then
ss -nltpu 2>/dev/null | grep -E "127\.0\.0\.1:|::1:" | sed -${E} "s,127\.0\.0\.1:|::1:,${SED_RED},g"
if [ "$lp_freebsd_netstat" ]; then
printf '%s\n' "$lp_freebsd_listeners" | lp_loopback_listeners 4 | sed -${E} "s,127\.[0-9]+\.[0-9]+\.[0-9]+|::1,${SED_RED},g"
elif command -v ss >/dev/null 2>&1; then
ss -nltpu 2>/dev/null | lp_loopback_listeners 5 | sed -${E} "s,127\.[0-9]+\.[0-9]+\.[0-9]+|::1,${SED_RED},g"
elif command -v netstat >/dev/null 2>&1; then
netstat -punta 2>/dev/null | grep -i listen | grep -E "127\.0\.0\.1:|::1:" | sed -${E} "s,127\.0\.0\.1:|::1:,${SED_RED},g"
netstat -punta 2>/dev/null | grep -i listen | lp_loopback_listeners 4 | sed -${E} "s,127\.[0-9]+\.[0-9]+\.[0-9]+|::1,${SED_RED},g"
fi
print_3title "Unique listener bind addresses" "T1049"
if command -v ss >/dev/null 2>&1; then
if [ "$lp_freebsd_netstat" ]; then
printf '%s\n' "$lp_freebsd_listeners" | awk 'toupper($NF) == "LISTEN" {
a=$4
sub(/[.][0-9]+$/, "", a)
if (a != "") print a
}' | sort -u | sed -${E} "s,127\.0\.0\.1|::1,${SED_RED},g"
elif command -v ss >/dev/null 2>&1; then
ss -nltpuH 2>/dev/null | awk '{
a=$5
if (a ~ /^\[/) {
@@ -6,10 +6,10 @@
# License: GNU GPL
# Version: 1.0
# Mitre: T1049
# Functions Used: print_2title, print_3title, warn_exec, echo_not_found
# Functions Used: print_2title, print_3title, warn_exec, echo_not_found, lp_trusted_version_path
# Global Variables: $EXTRA_CHECKS, $E, $SED_RED, $SED_GREEN, $SED_YELLOW
# Initial Functions:
# Generated Global Variables: $inetd_service, $log_file, $cmd, $service_name, $conf_file, $service_dir, $service_file, $inetd_file
# Generated Global Variables: $inetd_service, $log_file, $cmd, $service_name, $conf_file, $service_dir, $service_file, $file, $inetd_command, $inetd_telnet_probe_count, $service, $socket, $protocol, $user, $server, $checked_binary, $trusted_server, $version_output, $version, $major, $minor, $patch, $rest
# Fat linpeas: 0
# Small linpeas: 0
@@ -24,23 +24,95 @@ check_command() {
return 1
}
inetd_telnet_upstream_affected() {
local major=$1 minor=$2 patch=$3
if [ "$major" -eq 1 ]; then
[ "$minor" -gt 9 ] || { [ "$minor" -eq 9 ] && [ "$patch" -ge 3 ]; }
elif [ "$major" -eq 2 ]; then
[ "$minor" -lt 7 ] || { [ "$minor" -eq 7 ] && [ "$patch" -eq 0 ]; }
else
return 1
fi
}
# Inspect only configured root telnet stanzas. A version is an upstream hint,
# not proof that a downstream package is unpatched or that a listener is active.
inetd_telnet_cve_hint() {
local conf_file=$1 service socket protocol wait_mode user server arguments
local checked_binary='' version_output='' version='' major minor patch rest trusted_server
[ -f "$conf_file" ] || return 0
while read -r service socket protocol wait_mode user server arguments || [ -n "$service" ]; do
[ "$service" = telnet ] && [ "$socket" = stream ] || continue
case "$protocol" in tcp*) ;; *) continue ;; esac
case "$user" in root|root:*|root.*|0|0:*|0.*) ;; *) continue ;; esac
[ -n "$server" ] || continue
if [ "$server" != "$checked_binary" ]; then
checked_binary=$server
version_output=''
# A non-GNU daemon may not support --version or honor SIGTERM.
# Bound both the process lifetime and captured output.
case "$server" in
/*) if [ -x "$server" ] && [ "${inetd_telnet_probe_count:-0}" -lt 4 ] && command -v timeout >/dev/null 2>&1; then
inetd_telnet_probe_count=$((inetd_telnet_probe_count + 1))
if trusted_server=$(lp_trusted_version_path "$server"); then
version_output=$(timeout -k 1 1 "$trusted_server" --version 2>&1 | head -c 4096)
fi
fi ;;
esac
fi
if printf '%s\n' "$version_output" | grep -qi 'GNU inetutils'; then
version=$(printf '%s\n' "$version_output" | sed -n 's/.*[Ii]netutils[^0-9]*\([0-9][0-9.]*\).*/\1/p' | head -n 1)
case "$version" in *.*) ;; *) version='' ;; esac
case "$version" in *[!0-9.]*) version='' ;; esac
if [ -n "$version" ]; then
major=${version%%.*}
rest=${version#*.}
minor=${rest%%.*}
patch=0
[ "$rest" = "$minor" ] || patch=${rest#*.}
case "$major:$minor:$patch" in *[!0-9:]*|*::*|:*|*:) version='' ;; esac
fi
if [ -n "$version" ] && inetd_telnet_upstream_affected "$major" "$minor" "$patch"; then
echo "CVE-2026-24061: GNU Inetutils telnetd $version in upstream affected range (1.9.3-2.7), root inetd stanza in $conf_file; confirm package patches and active listener."
elif [ -n "$version" ]; then
echo "CVE-2026-24061: GNU Inetutils telnetd $version outside upstream affected range, root inetd stanza in $conf_file."
else
echo "CVE-2026-24061: GNU Inetutils telnetd version unknown, root inetd stanza in $conf_file; inspect package and listener."
fi
elif printf '%s\n' "$version_output" | grep -qiE 'BusyBox|NetKit|OpenBSD|FreeBSD|BSD telnetd'; then
echo "CVE-2026-24061: configured telnetd appears non-GNU, root inetd stanza in $conf_file."
else
echo "CVE-2026-24061: telnetd implementation/version unknown, root inetd stanza in $conf_file; inspect package and listener."
fi
done < "$conf_file"
}
# Function to analyze inetd services
analyze_inetd() {
local inetd_command
inetd_telnet_probe_count=0
echo ""
print_3title "Inetd Services" "T1049"
# Check if inetd is installed
if ! check_command inetd; then
if check_command inetd; then
inetd_command=inetd
elif check_command inetutils-inetd; then
inetd_command=inetutils-inetd
else
echo_not_found "inetd"
return
fi
# Check if inetd is running
if ! pgrep -x inetd >/dev/null 2>&1; then
if ! pgrep -x inetd >/dev/null 2>&1 && ! pgrep -x inetutils-inetd >/dev/null 2>&1; then
echo "inetd is not running" | sed -${E} "s,.*,${SED_YELLOW},g"
fi
# Get inetd version
warn_exec inetd -v 2>/dev/null
warn_exec "$inetd_command" -v 2>/dev/null
# Check main configuration file
if [ -f "/etc/inetd.conf" ]; then
@@ -62,6 +134,11 @@ analyze_inetd() {
warn_exec cat "$conf_file" | grep -v "^$" | grep -Ev "\W+\#|^#" 2>/dev/null
fi
done
# Keep the existing configuration output and add a local, passive CVE hint.
for conf_file in /etc/inetd.conf /etc/inetd.d/* /etc/inet/*.conf; do
inetd_telnet_cve_hint "$conf_file"
done
}
# Function to analyze xinetd services
@@ -174,9 +251,9 @@ analyze_inetd_services() {
# Check for inetd/xinetd related files
echo -e "\nChecking for related files..."
for file in /etc/init.d/inetd /etc/init.d/xinetd /etc/default/inetd /etc/default/xinetd; do
if [ -f "$inetd_file" ]; then
echo "Found file: $inetd_file" | sed -${E} "s,.*,${SED_GREEN},g"
warn_exec cat "$inetd_file" | grep -v "^$" | grep -Ev "\W+\#|^#" 2>/dev/null
if [ -f "$file" ]; then
echo "Found file: $file" | sed -${E} "s,.*,${SED_GREEN},g"
warn_exec cat "$file" | grep -v "^$" | grep -Ev "\W+\#|^#" 2>/dev/null
fi
done
fi
@@ -185,4 +262,4 @@ analyze_inetd_services() {
}
# Run the main function
analyze_inetd_services
analyze_inetd_services
@@ -1,15 +1,15 @@
# Title: Users Information - Pkexec
# ID: UG_Pkexec
# Author: Carlos Polop
# Last Update: 22-08-2023
# Last Update: 09-10-2026
# Description: Check Pkexec policy and related files for privilege escalation
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1548.003,T1548.004,T1068
# Functions Used: print_2title, print_info
# Global Variables: $Groups, $groupsB, $groupsVB, $nosh_usrs, $sh_usrs, $USER
# Initial Functions:
# Generated Global Variables: $pkexec_bin, $pkexec_version, $policy_dir, $policy_file
# Generated Global Variables: $pkexec_bin, $pkexec_owner, $pkexec_version_output, $pkexec_version, $pkexec_major, $pkexec_minor, $policy_dir, $policy_file
# Fat linpeas: 0
# Small linpeas: 1
@@ -27,14 +27,24 @@ if [ -n "$pkexec_bin" ]; then
echo "Pkexec binary has SUID bit set!" | sed -${E} "s,.*,${SED_RED},g"
fi
ls -l "$pkexec_bin" 2>/dev/null
# Check polkit version for known vulnerabilities
if command -v pkexec >/dev/null 2>&1; then
pkexec --version 2>/dev/null
pkexec_version="$(pkexec --version 2>/dev/null | grep -oE '[0-9]+(\.[0-9]+)+')"
if [ "$pkexec_version" ] && [ "$(printf '%s\n' "$pkexec_version" "0.120" | sort -V | head -n1)" = "$pkexec_version" ] && [ "$pkexec_version" != "0.120" ]; then
echo "Potentially vulnerable to CVE-2021-4034 (PwnKit) - check distro patches" | sed -${E} "s,.*,${SED_RED_YELLOW},"
fi
# The local escalation requires an executable root-owned SUID pkexec.
pkexec_owner=$(LC_ALL=C ls -ldnL "$pkexec_bin" 2>/dev/null | awk 'NR == 1 { print $3 }')
if [ -f "$pkexec_bin" ] && [ -x "$pkexec_bin" ] && [ -u "$pkexec_bin" ] && [ "$pkexec_owner" = 0 ]; then
pkexec_version_output=$(pkexec --version 2>/dev/null)
if [ -n "$pkexec_version_output" ]; then printf '%s\n' "$pkexec_version_output"; fi
pkexec_version=$(printf '%s\n' "$pkexec_version_output" | awk 'match($0, /[0-9]+([.][0-9]+)+/) { print substr($0, RSTART, RLENGTH); exit }')
pkexec_major=${pkexec_version%%.*}
pkexec_minor=${pkexec_version#*.}
pkexec_minor=${pkexec_minor%%.*}
case "$pkexec_major:$pkexec_minor" in
*[!0-9:]*|:|*:) ;;
*) if [ "$pkexec_major" -eq 0 ] && [ "$pkexec_minor" -lt 120 ]; then
echo "CVE-2021-4034 candidate: root-owned SUID pkexec with older upstream version; verify distro package fixes and mitigations" | sed -${E} "s,.*,${SED_RED_YELLOW},"
fi ;;
esac
else
echo "Pkexec is not an executable root-owned SUID binary; local SUID exploit candidate suppressed"
fi
fi
@@ -1,32 +1,62 @@
# Title: Users Information - Superusers
# ID: UG_Superusers
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Check for superusers and users with UID 0
# Last Update: 09-10-2026
# Description: Check UID 0 accounts and bounded shared local UIDs
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1087.001
# Functions Used: print_2title, print_info
# Global Variables: $knw_usrs, $nosh_usrs, $sh_usrs, $USER
# Initial Functions:
# Generated Global Variables: $group
# Generated Global Variables: $group, $group_entry, $passwd_identity_rows, $shared_uid_rows
# Fat linpeas: 0
# Small linpeas: 1
# Collect the existing UID 0 inventory and a bounded local duplicate-UID cue
# in one read of /etc/passwd. A shared UID is a review lead, not root access.
summarize_local_passwd() {
awk -F: '
$3 ~ /^[0-9]+$/ && $3 + 0 == 0 { print "R\t" $0 }
NR == 4097 { print "P" }
NR <= 4096 && $1 != "" && $3 ~ /^[0-9]+$/ && $3 + 0 > 0 {
uid = $3 + 0
if (uid in first) {
if (++duplicates <= 32) print "D\t" uid "\t" first[uid] "\t" $1
else if (duplicates == 33) print "L"
} else first[uid] = $1
}
' "$1" 2>/dev/null
}
print_2title "Superusers and UID 0 Users" "T1087.001"
print_info "https://book.hacktricks.wiki/en/linux-hardening/user-information/interesting-groups-linux-pe/index.html"
passwd_identity_rows=$(summarize_local_passwd /etc/passwd)
# Check /etc/passwd for UID 0 users
echo ""
print_3title "Users with UID 0 in /etc/passwd" "T1087.001"
awk -F: '($3 == "0") {print}' /etc/passwd 2>/dev/null | sed -${E} "s,$sh_usrs,${SED_LIGHT_CYAN},g" | sed -${E} "s,$nosh_usrs,${SED_BLUE},g" | sed -${E} "s,$knw_usrs,${SED_GREEN},g" | sed "s,$USER,${SED_RED_YELLOW},g" | sed "s,root,${SED_RED},g"
printf '%s\n' "$passwd_identity_rows" | awk -F '\t' '$1 == "R" { sub(/^R\t/, ""); print }' | sed -${E} "s,$sh_usrs,${SED_LIGHT_CYAN},g" | sed -${E} "s,$nosh_usrs,${SED_BLUE},g" | sed -${E} "s,$knw_usrs,${SED_GREEN},g" | sed "s,$USER,${SED_RED_YELLOW},g" | sed "s,root,${SED_RED},g"
if [ command -v getent >/dev/null 2>&1 ]; then
shared_uid_rows=$(printf '%s\n' "$passwd_identity_rows" | awk -F '\t' '
$1 == "D" { printf "UID %s: %s, %s\n", $2, $3, $4 }
$1 == "L" { print "Partial: first 32 shared-UID pairs shown." }
')
if [ -n "$shared_uid_rows" ]; then
echo ""
print_3title "Accounts sharing nonzero local UIDs (review candidate)" "T1087.001"
printf '%s\n' "$shared_uid_rows"
echo "Shared UIDs can be intentional; verify account ownership, authentication, and local policy."
fi
if printf '%s\n' "$passwd_identity_rows" | grep -q '^P$'; then
echo "Shared-UID comparison partial: only the first 4096 local accounts were compared."
fi
if command -v getent >/dev/null 2>&1; then
for group in sudo wheel adm docker lxd lxc root shadow disk video; do
if getent group "$group" >/dev/null 2>&1; then
if group_entry=$(getent group "$group" 2>/dev/null) && [ -n "$group_entry" ]; then
echo "- Users in group '$group':"
getent group "$group" 2>/dev/null | sed -${E} "s,$sh_usrs,${SED_LIGHT_CYAN},g" | sed -${E} "s,$nosh_usrs,${SED_BLUE},g" | sed -${E} "s,$knw_usrs,${SED_GREEN},g" | sed "s,$USER,${SED_RED},g" | sed "s,root,${SED_RED},g"
printf '%s\n' "$group_entry" | sed -${E} "s,$sh_usrs,${SED_LIGHT_CYAN},g" | sed -${E} "s,$nosh_usrs,${SED_BLUE},g" | sed -${E} "s,$knw_usrs,${SED_GREEN},g" | sed "s,$USER,${SED_RED},g" | sed "s,root,${SED_RED},g"
fi
done
fi
@@ -7,9 +7,9 @@
# Version: 1.0
# Mitre: T1069.001
# Functions Used: print_2title
# Global Variables: $groupsVB, $groupsB, $Groups
# Global Variables: $groupsVB, $groupsB
# Initial Functions:
# Generated Global Variables: $ActualGroup, $groupname, $gid, $result
# Generated Global Variables: $ActualGroup, $groupname, $gid, $result, $actual_group_timeout, $actual_group_deadline, $actual_group_gids
# Fat linpeas: 0
# Small linpeas: 1
@@ -20,15 +20,35 @@ print_2title "Actual Group Memberships via newgrp" "T1069.001"
if [ "${IAMROOT:-0}" != "1" ]; then
ActualGroup="|"
while IFS=: read -r groupname _ gid _; do
result=$(timeout 1 sh -c "echo id | newgrp \"$groupname\"" 2>/dev/null)
if echo "$result" | grep -q "uid="; then
if ! echo "${Groups}|" | grep -Fq "|${groupname}|"; then
# A password prompt can read /dev/tty rather than stdin. Never run newgrp
# without a watchdog; newer OpenBSD and GNU provide compatible timeout -k.
actual_group_timeout=""
if command -v timeout >/dev/null 2>&1; then
actual_group_timeout=timeout
elif command -v gtimeout >/dev/null 2>&1; then
actual_group_timeout=gtimeout
fi
if [ -n "$actual_group_timeout" ] && command -v newgrp >/dev/null 2>&1; then
actual_group_gids=" $(id -G 2>/dev/null) "
actual_group_deadline=$(($(date +%s) + 15))
while IFS=: read -r groupname _ gid _; do
[ "$(date +%s)" -lt "$actual_group_deadline" ] || break
case "$gid" in ''|*[!0-9]*) continue ;; esac
case "$actual_group_gids" in *" $gid "*) continue ;; esac
# Check the effective GID after newgrp: it can start a shell even
# when the group switch failed. Pass the name as data, not shell code.
result=$(printf 'id -g\n' | "$actual_group_timeout" -k 1 1 newgrp "$groupname" 2>/dev/null)
if [ "$result" = "$gid" ]; then
ActualGroup="${ActualGroup}${groupname}|"
echo "Accessible group not shown in id: $groupname (gid=$gid)" | sed -${E} "s,$groupsVB,${SED_RED_YELLOW},g" | sed -${E} "s,$groupsB,${SED_RED},g"
# groupsVB/groupsB are populated by the builder's variables module.
# shellcheck disable=SC2154
echo "Accessible group not shown in id: $groupname (gid=$gid)" | sed -"${E}" "s,$groupsVB,${SED_RED_YELLOW},g" | sed -"${E}" "s,$groupsB,${SED_RED},g"
fi
fi
done < /etc/group
done < /etc/group
fi
echo ""
fi
@@ -9,7 +9,7 @@
# Functions Used: echo_not_found, print_2title, print_info
# Global Variables: $HOME
# Initial Functions:
# Generated Global Variables: $pgp_file
# Generated Global Variables: $pgp_file, $pgp_walk, $pgp_key_count, $pgp_home_count, $pgp_home, $pgp_gnupg, $pgp_keydir, $pgp_nearby, $pgp_ciphertext, $pgp_key
# Fat linpeas: 0
# Small linpeas: 1
@@ -17,6 +17,62 @@
print_2title "PGP Keys and Related Files" "T1552.004"
print_info "https://book.hacktricks.wiki/en/linux-hardening/user-information/user-and-session-triage.html#review-user-artifacts"
# Review direct GnuPG private-key paths in login homes. Never open key material.
# Input is a newline-separated list of home paths; the caller caps that list.
pgp_path_has_no_symlinks() {
pgp_walk=$1
while [ "$pgp_walk" != / ]; do
[ ! -L "$pgp_walk" ] || return 1
pgp_walk=${pgp_walk%/*}
[ -n "$pgp_walk" ] || pgp_walk=/
done
}
pgp_private_key_candidates() {
pgp_key_count=0
pgp_home_count=0
while IFS= read -r pgp_home; do
[ "$pgp_key_count" -lt 24 ] && [ "$pgp_home_count" -lt 64 ] || break
pgp_home_count=$((pgp_home_count + 1))
case "$pgp_home" in
/*) ;;
*) continue ;;
esac
case "$pgp_home/" in
*'/../'*|*'/./'*|*'//'*) continue ;;
esac
[ "$pgp_home" != "$HOME" ] || continue
pgp_gnupg="$pgp_home/.gnupg"
pgp_keydir="$pgp_gnupg/private-keys-v1.d"
# Reject symlinks at each path component we intentionally enter.
[ -d "$pgp_home" ] && [ -x "$pgp_home" ] && pgp_path_has_no_symlinks "$pgp_home" || continue
[ -d "$pgp_gnupg" ] && [ -x "$pgp_gnupg" ] && [ ! -L "$pgp_gnupg" ] || continue
[ -d "$pgp_keydir" ] && [ -x "$pgp_keydir" ] && [ ! -L "$pgp_keydir" ] || continue
pgp_nearby="no"
for pgp_ciphertext in "$pgp_home"/*.gpg "$pgp_home"/backup/*.gpg; do
case "$pgp_ciphertext" in
"$pgp_home"/backup/*)
[ -d "$pgp_home/backup" ] && [ -x "$pgp_home/backup" ] && [ ! -L "$pgp_home/backup" ] || continue ;;
esac
if [ -f "$pgp_ciphertext" ] && [ -r "$pgp_ciphertext" ] && [ ! -L "$pgp_ciphertext" ]; then
pgp_nearby="yes: $pgp_ciphertext"
break
fi
done
# Only direct children, with a global output cap; do not traverse homes.
for pgp_key in "$pgp_keydir"/*.key; do
[ "$pgp_key_count" -lt 24 ] || break
[ -f "$pgp_key" ] && [ -r "$pgp_key" ] && [ ! -L "$pgp_key" ] || continue
printf 'Readable GnuPG private key (review candidate): %s\n' "$pgp_key"
ls -ld "$pgp_key" 2>/dev/null
printf ' Readable nearby .gpg ciphertext: %s\n' "$pgp_nearby"
pgp_key_count=$((pgp_key_count + 1))
done
done
}
# Check for GPG
echo "GPG:" | sed -${E} "s,.*,${SED_LIGHT_CYAN},g"
if command -v gpg >/dev/null 2>&1; then
@@ -47,4 +103,9 @@ for pgp_file in "$HOME/.gnupg" "$HOME/.pgp" "$HOME/.openpgp" "$HOME/.ssh/gpg-age
fi
fi
done
echo ""
echo ""
# Only login homes from the account database; at most 64 distinct homes.
echo "Cross-user GnuPG private-key paths (metadata only, limit 24):" | sed -${E} "s,.*,${SED_LIGHT_CYAN},g"
awk -F: '$6 ~ /^\// && $7 !~ /(nologin|false)$/ && $6 != "/" && $6 != "/nonexistent" && !seen[$6]++ { print $6; if (++n == 64) exit }' /etc/passwd 2>/dev/null | pgp_private_key_candidates
echo ""
File diff suppressed because it is too large. Load diff
@@ -19,6 +19,7 @@ print_info "https://book.hacktricks.wiki/en/linux-hardening/linux-basics/linux-p
ptrace_scope="$(cat /proc/sys/kernel/yama/ptrace_scope 2>/dev/null)"
if [ "$ptrace_scope" ] && [ "$ptrace_scope" -eq 0 ]; then
echo "ptrace protection is disabled (0), so sudo tokens could be abused" | sed "s,is disabled,${SED_RED},g";
echo "Same-UID process input or memory may also expose vault passwords or reused credentials if the target is dumpable and other access controls permit tracing"
if [ "$(command -v gdb 2>/dev/null || echo -n '')" ]; then
echo "gdb was found in PATH" | sed -${E} "s,.*,${SED_RED},g";
@@ -1,16 +1,16 @@
# Title: Users Information - Doas
# ID: UG_Doas
# Author: Carlos Polop
# Last Update: 11-08-2026
# Last Update: 09-10-2026
# Description: Check doas/OpenDoas configuration, effective rules, binary permissions, and known vulnerable versions.
# Detects unrestricted and nopass root rules, GTFOBins-capable commands (including the HTB Soccer dstat path), dangerous environment preservation, writable configuration paths, and applicable CVEs.
# Detects unrestricted and nopass root rules, GTFOBins-capable commands, writable dstat plugin directories, dangerous environment preservation, writable configuration paths, and applicable CVEs.
# License: GNU GPL
# Version: 1.1
# Version: 1.2
# Mitre: T1548.003
# Functions Used: doas_check_command, doas_command_is_dangerous, doas_config_syntax_valid, doas_extract_upstream_version, doas_get_package_details, doas_read_rules, doas_rule_applies_to_current_user, doas_rule_command, doas_rule_has_dangerous_environment, doas_rule_has_option, doas_rule_targets_root, doas_version_ge, doas_version_le, doas_version_lt, echo_not_found, print_2title, print_3title, print_info
# Global Variables: $doas_package_full_version, $doas_package_homepage, $doas_package_implementation, $doas_package_manager, $doas_package_name
# Initial Functions:
# Generated Global Variables: $conf_file, $doas_active_rules, $doas_bin, $doas_bin_mode, $doas_bin_owner, $doas_bin_trusted, $doas_conf_candidates, $doas_conf_dir, $doas_conf_dir_mode, $doas_conf_found, $doas_conf_mode, $doas_conf_owner, $doas_conf_trusted, $doas_current_gids, $doas_current_groups, $doas_current_uid, $doas_current_user, $doas_package_label, $doas_rule_applies, $doas_rule_cmd_value, $doas_rule_dangerous, $doas_rule_env, $doas_rule_line, $doas_rule_nopass, $doas_rule_number, $doas_rule_root, $doas_rule_unrestricted, $doas_seen_configs, $doas_seen_test_commands, $doas_strings_conf, $doas_test_cmd, $doas_test_commands, $doas_check_output, $doas_tiocsti, $doas_upstream_version
# Generated Global Variables: $conf_file, $doas_active_rules, $doas_bin, $doas_bin_mode, $doas_bin_owner, $doas_bin_trusted, $doas_conf_candidates, $doas_conf_dir, $doas_conf_dir_mode, $doas_conf_found, $doas_conf_mode, $doas_conf_owner, $doas_conf_trusted, $doas_current_gids, $doas_current_groups, $doas_current_uid, $doas_current_user, $doas_package_label, $doas_rule_applies, $doas_rule_cmd_value, $doas_rule_dangerous, $doas_rule_env, $doas_rule_line, $doas_rule_nopass, $doas_rule_number, $doas_rule_root, $doas_rule_unrestricted, $doas_seen_configs, $doas_seen_test_commands, $doas_strings_conf, $doas_test_cmd, $doas_test_commands, $doas_check_output, $doas_tiocsti, $doas_upstream_version, $doas_dstat_plugin_dir, $doas_dstat_checked
# Fat linpeas: 0
# Small linpeas: 1
@@ -21,6 +21,21 @@ doas_current_uid="$(id -u 2>/dev/null)"
doas_current_groups="$(id -Gn 2>/dev/null)"
doas_current_gids="$(id -G 2>/dev/null)"
doas_bin_trusted="no"
doas_dstat_checked=""
# Dstat implementations differ: this checks only two documented global
# plugin locations, and only current-user write/search rights. It does not
# inspect plugin content or invoke the privileged program.
doas_dstat_plugin_review() {
[ "$doas_current_uid" != "0" ] || return 0
for doas_dstat_plugin_dir in "$@"; do
[ -d "$doas_dstat_plugin_dir" ] &&
[ -w "$doas_dstat_plugin_dir" ] &&
[ -x "$doas_dstat_plugin_dir" ] || continue
printf 'Writable dstat plugin directory candidate: %s (review installed plugin search path, doas policy, and whether a selected plugin is loaded)\n' "$doas_dstat_plugin_dir"
ls -ld "$doas_dstat_plugin_dir" 2>/dev/null || :
done
}
doas_conf_candidates="/etc/doas.conf
/usr/local/etc/doas.conf
@@ -180,9 +195,6 @@ if [ -n "$doas_bin" ] || [ "$doas_conf_found" = "yes" ]; then
echo "POTENTIAL: a matching permit rule allows arbitrary root commands after authentication; a later rule may override it" | sed -${E} "s,.*,${SED_RED_YELLOW},g"
elif [ "$doas_rule_dangerous" = "yes" ] && [ "$doas_rule_nopass" = "yes" ]; then
echo "POTENTIAL: a matching permit rule allows GTFOBins-capable command $doas_rule_cmd_value as root without a password; a later rule may override it" | sed -${E} "s,.*,${SED_RED},g"
if [ "${doas_rule_cmd_value##*/}" = "dstat" ]; then
echo "This is the HTB Soccer privilege-escalation pattern: a user-controlled dstat plugin can execute as root" | sed -${E} "s,.*,${SED_RED},g"
fi
elif [ "$doas_rule_dangerous" = "yes" ]; then
echo "POTENTIAL: a matching permit rule allows GTFOBins-capable command $doas_rule_cmd_value as root after authentication; a later rule may override it" | sed -${E} "s,.*,${SED_RED_YELLOW},g"
elif [ "$doas_rule_nopass" = "yes" ]; then
@@ -191,6 +203,11 @@ if [ -n "$doas_bin" ] || [ "$doas_conf_found" = "yes" ]; then
if [ "$doas_rule_env" = "yes" ]; then
echo "Dangerous environment preservation is enabled for an applicable root rule (keepenv or sensitive setenv variable)" | sed -${E} "s,.*,${SED_RED_YELLOW},g"
fi
if [ "${doas_rule_cmd_value##*/}" = "dstat" ] && [ -z "$doas_dstat_checked" ]; then
doas_dstat_checked=1
doas_dstat_plugin_review /usr/local/share/dstat /usr/share/dstat |
sed -"${E}" "s,.*,${SED_RED_YELLOW},"
fi
elif [ "$doas_rule_root" = "yes" ] && { [ "$doas_rule_nopass" = "yes" ] || [ "$doas_rule_unrestricted" = "yes" ] || [ "$doas_rule_dangerous" = "yes" ]; }; then
echo " $conf_file:$doas_rule_number $doas_rule_line" | sed -${E} "s,.*,${SED_LIGHT_CYAN},g"
else
@@ -7,11 +7,154 @@
# Version: 1.0
# Mitre: T1552.001
# Functions Used: print_3title, warn_exec
# Global Variables: $NGINX_KNOWN_MODULES
# Global Variables: $NGINX_KNOWN_MODULES, $TIMEOUT, $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables:
# Generated Global Variables: $lp_apache_checked, $lp_apache_deadline, $lp_apache_dir, $lp_apache_file, $lp_apache_found, $lp_apache_lines, $lp_apache_name, $lp_remco_checked, $lp_remco_config, $lp_remco_deadline, $lp_remco_raw, $lp_remco_root, $lp_remco_size, $lp_remco_source, $lp_remco_sources, $lp_remco_template_size, $lp_remco_templates
# Fat linpeas: 0
# Small linpeas: 1
# The ordinary Apache file inventory below already displays virtual-host
# configuration. This extra view names piped-log directives without echoing
# their commands (which may contain credentials or attacker-controlled text).
lp_apache_piped_log_check() (
if [ "$#" -eq 0 ]; then
if [ "$SEARCH_IN_FOLDER" ]; then
set -- "$SEARCH_IN_FOLDER" "$SEARCH_IN_FOLDER/sites-enabled"
else
set -- /etc/apache2/sites-enabled /etc/apache2/sites-available /etc/httpd/conf.d /usr/local/etc/apache24/Includes
fi
fi
if [ -z "$TIMEOUT" ] || [ ! -x "$TIMEOUT" ]; then
for lp_apache_dir do
if [ -d "$lp_apache_dir" ]; then
printf '%s\n' 'Apache piped-log inspection unavailable (timeout missing); configuration output withholds piped-log commands.'
break
fi
done
return 0
fi
lp_apache_deadline=$(($(date +%s 2>/dev/null || printf 0) + 4))
lp_apache_checked=0
lp_apache_found=0
for lp_apache_dir do
[ -d "$lp_apache_dir" ] && [ -r "$lp_apache_dir" ] || continue
for lp_apache_file in "$lp_apache_dir"/*; do
[ "$lp_apache_checked" -lt 64 ] || break 2
[ "$(date +%s 2>/dev/null || printf 0)" -lt "$lp_apache_deadline" ] || break 2
[ -f "$lp_apache_file" ] && [ -r "$lp_apache_file" ] || continue
lp_apache_checked=$((lp_apache_checked + 1))
lp_apache_lines=$("$TIMEOUT" 1 dd if="$lp_apache_file" bs=4096 count=16 2>/dev/null | awk '
{
line = $0
sub(/^[[:space:]]*/, "", line)
if (line ~ /^#/ || line !~ /^[[:alpha:]]+[[:space:]]+/) next
directive = line
sub(/[[:space:]].*$/, "", directive)
if (tolower(directive) != "customlog" && tolower(directive) != "errorlog" &&
tolower(directive) != "transferlog") next
sub(/^[^[:space:]]+[[:space:]]+/, "", line)
if (substr(line, 1, 1) == "\"" || substr(line, 1, 1) == "\047") line = substr(line, 2)
if (substr(line, 1, 2) == "|$") mode = "shell"
else if (substr(line, 1, 1) == "|") mode = "direct"
else next
if (shown++ < 2) print directive ": " mode " pipeline (command redacted)"
}
' 2>/dev/null)
[ -n "$lp_apache_lines" ] || continue
if [ "$lp_apache_found" -eq 0 ]; then
printf '%s\n' 'Apache piped-log directives (bounded, read-only):'
lp_apache_found=1
fi
# Limit the displayed basename; never print the directive's arguments.
lp_apache_name=$(basename "$lp_apache_file" | tr -cd '[:print:]' | cut -c 1-80)
printf ' %s: %s\n' "$lp_apache_name" "$lp_apache_lines"
done
done
[ "$lp_apache_found" -eq 0 ] || printf '%s\n' 'A piped logger is a review cue; its command and privilege depend on the Apache parent.'
if [ "$lp_apache_checked" -ge 64 ] || [ "$(date +%s 2>/dev/null || printf 0)" -ge "$lp_apache_deadline" ]; then
printf '%s\n' 'Apache piped-log inspection was partial (file or time cap reached).'
fi
)
peass{Apache-Nginx}
# remco can render key/value data into privileged service configuration. This
# checks only fixed local configuration and templates; it never contacts the
# backend or invokes the target service.
lp_remco_apache_template_check() (
[ "$SEARCH_IN_FOLDER" ] && return 0
lp_remco_config=${1:-/etc/remco/config}
lp_remco_templates=${2:-/etc/remco/templates}
[ -e "$lp_remco_config" ] || return 0
if [ ! -f "$lp_remco_config" ] || [ ! -r "$lp_remco_config" ] || [ -L "$lp_remco_config" ]; then
printf '%s\n' 'remco configuration exists; static linkage unknown (unreadable or non-regular file).'
return 0
fi
if [ -z "$TIMEOUT" ] || [ ! -x "$TIMEOUT" ]; then
printf '%s\n' 'remco configuration exists; bounded inspection unavailable (timeout missing).'
return 0
fi
lp_remco_deadline=$(($(date +%s 2>/dev/null || printf 0) + 4))
lp_remco_size=$("$TIMEOUT" 1 dd if="$lp_remco_config" bs=65537 count=1 2>/dev/null | wc -c | tr -d '[:space:]')
case "$lp_remco_size" in ''|*[!0-9]*) lp_remco_size=0 ;; esac
if [ "$lp_remco_size" -eq 0 ] || [ "$lp_remco_size" -gt 65536 ]; then
printf '%s\n' 'remco configuration exists; static linkage unknown (empty, unreadable, or over 64 KiB).'
return 0
fi
lp_remco_root=${3:-}
if [ -z "$lp_remco_root" ]; then
lp_remco_root=$("$TIMEOUT" 1 ps -eo user=,args= 2>/dev/null | awk '
NR > 2048 { exit }
$1 == "root" && $2 ~ /(^|\/)remco$/ && $0 !~ /[[:space:]]-config([=[:space:]]|$)/ { print "root"; exit }
')
fi
[ "$lp_remco_root" = root ] || lp_remco_root=unknown
lp_remco_sources=$("$TIMEOUT" 1 dd if="$lp_remco_config" bs=65536 count=1 2>/dev/null | awk -v root="$lp_remco_templates" '
function quoted(line, fields, n) {
n = split(line, fields, "\"")
return n >= 3 ? fields[2] : ""
}
function finish() {
if (src != "" && index(src, root "/") == 1 && src !~ /\.\./ &&
src ~ /^[[:alnum:]_.\/-]+$/ &&
dst ~ /^\/etc\/(apache2|httpd)\// &&
reload ~ /(apache2|httpd)/ && reload ~ /(restart|reload|graceful)/ &&
backend && watch && keys && emitted++ < 8) print src
}
/^[[:space:]]*\[\[resource(\.template)?\]\][[:space:]]*$/ {
finish(); src = dst = reload = ""; backend = watch = keys = 0; next
}
/^[[:space:]]*#/ { next }
/^[[:space:]]*src[[:space:]]*=/ { src = quoted($0) }
/^[[:space:]]*dst[[:space:]]*=/ { dst = quoted($0) }
/^[[:space:]]*reload_cmd[[:space:]]*=/ { reload = tolower(quoted($0)) }
/^[[:space:]]*\[resource.backend.etcd\]/ { backend = 1 }
/^[[:space:]]*watch[[:space:]]*=[[:space:]]*true([[:space:]#]|$)/ { watch = 1 }
/^[[:space:]]*keys[[:space:]]*=[[:space:]]*\[[[:space:]]*"/ { keys = 1 }
END { finish() }
' 2>/dev/null)
[ -n "$lp_remco_sources" ] || return 0
lp_remco_checked=0
printf '%s\n' 'remco watches a backend and reloads Apache from a template; template contents and backend write permission require review.'
printf '%s\n' "$lp_remco_sources" | while IFS= read -r lp_remco_source; do
[ "$lp_remco_checked" -lt 8 ] || break
[ "$(date +%s 2>/dev/null || printf 0)" -lt "$lp_remco_deadline" ] || break
lp_remco_checked=$((lp_remco_checked + 1))
[ -f "$lp_remco_source" ] && [ -r "$lp_remco_source" ] && [ ! -L "$lp_remco_source" ] || continue
lp_remco_template_size=$("$TIMEOUT" 1 dd if="$lp_remco_source" bs=32769 count=1 2>/dev/null | wc -c | tr -d '[:space:]')
case "$lp_remco_template_size" in ''|*[!0-9]*) lp_remco_template_size=0 ;; esac
[ "$lp_remco_template_size" -gt 0 ] && [ "$lp_remco_template_size" -le 32768 ] || continue
lp_remco_raw=$("$TIMEOUT" 1 dd if="$lp_remco_source" bs=32768 count=1 2>/dev/null | awk '
/^[[:space:]]*(ServerName|ServerAlias|DocumentRoot|CustomLog|ErrorLog|TransferLog|Include|LoadModule)[[:space:]]+/ &&
/\{\{[[:space:]]*getv[[:space:]]*\([^}]*\)[[:space:]]*\}\}/ { print "yes"; exit }
' 2>/dev/null)
[ "$lp_remco_raw" = yes ] || continue
if [ "$lp_remco_root" = root ]; then
printf '%s\n' 'Candidate: root remco process and watched backend value rendered directly into Apache configuration (backend write permission unknown).'
else
printf '%s\n' 'Candidate: watched backend value rendered directly into Apache configuration (remco process privilege and backend write permission unknown).'
fi
break
done
)
peass{Apache-Nginx}
@@ -0,0 +1,82 @@
# Title: Software Information - Below log directory
# ID: SI_Below_Log_Directory
# Author: PEASS-ng contributors
# Last Update: 09-10-2026
# Description: Passive triage of a writable Below log directory with a privileged execution path
# License: GNU GPL
# Version: 1.0
# Mitre: T1068
# Functions Used: print_2title
# Global Variables: $MACPEAS, $SEARCH_IN_FOLDER, $sudo_l_cached_output, $sudo_l_password_output, $sudo_l_output
# Initial Functions:
# Generated Global Variables: $lp_below_binary, $lp_below_candidate, $lp_below_dir_mode, $lp_below_privileged_path
# Fat linpeas: 0
# Small linpeas: 1
lp_below_effective_access() {
[ -w "$1" ] && [ -x "$1" ]
}
lp_below_is_symlink() {
[ -L "$1" ]
}
lp_below_check_log_directory() {
# The arguments permit metadata-only fixtures; the live call uses fixed paths.
[ -f "$1" ] && [ -x "$1" ] || return 0
[ -d "$2" ] && ! lp_below_is_symlink "$2" && lp_below_effective_access "$2" || return 0
# Only the mode token for the fixed Below directory is used.
# shellcheck disable=SC2012
lp_below_dir_mode=$(ls -ld "$2" 2>/dev/null | awk 'NR == 1 { print $1 }')
case "$lp_below_dir_mode" in
d????????t*|d????????T*) return 0 ;;
d????????[x-]*) ;;
*) return 0 ;; # Unknown metadata must not become a positive finding.
esac
lp_below_privileged_path=""
# These values are collected by the preceding sudo module when available.
# shellcheck disable=SC2154
if printf '%s\n%s\n%s\n' "$sudo_l_cached_output" "$sudo_l_password_output" "$sudo_l_output" |
awk -v path="$1" '
/^[[:space:]]*\((root|ALL)([[:space:]]*:[[:space:]]*ALL)?\)/ && index($0, "!") == 0 {
at = index($0, path)
if (at && (at == 1 || substr($0, at - 1, 1) ~ /[[:space:],:]/) &&
(substr($0, at + length(path), 1) ~ /[[:space:]]/ || at + length(path) > length($0)))
found = 1
}
END { exit !found }
'; then
lp_below_privileged_path="sudo rule lists this Below executable for root"
elif command -v ps >/dev/null 2>&1 && ps -eo user=,comm= 2>/dev/null |
awk '$1 == "root" && $2 == "below" { found = 1 } END { exit !found }'; then
lp_below_privileged_path="root-owned Below process observed"
fi
[ -n "$lp_below_privileged_path" ] || return 0
print_2title "Below log directory: potential privileged log-file exposure" "T1068"
printf 'Installed Below executable: %s\n' "$1"
printf 'Log directory: %s (effective write/search access; nonsticky mode %s)\n' "$2" "$lp_below_dir_mode"
printf 'Privileged execution evidence: %s\n' "$lp_below_privileged_path"
if lp_below_is_symlink "$3"; then
printf 'Log entry: %s is a symlink (target not read)\n' "$3"
elif [ -e "$3" ]; then
printf 'Log entry: %s exists\n' "$3"
else
printf 'Log entry: %s absent\n' "$3"
fi
printf '%s\n' 'Patch status: unknown; package version and backports were not verified.'
printf '%s\n\n' 'Review ACLs, filesystem behavior, and the actual privileged launch before treating this as CVE-2025-27591 exposure.'
}
if [ -z "$SEARCH_IN_FOLDER" ] && [ -z "$MACPEAS" ] && [ "$(id -u 2>/dev/null)" != 0 ]; then
lp_below_binary=""
for lp_below_candidate in /usr/bin/below /usr/local/bin/below; do
if [ -f "$lp_below_candidate" ] && [ -x "$lp_below_candidate" ]; then
lp_below_binary="$lp_below_candidate"
break
fi
done
[ -n "$lp_below_binary" ] && lp_below_check_log_directory "$lp_below_binary" /var/log/below /var/log/below/error_root.log
fi
@@ -9,7 +9,7 @@
# Functions Used: print_2title, print_3title, print_info
# Global Variables: $HOMESEARCH, $SED_RED
# Initial Functions:
# Generated Global Variables: $h, $firefox_ini, $chrome_base, $profiles
# Generated Global Variables: $h, $firefox_ini, $chrome_base, $profiles, $profile, $profile_count, $extension_settings, $passbolt_store
# Fat linpeas: 0
# Small linpeas: 1
@@ -57,6 +57,16 @@ for h in $HOMESEARCH; do
if [ "$profiles" ]; then
print_3title "Chromium profiles ($chrome_base)" "T1539,T1217"
printf "%s\n" "$profiles" | sed -${E} "s,.*,${SED_RED},"
profile_count=0
printf "%s\n" "$profiles" | while IFS= read -r profile; do
profile_count=$((profile_count + 1))
[ "$profile_count" -le 8 ] || break
extension_settings="$profile/Local Extension Settings"
passbolt_store="$extension_settings/didegimhafipceonhjepacocaffmoppf"
[ -d "$extension_settings" ] && [ ! -L "$extension_settings" ] || continue
[ -d "$passbolt_store" ] && [ ! -L "$passbolt_store" ] && [ -r "$passbolt_store" ] && [ -x "$passbolt_store" ] || continue
printf ' Passbolt extension local storage (path only; key availability unknown): %s\n' "$passbolt_store"
done
echo ""
fi
fi
@@ -1,22 +1,40 @@
# Title: Software Information - Cached AD Hashes
# ID: SI_Cached_AD_hashes
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Cached AD Hashes
# Last Update: 09-10-2026
# Description: Passive metadata for local domain credential-cache candidates
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1003.003
# Functions Used: print_2title
# Global Variables: $DEBUG
# Initial Functions:
# Generated Global Variables: $adhashes
# Generated Global Variables: $adhashes, $adhash, $lp_ad_cache_count
# Fat linpeas: 0
# Small linpeas: 1
adhashes=$(ls "/var/lib/samba/private/secrets.tdb" "/var/lib/samba/passdb.tdb" "/var/opt/quest/vas/authcache/vas_auth.vdb" "/var/lib/sss/db/cache_*" 2>/dev/null)
if [ "$adhashes" ] || [ "$DEBUG" ]; then
print_2title "Searching AD cached hashes" "T1003.003"
ls -l "/var/lib/samba/private/secrets.tdb" "/var/lib/samba/passdb.tdb" "/var/opt/quest/vas/authcache/vas_auth.vdb" "/var/lib/sss/db/cache_*" 2>/dev/null
echo ""
fi
adhashes=""
lp_ad_cache_count=0
for adhash in /var/lib/samba/private/secrets.tdb /var/lib/samba/passdb.tdb /var/opt/quest/vas/authcache/vas_auth.vdb /var/lib/sss/db/cache_*.ldb; do
[ -f "$adhash" ] || continue
if [ -z "$adhashes" ]; then
print_2title "Local domain credential-cache candidates" "T1003.003"
adhashes=found
fi
ls -ld "$adhash" 2>/dev/null
if [ -r "$adhash" ]; then
printf ' readable by current user (contents not inspected)\n'
else
printf ' not readable by current user\n'
fi
lp_ad_cache_count=$((lp_ad_cache_count + 1))
if [ "$lp_ad_cache_count" -ge 32 ]; then
printf ' additional cache files omitted after 32 entries\n'
break
fi
done
if [ -z "$adhashes" ] && [ "$DEBUG" ]; then
print_2title "Local domain credential-cache candidates" "T1003.003"
fi
[ -z "$adhashes" ] || printf '%s\n\n' 'Cache-file presence alone does not prove cached passwords; check domain cache_credentials and prior logins.'
@@ -0,0 +1,152 @@
# Title: Software Information - Consul agent script checks
# ID: SO_Consul
# Author: Carlos Polop
# Last Update: 2026-10-09
# Description: Passive review of script checks and loaded configuration directories for root-run Consul agents
# License: GNU GPL
# Version: 1.1
# Mitre: T1548.003
# Functions Used: print_2title, print_info
# Global Variables: $SEARCH_IN_FOLDER, $IAMROOT
# Initial Functions:
# Generated Global Variables: $consul_root_agents, $consul_agent_args, $consul_cli_enabled, $consul_config_paths, $consul_config_path, $consul_config_file, $consul_file_count, $consul_file_result, $consul_enabled_file, $consul_disabled_file, $consul_writable_dirs, $consul_writable_dir, $consul_partial
# Fat linpeas: 0
# Small linpeas: 0
# Only inspect the current process list and readable, bounded configuration.
# Never call the agent API or register a health check during enumeration.
if [ ! "$SEARCH_IN_FOLDER" ] && command -v ps >/dev/null 2>&1; then
consul_root_agents=$(LC_ALL=C ps -eo user=,args= 2>/dev/null | awk '
NR > 4096 { exit }
length($0) > 2048 { next }
$0 ~ /^[[:space:]]*root[[:space:]]+([^[:space:]]*\/)?consul[[:space:]]+agent([[:space:]]|$)/ {
sub(/^[[:space:]]*root[[:space:]]+/, "")
if (seen++ < 4) print
}
')
if [ "$consul_root_agents" ]; then
printf '%s\n' "$consul_root_agents" | while IFS= read -r consul_agent_args; do
consul_cli_enabled=
case " $consul_agent_args " in
*' -enable-script-checks '*|*' -enable-script-checks=true '*) consul_cli_enabled=1 ;;
esac
# Inspect only paths named by this running agent. An unused default
# directory could contain stale configuration and create a false alarm.
consul_config_paths=$(printf '%s\n' "$consul_agent_args" | awk '
{
count = split($0, args, /[[:space:]]+/)
for (i = 1; i <= count && found < 8; i++) {
if (args[i] == "-config-dir" || args[i] == "-config-file") {
if (i < count) { print args[++i]; found++ }
} else if (args[i] ~ /^-config-(dir|file)=/) {
sub(/^-config-(dir|file)=/, "", args[i])
print args[i]
found++
}
}
}
')
consul_file_count=0
consul_enabled_file=
consul_disabled_file=
consul_writable_dirs=
consul_partial=
while IFS= read -r consul_config_path; do
case "$consul_config_path" in
/*) ;;
*) continue ;;
esac
case "$consul_config_path" in
*'//'*|*'/./'*|*'/../'*|*'/.'|*'/..'|*'"'*|*"'"*) continue ;;
esac
if [ -d "$consul_config_path" ] && [ ! -L "$consul_config_path" ]; then
if [ ! "$IAMROOT" ] && [ -w "$consul_config_path" ] && [ -x "$consul_config_path" ]; then
if [ "$consul_writable_dirs" ]; then
consul_writable_dirs="$consul_writable_dirs
$consul_config_path"
else
consul_writable_dirs=$consul_config_path
fi
fi
set -- "$consul_config_path"/*.json "$consul_config_path"/*.hcl
else
set -- "$consul_config_path"
fi
for consul_config_file do
case "$consul_config_file" in
*.json|*.hcl) ;;
*) continue ;;
esac
[ -f "$consul_config_file" ] && [ -r "$consul_config_file" ] &&
[ ! -L "$consul_config_file" ] || continue
consul_file_count=$((consul_file_count + 1))
if [ "$consul_file_count" -gt 24 ]; then
consul_partial=1
break
fi
if [ ! "$(find "$consul_config_file" -prune -type f -size -65537c -print 2>/dev/null)" ]; then
consul_partial=1
continue
fi
consul_file_result=$(LC_ALL=C awk '
NR > 400 { partial = 1; exit }
length($0) > 2048 { partial = 1; next }
{
line = $0
sub(/^[[:space:]]*/, "", line)
if (line ~ /^(#|\/\/)/) next
if (line ~ /(^|[{,])[[:space:]]*"?enable_script_checks"?[[:space:]]*[:=][[:space:]]*true([,[:space:]}]|$)/) enabled = 1
if (line ~ /(^|[{,])[[:space:]]*"?enable_script_checks"?[[:space:]]*[:=][[:space:]]*false([,[:space:]}]|$)/) disabled = 1
}
END {
if (enabled) print "enabled"
if (disabled) print "disabled"
if (partial) print "partial"
}
' "$consul_config_file" 2>/dev/null)
case "$consul_file_result" in
*enabled*) consul_enabled_file=$consul_config_file ;;
esac
case "$consul_file_result" in
*disabled*) consul_disabled_file=$consul_config_file ;;
esac
case "$consul_file_result" in
*partial*) consul_partial=1 ;;
esac
done
[ "$consul_file_count" -gt 24 ] && break
done <<_PEAS_CONSUL_CONFIG_PATHS_
$consul_config_paths
_PEAS_CONSUL_CONFIG_PATHS_
if [ "$consul_enabled_file" ] || [ "$consul_cli_enabled" ] || [ "$consul_writable_dirs" ]; then
if [ "$consul_enabled_file" ] || [ "$consul_cli_enabled" ]; then
print_2title "Root-run Consul script-check review candidate" "T1548.003"
else
print_2title "Root-run Consul config-directory write review candidate" "T1548.003"
fi
print_info "https://book.hacktricks.wiki/en/linux-hardening/processes-crontab-systemd-dbus/process-enumeration-and-service-paths.html#consul-agent-script-checks"
if [ "$consul_enabled_file" ]; then
printf 'Root-run Consul agent has enable_script_checks=true in %s\n' "$consul_enabled_file"
fi
if [ "$consul_cli_enabled" ]; then
printf '%s\n' 'Root-run Consul agent enables script checks on its command line'
fi
if [ "$consul_disabled_file" ]; then
printf 'Conflicting enable_script_checks=false in %s; effective setting unknown\n' "$consul_disabled_file"
fi
if [ "$consul_partial" ]; then
printf '%s\n' 'Consul config review incomplete (file size, line length, line count, or file count limit)'
fi
if [ "$consul_writable_dirs" ]; then
printf '%s\n' "$consul_writable_dirs" | while IFS= read -r consul_writable_dir; do
printf 'Loaded Consul config-dir writable/searchable by current user: %s\n' "$consul_writable_dir"
done
printf '%s\n' 'Config-file execution requires a loaded directory, effective script-check policy, and an authorized reload or restart; verify parent path/symlink and ACL policy. No configuration was written or reloaded.'
fi
if [ "$consul_enabled_file" ] || [ "$consul_cli_enabled" ]; then
printf '%s\n' 'API script execution requires an authorized service registration and the agent running as privileged user; static config does not prove either API access or effective ACL policy.'
fi
fi
done
fi
fi
@@ -9,7 +9,7 @@
# Functions Used: print_2title
# Global Variables: $DEBUG
# Initial Functions:
# Generated Global Variables:
# Generated Global Variables: $gitlab_bytes
# Fat linpeas: 0
# Small linpeas: 1
@@ -30,18 +30,37 @@ if [ "$(command -v gitlab-rails || echo -n '')" ] || [ "$(command -v gitlab-back
echo ""
fi
#Check gitlab files
printf "%s\n" "$PSTORAGE_GITLAB" | sort | uniq | while read f; do
if echo $f | grep -q secrets.yml; then
printf "%s\n" "$PSTORAGE_GITLAB" | sort | uniq | while IFS= read -r f; do
if [ "${f##*/}" = "secrets.yml" ]; then
echo "Found $f" | sed "s,$f,${SED_RED},"
cat "$f" 2>/dev/null | grep -Iv "^$" | grep -v "^#"
elif echo $f | grep -q gitlab.yml; then
elif [ "${f##*/}" = "gitlab.yml" ]; then
echo "Found $f" | sed "s,$f,${SED_RED},"
cat "" | grep -A 4 "repositories:"
elif echo $f | grep -q gitlab.rb; then
if [ ! -f "$f" ] || [ -L "$f" ] || [ ! -r "$f" ]; then
echo "Repository preview skipped (unreadable, non-regular, or symlink); inspect path manually"
else
gitlab_bytes=$(dd if="$f" bs=262145 count=1 2>/dev/null | wc -c | tr -d '[:space:]')
if [ "$gitlab_bytes" -le 262144 ] 2>/dev/null; then
dd if="$f" bs=262144 count=1 2>/dev/null |
grep -A 4 '^[[:space:]]*repositories:' | head -n 20 | cut -c 1-256
else
echo "Repository preview skipped (larger than 256 KiB); inspect path manually"
fi
fi
elif [ "${f##*/}" = "gitlab.rb" ]; then
echo "Found $f" | sed "s,$f,${SED_RED},"
cat "$f" | grep -Iv "^$" | grep -v "^#" | sed -${E} "s,email|user|password,${SED_RED},"
if [ ! -f "$f" ] || [ -L "$f" ] || [ ! -r "$f" ]; then
echo "Config preview skipped (unreadable, non-regular, or symlink); inspect path manually"
else
gitlab_bytes=$(dd if="$f" bs=262145 count=1 2>/dev/null | wc -c | tr -d '[:space:]')
if [ "$gitlab_bytes" -le 262144 ] 2>/dev/null; then
dd if="$f" bs=262144 count=1 2>/dev/null | grep -Eiv '^[[:space:]]*(#|$)' | grep -Ei 'smtp_|password|user|token|secret|database|redis|external_url' | head -n 40 | cut -c 1-256 | sed -${E} "s,email|user|password,${SED_RED},"
else
echo "Config preview skipped (larger than 256 KiB); inspect path manually"
fi
fi
fi
echo ""
done
echo ""
fi
fi
@@ -41,7 +41,7 @@ if ! [ "$SEARCH_IN_FOLDER" ] && ! [ "$MACPEAS" ]; then
if [ "$gogs_processes" ] || [ "$gogs_bin" ] || [ -r /etc/gogs/conf/app.ini ] || [ -r /etc/gogs/app.ini ] || [ "$DEBUG" ]; then
print_2title "Gogs privileged file-write checks" "T1068"
print_info "Gogs <= 0.13.3 is vulnerable to authenticated symlink-based file writes through the PutContents API (CVE-2025-8110): https://github.com/gogs/gogs/security/advisories/GHSA-gg64-xxr9-qhjp"
print_info "Gogs <= 0.13.3 is vulnerable to authenticated symlink-based file writes through the PutContents API (CVE-2025-8110): https://github.com/advisories/GHSA-mq8m-42gh-wq7r"
gogs_root_process="$(printf "%s\n" "$gogs_processes" | awk '$1 == "root" { print "yes"; exit }')"
if [ "$gogs_processes" ]; then
@@ -31,9 +31,12 @@ if [ "$kadmin_exists" ] || [ "$klist_exists" ] || [ "$kinit_exists" ] || [ "$PST
(env || printenv) 2>/dev/null | grep -E "^KRB5" | sed -${E} "s,KRB5,${SED_RED},g"
printf "%s\n" "$PSTORAGE_KERBEROS" | while read f; do
if [ -r "$f" ]; then
if echo "$f" | grep -q .k5login; then
printf "%s\n" "$PSTORAGE_KERBEROS" | while IFS= read -r f; do
if [ "${f##*/}" = ".k5users" ] && [ -f "$f" ]; then
echo ".k5users authorization file (principal and command rights require review)"
ls -ld "$f" 2>/dev/null
elif [ -r "$f" ]; then
if [ "${f##*/}" = ".k5login" ]; then
echo ".k5login file (users with access to the user who has this file in his home)"
cat "$f" 2>/dev/null | sed -${E} "s,.*,${SED_RED},g"
elif echo "$f" | grep -q keytab; then
@@ -69,4 +72,4 @@ if [ "$kadmin_exists" ] || [ "$klist_exists" ] || [ "$kinit_exists" ] || [ "$PST
klist 2>/dev/null || echo_not_found "klist"
echo ""
fi
fi
@@ -1,29 +1,61 @@
# Title: Software Information - Logstash
# ID: SI_Logstash
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Searching logstash files
# Last Update: 09-10-2026
# Description: Review output/filter directives and bounded input-path leads in known Logstash directories
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1552.001
# Functions Used: print_2title
# Global Variables: $DEBUG, $knw_usrs, $nosh_usrs, $sh_usrs, $USER
# Global Variables: $DEBUG, $E, $SED_BLUE, $SED_GREEN, $SED_LIGHT_CYAN, $SED_LIGHT_MAGENTA, $SED_RED, $knw_usrs, $nosh_usrs, $sh_usrs, $USER
# Cached storage inputs (builder): $PSTORAGE_LOGSTASH
# Initial Functions:
# Generated Global Variables:
# Generated Global Variables: $d, $ls_config, $ls_count, $ls_lines, $ls_root_count
# Fat linpeas: 0
# Small linpeas: 1
if [ "$PSTORAGE_LOGSTASH" ] || [ "$DEBUG" ]; then
print_2title "Searching logstash files" "T1552.001"
printf "$PSTORAGE_LOGSTASH"
printf "%s\n" "$PSTORAGE_LOGSTASH" | while read d; do
printf '%s\n' "$PSTORAGE_LOGSTASH"
ls_root_count=0
printf '%s\n' "$PSTORAGE_LOGSTASH" | while IFS= read -r d; do
[ -n "$d" ] && [ -d "$d" ] || continue
ls_root_count=$((ls_root_count + 1))
if [ -r "$d/startup.options" ]; then
echo "Logstash is running as user:"
cat "$d/startup.options" 2>/dev/null | grep "LS_USER\|LS_GROUP" | sed -${E} "s,$sh_usrs,${SED_LIGHT_CYAN}," | sed -${E} "s,$nosh_usrs,${SED_BLUE}," | sed -${E} "s,$knw_usrs,${SED_GREEN}," | sed -${E} "s,$USER,${SED_LIGHT_MAGENTA}," | sed -${E} "s,root,${SED_RED},"
echo "Logstash startup.options account setting (verify running identity):"
cat "$d/startup.options" 2>/dev/null | grep "LS_USER\|LS_GROUP" | sed -${E} "s,$sh_usrs,${SED_LIGHT_CYAN}," | sed -${E} "s,$nosh_usrs,${SED_BLUE}," | sed -${E} "s,$knw_usrs,${SED_GREEN}," | sed -${E} "s,${USER:-^$},${SED_LIGHT_MAGENTA}," | sed -${E} "s,root,${SED_RED},"
fi
# Preserve full-file output/filter coverage, including extensionless names.
# Keep the directory component quoted while allowing the filename glob.
for ls_config in "$d"/conf.d/out*; do
[ -f "$ls_config" ] && [ -r "$ls_config" ] || continue
grep -E 'exec[[:space:]]*\{|command[[:space:]]*=>' "$ls_config" 2>/dev/null |
sed -${E} "s,exec\W*\{|command\W*=>,${SED_RED},"
done
for ls_config in "$d"/conf.d/filt*; do
[ -f "$ls_config" ] && [ -r "$ls_config" ] || continue
grep -E 'path[[:space:]]*=>|code[[:space:]]*=>|match[[:space:]]*=>|ruby[[:space:]]*\{' "$ls_config" 2>/dev/null |
sed -${E} "s,path\W*=>|code\W*=>|match\W*=>|ruby\W*\{,${SED_RED},"
done
# Input-path cues are a separate bounded pass over already-known dirs.
if [ "$ls_root_count" -le 8 ]; then
ls_count=0
for ls_config in "$d"/conf.d/in*; do
[ -f "$ls_config" ] && [ -r "$ls_config" ] && [ ! -L "$ls_config" ] || continue
ls_count=$((ls_count + 1))
if [ "$ls_count" -gt 16 ]; then
echo ' Additional input-named Logstash files not inspected (16-file cap).'
break
fi
ls_lines=$(dd if="$ls_config" bs=8192 count=1 2>/dev/null |
grep -E '^[[:space:]]*path[[:space:]]*=>' | head -n 16)
if [ -n "$ls_lines" ]; then
printf ' Input path directive lead in %s (verify file input and permissions):\n' "$ls_config"
printf '%s\n' "$ls_lines"
fi
done
fi
cat "$d/conf.d/out*" | grep "exec\s*{\|command\s*=>" | sed -${E} "s,exec\W*\{|command\W*=>,${SED_RED},"
cat "$d/conf.d/filt*" | grep "path\s*=>\|code\s*=>\|ruby\s*{" | sed -${E} "s,path\W*=>|code\W*=>|ruby\W*\{,${SED_RED},"
done
fi
echo ""
echo ""
@@ -0,0 +1,97 @@
# Title: Software Information - Motion configuration
# ID: SI_Motion_Configuration
# Author: PEASS-ng contributors
# Last Update: 08-10-2026
# Description: Read-only inventory of local motionEye and Motion control settings without printing credentials
# License: GNU GPL
# Version: 1.0
# Mitre: T1552.001,T1068
# Functions Used: print_2title
# Global Variables: $MACPEAS, $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables: $lp_motion_conf_dir, $lp_motion_custom_dir, $motion_config, $motion_dir, $motion_camera, $motion_camera_count, $motion_hook, $motion_filename, $motion_camera_unreadable, $motion_camera_flags
# Fat linpeas: 0
# Small linpeas: 1
lp_motion_readable() {
[ -r "$1" ]
}
lp_motion_check_config() {
local motion_config="$1" motion_dir motion_camera motion_camera_count=0
local motion_hook=0 motion_filename=0 motion_camera_unreadable=0 motion_camera_flags
[ -e "$motion_config" ] || return 0
print_2title "Motion configuration: $motion_config" "T1552.001"
if [ ! -f "$motion_config" ] || ! lp_motion_readable "$motion_config"; then
echo "Configuration exists but is not readable by the current user"
echo ""
return 0
fi
# Values are reduced to fixed labels or validated numbers. In particular,
# motionEye stores the admin hash in a comment, which must never be printed.
dd if="$motion_config" bs=65536 count=1 2>/dev/null | awk '
/^[[:space:]]*#[[:space:]]*@admin_password([[:space:]]|$)/ {
admin_seen = 1
admin_present = ($3 != "")
next
}
/^[[:space:]]*#/ { next }
$1 == "webcontrol_port" { port = ($2 ~ /^[0-9]+$/ && $2 + 0 <= 65535 ? $2 : "invalid") }
$1 == "webcontrol_parms" { parms = ($2 ~ /^[0-3]$/ ? $2 : "invalid") }
$1 == "webcontrol_auth_method" {
method = tolower($2)
if (method == "0" || method == "none") auth = "disabled"
else if (method == "1" || method == "2" || method == "basic" || method == "digest") auth = "enabled"
else auth = "unknown"
}
$1 == "webcontrol_localhost" { local_only = ($2 == "on" || $2 == "off" ? $2 : "invalid") }
END {
if (admin_seen) print "motionEye admin hash: " (admin_present ? "present (redacted)" : "empty")
print "Motion webcontrol port: " (port == "" ? "not configured" : port)
print "Motion webcontrol parameter level: " (parms == "" ? "default (0)" : parms)
print "Motion webcontrol authentication: " (auth == "" ? "disabled/default" : auth)
print "Motion webcontrol localhost restriction: " (local_only == "" ? "default (on)" : local_only)
if (port ~ /^[0-9]+$/ && port + 0 > 0 && parms ~ /^[2-3]$/ && (auth == "" || auth == "disabled"))
print "Review: Motion webcontrol may expose advanced settings without authentication to local users"
}
' 2>/dev/null
# motionEye normally places per-camera settings beside motion.conf. Bound
# this local glob so an unusual directory cannot turn the check into a scan.
motion_dir="${motion_config%/*}"
for motion_camera in "$motion_dir"/camera-*.conf; do
[ -e "$motion_camera" ] || continue
motion_camera_count=$((motion_camera_count + 1))
[ "$motion_camera_count" -le 10 ] || break
if [ ! -f "$motion_camera" ] || ! lp_motion_readable "$motion_camera"; then
motion_camera_unreadable=1
continue
fi
motion_camera_flags="$(dd if="$motion_camera" bs=65536 count=1 2>/dev/null | awk '
/^[[:space:]]*#/ { next }
$1 == "on_picture_save" && NF > 1 { hook = 1 }
$1 == "picture_filename" && NF > 1 { filename = 1 }
END { print hook + 0, filename + 0 }
' 2>/dev/null)"
case "$motion_camera_flags" in "1 "*) motion_hook=1 ;; esac
case "$motion_camera_flags" in *" 1") motion_filename=1 ;; esac
done
[ "$motion_hook" -eq 1 ] && echo "Camera picture-save event hook: configured (value redacted)"
[ "$motion_filename" -eq 1 ] && echo "Camera picture filename: configured (value redacted)"
[ "$motion_camera_unreadable" -eq 1 ] && echo "Some camera configuration files are not readable"
echo "Configuration alone does not establish that Motion is running or its service user"
echo ""
}
if ! [ "$SEARCH_IN_FOLDER" ] && ! [ "$MACPEAS" ]; then
lp_motion_conf_dir="/etc/motioneye"
if lp_motion_readable /etc/motioneye/motioneye.conf; then
lp_motion_custom_dir="$(awk '$1 == "conf_path" { path = $2 } END { print path }' /etc/motioneye/motioneye.conf 2>/dev/null)"
case "$lp_motion_custom_dir" in /*) lp_motion_conf_dir="$lp_motion_custom_dir" ;; esac
fi
lp_motion_check_config "$lp_motion_conf_dir/motion.conf"
lp_motion_check_config /etc/motion/motion.conf
lp_motion_check_config /usr/local/etc/motion/motion.conf
fi
@@ -9,7 +9,7 @@
# Functions Used: print_2title
# Global Variables: $DEBUG, $knw_usrs, $nosh_usrs, $sh_usrs, $DEBUG, $USER, $STRINGS
# Initial Functions:
# Generated Global Variables: $mysqluser, $mysqlexec, $mysqlconnect, $mysqlconnectnopass, $mysqluser, $version_output, $major_version, $version, $process_info
# Generated Global Variables: $mysqluser, $mysqlexec, $mysqlconnect, $mysqlconnectnopass, $mysqluser, $version_output, $version, $process_info, $mysql_unit_dir, $mysql_unit_parent, $mysql_unit_file, $mysql_unit_count, $mysql_unit_size, $mysql_unit_signal
# Fat linpeas: 0
# Small linpeas: 1
@@ -107,13 +107,69 @@ if [ "$(command -v mysql || echo -n '')" ] || [ "$(command -v mysqladmin || echo
echo ""
fi
### This section checks if MySQL (mysqld) is running as root and if its version is 4.x or 5.x to refer a known local privilege escalation exploit! ###
### Review the MySQL/MariaDB service identity without assuming a version is exploitable. ###
# Find the mysqld process
process_info=$(ps aux | grep '[m]ysqld' | head -n1)
process_info=$(ps aux 2>/dev/null | grep -E '[m]ysqld|[m]ariadbd' | head -n1)
if [ -z "$process_info" ]; then
echo "MySQL process not found." | sed -${E} "s,.*,${SED_GREEN},"
echo "MySQL/MariaDB process not visible to this user (it may be stopped or /proc may hide other users)."
# A fixed, small unit-file review can still expose an explicitly configured
# root context when hidepid prevents process enumeration. Never run the unit.
if [ -d /run/systemd/system ]; then
mysql_unit_count=0
for mysql_unit_dir in /etc/systemd/system /usr/lib/systemd/system /lib/systemd/system; do
[ -d "$mysql_unit_dir" ] || continue
mysql_unit_parent=$mysql_unit_dir
while [ "$mysql_unit_parent" != / ] && [ ! -L "$mysql_unit_parent" ]; do
mysql_unit_parent=${mysql_unit_parent%/*}
[ "$mysql_unit_parent" ] || mysql_unit_parent=/
done
[ "$mysql_unit_parent" = / ] || continue
for mysql_unit_file in "$mysql_unit_dir"/*mysql*.service "$mysql_unit_dir"/*mariadb*.service; do
[ -f "$mysql_unit_file" ] && [ -r "$mysql_unit_file" ] && [ ! -L "$mysql_unit_file" ] || continue
mysql_unit_count=$((mysql_unit_count + 1))
if [ "$mysql_unit_count" -gt 12 ]; then
echo "MySQL/MariaDB unit review incomplete (12-file limit)."
break 2
fi
mysql_unit_size=$(stat -c %s "$mysql_unit_file" 2>/dev/null) ||
mysql_unit_size=$(stat -f %z "$mysql_unit_file" 2>/dev/null) || continue
case "$mysql_unit_size" in ''|*[!0-9]*) continue ;; esac
[ "${#mysql_unit_size}" -le 6 ] && [ "$mysql_unit_size" -le 65536 ] || continue
mysql_unit_signal=$(head -c 65537 "$mysql_unit_file" 2>/dev/null | awk '
/^\[Service\][[:space:]]*$/ { service = 1; next }
/^\[/ { service = 0; next }
!service || /^[[:space:]]*[#;]/ { next }
{
line = $0
sub(/^[[:space:]]*/, "", line)
if (line ~ /^User[[:space:]]*=/) {
sub(/^User[[:space:]]*=[[:space:]]*/, "", line)
sub(/[[:space:]]*$/, "", line)
user = line
} else if (line ~ /^DynamicUser[[:space:]]*=/) {
sub(/^DynamicUser[[:space:]]*=[[:space:]]*/, "", line)
sub(/[[:space:]]*$/, "", line)
dynamic = tolower(line)
} else if (line ~ /^ExecStart[[:space:]]*=/) {
sub(/^ExecStart[[:space:]]*=[[:space:]]*[-+!@]*/, "", line)
direct = (line ~ /^\/[^[:space:]]+\/(mysqld|mariadbd)([[:space:]]|$)/ &&
line !~ /(^|[[:space:]])--user([=[:space:]]|$)/)
}
}
END {
if (direct && (user == "" || user == "root") && dynamic !~ /^(yes|true|1)$/)
print (user == "root" ? "explicit root" : "default root")
}
' 2>/dev/null)
if [ "$mysql_unit_signal" ]; then
printf 'MySQL/MariaDB unit root-context review candidate: %s (%s; process state, drop-ins, SQL FILE/UDF rights and plugin directory unverified)\n' "$mysql_unit_file" "$mysql_unit_signal"
fi
done
done
fi
else
# Extract the process user
@@ -125,22 +181,9 @@ else
# Extract the version number (expects format like X.Y.Z)
version=$(echo "$version_output" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)
if [ -z "$version" ]; then
echo "Unable to determine MySQL version." | sed -${E} "s,.*,${SED_GREEN},"
if [ "$mysqluser" = "root" ]; then
printf 'MySQL/MariaDB process runs as root%s; review SQL access, FILE/UDF rights, plugin directory, and effective service policy.\n' "${version:+ (binary version $version)}" | sed -${E} "s,.*,${SED_RED},"
else
# Extract the major version number (X from X.Y.Z)
major_version=$(echo "$version" | cut -d. -f1)
# Check if MySQL is running as root and if the version is either 4.x or 5.x
if [ "$mysqluser" = "root" ] && { [ "$major_version" -eq 4 ] || [ "$major_version" -eq 5 ]; }; then
echo "MySQL is running as root with version $version. This is a potential local privilege escalation vulnerability!" | sed -${E} "s,.*,${SED_RED},"
echo "\tRefer to: https://www.exploit-db.com/exploits/1518" | sed -${E} "s,.*,${SED_YELLOW},"
echo "\tRefer to: https://medium.com/r3d-buck3t/privilege-escalation-with-mysql-user-defined-functions-996ef7d5ceaf" | sed -${E} "s,.*,${SED_YELLOW},"
else
echo "MySQL is running as user '$mysqluser' with version $version." | sed -${E} "s,.*,${SED_GREEN},"
fi
### ------------------------------------------------------------------------------------------------------------------------------------------------ ###
printf "MySQL/MariaDB process runs as user '%s'%s.\n" "$mysqluser" "${version:+ (binary version $version)}" | sed -${E} "s,.*,${SED_GREEN},"
fi
fi
@@ -0,0 +1,84 @@
# Title: Software Information - OMI management listener
# ID: SI_Omi
# Author: PEASS-ng contributors
# Last Update: 09-10-2026
# Description: Passively correlate a standard OMI installation, process identity, and local WS-Management listener.
# License: GNU GPL
# Version: 1.0
# Mitre: T1068
# Functions Used: print_3title, print_info
# Global Variables:
# Initial Functions:
# Generated Global Variables: $omi_proc, $omi_port, $omi_pkg, $omi_timeout
# Fat linpeas: 0
# Small linpeas: 1
omi_bounded() {
if [ -n "$omi_timeout" ]; then
"$omi_timeout" 2 "$@"
else
"$@"
fi
}
omi_process_user() {
LC_ALL=C awk '
NR > 4096 { print "#limit"; exit }
$2 == "omiengine" { print $1; exit }
'
}
omi_listener() {
LC_ALL=C awk '
NR > 4096 { print "#limit"; exit }
/LISTEN/ {
local_address = $4
if (local_address ~ /[.:]598[56]$/) { print local_address; exit }
}
'
}
omi_report_evidence() {
printf 'OMI package version: %s (vendor backports/build provenance unverified).\n' "${3:-unknown}"
case "$1" in
'#limit') echo 'OMI process context: unknown (4,096-line process limit).' ;;
'') echo 'OMI process context: not visible; service may be stopped or hidden.' ;;
*) printf 'OMI process owner: %s (effective service identity needs confirmation).\n' "$1" ;;
esac
case "$2" in
'#limit') echo 'WS-Management listener: unknown (4,096-line socket limit).' ;;
'') echo 'WS-Management listener on 5985/5986: not visible; custom ports or Unix socket remain possible.' ;;
*) printf 'WS-Management listener co-observed: %s (socket ownership unproven).\n' "$2" ;;
esac
if [ "$1" = root ] && [ -n "$2" ] && [ "$2" != '#limit' ]; then
echo 'OMI review candidate: root-owned omiengine and a 5985/5986 listener are visible; verify socket ownership, installed security fix/backports, access policy, and actual SSRF/network reachability.'
fi
}
check_omi_exposure() {
# Fixed standard paths gate all commands; no directory walk or service request.
[ -e /opt/omi/bin/omiengine ] || [ -e /etc/opt/omi/conf/omiserver.conf ] || return 0
print_3title 'OMI management listener review' 'T1068'
print_info 'https://github.com/microsoft/omi/security/advisories/GHSA-fmh7-p6gp-8xpj'
print_info 'https://book.hacktricks.wiki/en/network-services-pentesting/5985-5986-pentesting-omi.html'
omi_timeout=$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
omi_proc=$(omi_bounded ps -eo user=,comm= 2>/dev/null | omi_process_user)
if command -v ss >/dev/null 2>&1; then
omi_port=$(omi_bounded ss -ltn 2>/dev/null | omi_listener)
elif command -v netstat >/dev/null 2>&1; then
omi_port=$(omi_bounded netstat -an 2>/dev/null | omi_listener)
else
omi_port=
fi
omi_pkg=
if command -v dpkg-query >/dev/null 2>&1; then
omi_pkg=$(omi_bounded dpkg-query -W -f='$''{Version}\n' omi 2>/dev/null | LC_ALL=C head -n1 | cut -c1-64)
elif command -v rpm >/dev/null 2>&1; then
omi_pkg=$(omi_bounded rpm -q --qf '%{VERSION}-%{RELEASE}\n' omi 2>/dev/null | LC_ALL=C head -n1 | cut -c1-64)
fi
case "$omi_pkg" in [0-9]* ) ;; * ) omi_pkg= ;; esac
omi_report_evidence "$omi_proc" "$omi_port" "$omi_pkg"
}
check_omi_exposure
@@ -1,22 +1,100 @@
# Title: Software Information - Pam.d
# ID: SI_Pamd
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Passwords inside pam.d
# Last Update: 09-10-2026
# Description: Password strings, nonstandard modules, and writable pam_exec helpers in PAM policy
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1556.003
# Functions Used: print_2title
# Global Variables: $DEBUG
# Functions Used: checkPamAllowActiveCVE20256018, print_2title
# Global Variables: $DEBUG, $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables: $pamdpass
# Generated Global Variables: $pamdpass, $pamd_files, $pamd_hits, $pamd_module_hits, $pamd_module_limited, $pamd_config, $pamd_size, $pamd_helpers, $pamd_helper, $pamd_target, $pamd_module, $pamd_line
# Fat linpeas: 0
# Small linpeas: 1
pamdpass=$(grep -Ri "passwd" ${ROOT_FOLDER}etc/pam.d/ 2>/dev/null | grep -v ":#")
pamdpass=$(grep -Ri "passwd" "${ROOT_FOLDER}etc/pam.d/" 2>/dev/null | grep -v ":#")
if [ "$pamdpass" ] || [ "$DEBUG" ]; then
print_2title "Passwords inside pam.d" "T1556.003"
grep -Ri "passwd" ${ROOT_FOLDER}etc/pam.d/ 2>/dev/null | grep -v ":#" | sed "s,passwd,${SED_RED},"
if [ "$pamdpass" ]; then
printf '%s\n' "$pamdpass" | sed "s,passwd,${SED_RED},"
fi
echo ""
fi
fi
# Inspect only small local PAM policy files; do not execute or read helper contents.
(
pamd_files=0
pamd_hits=0
pamd_module_hits=0
pamd_module_limited=0
for pamd_config in "${ROOT_FOLDER}etc/pam.d/"* "${ROOT_FOLDER}etc/pam.conf"; do
if [ "$pamd_files" -ge 128 ] || [ "$pamd_hits" -ge 24 ]; then break; fi
[ -f "$pamd_config" ] && [ ! -L "$pamd_config" ] && [ -r "$pamd_config" ] || continue
pamd_files=$((pamd_files + 1))
pamd_size=$(wc -c < "$pamd_config" 2>/dev/null) || continue
[ "$pamd_size" -le 8192 ] || continue
pamd_helpers=$(awk '
/^[[:space:]]*#/ { next }
{
for (i = 1; i <= NF; i++) {
if (substr($i, 1, 1) == "#") break
if ($i ~ /(^|\/)pam_exec[.]so$/) {
for (j = i + 1; j <= NF; j++) {
if (substr($j, 1, 1) == "#") break
if ($j ~ /^\/[A-Za-z0-9._+\/-]+$/) { print "E:" $j; break }
}
}
}
module_start = 0
if ($1 ~ /^-?(auth|account|password|session)$/) module_start = 3
else if ($2 ~ /^-?(auth|account|password|session)$/) module_start = 4
if (module_start) {
for (i = module_start; i <= NF; i++) {
if (substr($i, 1, 1) == "#") break
if ($i ~ /^[A-Za-z0-9._+\/-]+[.]so$/) {
module = $i
sub(/^.*\//, "", module)
if (tolower(module) !~ /^pam_/) print "M:" NR ":" $i
break
}
}
}
}
' "$pamd_config" 2>/dev/null)
[ -n "$pamd_helpers" ] || continue
while IFS= read -r pamd_helper; do
case "$pamd_helper" in
M:*)
if [ "$pamd_module_hits" -ge 16 ]; then
pamd_module_limited=1
continue
fi
pamd_line=${pamd_helper#M:}
pamd_module=${pamd_line#*:}
pamd_line=${pamd_line%%:*}
pamd_module_hits=$((pamd_module_hits + 1))
printf 'PAM nonstandard module review candidate: %s:%s -> %s (confirm installed module, service reachability, and control flow)\n' "$pamd_config" "$pamd_line" "$pamd_module"
continue
;;
E:*) pamd_helper=${pamd_helper#E:} ;;
*) continue ;;
esac
[ "$pamd_hits" -ge 24 ] && continue
pamd_target="${ROOT_FOLDER%/}${pamd_helper}"
[ -f "$pamd_target" ] && [ ! -L "$pamd_target" ] && [ -x "$pamd_target" ] && [ -w "$pamd_target" ] || continue
pamd_hits=$((pamd_hits + 1))
printf 'PAM exec writable helper candidate: %s -> %s (confirm PAM event, effective UID, parent paths, and ACLs)\n' "$pamd_config" "$pamd_target"
ls -ld "$pamd_target" 2>/dev/null
if command -v lsattr >/dev/null 2>&1; then lsattr -d "$pamd_target" 2>/dev/null; fi
done <<EOF
$pamd_helpers
EOF
done
[ "$pamd_module_limited" -eq 1 ] && printf 'PAM nonstandard module review capped at 16 findings; more policy lines may need review.\n'
)
if ! [ "$SEARCH_IN_FOLDER" ]; then
checkPamAllowActiveCVE20256018
fi
@@ -9,10 +9,214 @@
# Functions Used: print_2title, print_3title
# Global Variables: $HOME, $HOMESEARCH, $ROOT_FOLDER, $SEARCH_IN_FOLDER, $TIMEOUT, $USER, $wgroups
# Initial Functions:
# Generated Global Variables: $certsb4_grep, $hostsallow, $hostsdenied, $sshconfig, $writable_agents, $agent_sockets, $privatekeyfilesetc, $privatekeyfileshome, $privatekeyfilesroot, $privatekeyfilesmnt,
# Generated Global Variables: $certsb4_grep, $hostsallow, $hostsdenied, $sshconfig, $writable_agents, $agent_sockets, $privatekeyfilesetc, $privatekeyfileshome, $privatekeyfilesroot, $privatekeyfilesmnt, $sc_number, $sc_depth, $sc_pub, $ssh_ca_files, $sc_file, $sc_line, $sc_meta, $sc_candidate, $ssh_ca_root_login_seen, $sc_mapped, $sc_key, $ssh_ca_context, $sc_header, $sc_value, $sc_pattern, $ssh_ca_principals_seen, $sc_keyword, $sc_config, $sc_output, $sc_root, $sc_count, $sc_size, $mux_home, $mux_path, $mux_count
# Fat linpeas: 0
# Small linpeas: 1
# A multiplexed SSH master can leave a Unix socket below the current home.
# Inspect only .ssh and one direct child level; do not connect to candidates.
ssh_control_socket_candidates() {
mux_home=$1
mux_count=0
[ "$SEARCH_IN_FOLDER" ] && return 0
[ -n "$mux_home" ] && [ -d "$mux_home/.ssh" ] || return 0
for mux_path in "$mux_home"/.ssh/* "$mux_home"/.ssh/*/*; do
[ -S "$mux_path" ] && [ ! -L "$mux_path" ] || continue
if [ "$mux_count" -eq 0 ]; then
print_3title "Unix sockets under current SSH home (ControlMaster candidates):" "T1021.004"
fi
LC_ALL=C ls -ld "$mux_path" 2>/dev/null
mux_count=$((mux_count + 1))
if [ "$mux_count" -ge 20 ]; then
printf '%s\n' ' [*] Output capped at 20 sockets; others may be unseen.'
break
fi
done
if [ "$mux_count" -gt 0 ]; then
printf '%s\n' ' [?] Path, owner and mode only. Config, access and live SSH master remain unverified; an agent socket is different.'
echo ""
fi
}
# SSH user CA correlation. This is a bounded config read, not sshd's effective
# configuration: Match criteria, command-line options and unvisited Includes may
# change the values for a particular connection.
ssh_ca_scan_config() {
local sc_file="$1" sc_depth="$2" sc_line sc_number=0 sc_keyword sc_value
local sc_pattern sc_mapped sc_candidate sc_pub sc_key sc_header sc_meta
[ "$sc_depth" -le 3 ] && [ "$ssh_ca_files" -lt 32 ] || return 0
[ -f "$sc_file" ] && [ -r "$sc_file" ] || return 0
ssh_ca_files=$((ssh_ca_files + 1))
while IFS= read -r sc_line || [ -n "$sc_line" ]; do
sc_number=$((sc_number + 1))
[ "$sc_number" -le 256 ] || break
# Only simple, unquoted directives are interpreted. Never evaluate config text.
sc_line=${sc_line%%#*}
case "$sc_line" in *\"*|*\'* ) continue ;; esac
set -f
# Intentional whitespace tokenization with globbing disabled.
# shellcheck disable=SC2086
set -- $sc_line
set +f
[ "$#" -gt 0 ] || continue
sc_keyword=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
shift
case "$sc_keyword" in
match)
ssh_ca_context='Match (conditional)'
;;
include)
[ "$#" -le 8 ] || continue
for sc_pattern do
# Restrict to plain paths and a final-component '*' glob. This avoids
# eval, broad directory walks, and ambiguous quoted/token paths.
case "$sc_pattern" in
*[!A-Za-z0-9_./*-]*|*/*/*/*/*/*/*/*|*'**'*) continue ;;
esac
case "$sc_pattern" in
/*) ;;
*) sc_pattern="/etc/ssh/$sc_pattern" ;;
esac
case "${sc_pattern%/*}" in *'*'*) continue ;; esac
sc_mapped="$sc_pattern"
[ "$SEARCH_IN_FOLDER" ] && sc_mapped="${ROOT_FOLDER%/}$sc_pattern"
for sc_candidate in $sc_mapped; do
[ "$ssh_ca_files" -lt 32 ] || break
case "$sc_candidate" in *[!A-Za-z0-9_./-]* ) continue ;; esac
ssh_ca_scan_config "$sc_candidate" $((sc_depth + 1))
done
done
;;
trustedusercakeys|authorizedprincipalsfile|authorizedprincipalscommand|permitrootlogin)
[ "$#" -gt 0 ] || continue
sc_value="$1"
case "$sc_keyword" in
trustedusercakeys)
[ "$#" -eq 1 ] || continue
case "$sc_value" in /*) ;; *) continue ;; esac
case "$sc_value" in *[!A-Za-z0-9_./-]* ) continue ;; esac
printf 'TrustedUserCAKeys %s (%s:%s; %s)\n' "$sc_value" "$sc_file" "$sc_number" "$ssh_ca_context"
case "$sc_value" in *.pub) ;; *) continue ;; esac
sc_pub="$sc_value"
[ "$SEARCH_IN_FOLDER" ] && sc_pub="${ROOT_FOLDER%/}$sc_value"
[ -f "$sc_pub" ] && [ -r "$sc_pub" ] || continue
sc_key=${sc_pub%.pub}
[ -f "$sc_key" ] && [ -r "$sc_key" ] && [ ! -L "$sc_key" ] || continue
sc_header=$(dd if="$sc_key" bs=64 count=1 2>/dev/null) || continue
case "$sc_header" in
'-----BEGIN OPENSSH PRIVATE KEY-----'*|'-----BEGIN RSA PRIVATE KEY-----'*|\
'-----BEGIN EC PRIVATE KEY-----'*|'-----BEGIN DSA PRIVATE KEY-----'*|\
'-----BEGIN PRIVATE KEY-----'*|'-----BEGIN ENCRYPTED PRIVATE KEY-----'*) ;;
*) continue ;;
esac
sc_meta=$(stat -c '%U %a' "$sc_key" 2>/dev/null) ||
sc_meta=$(stat -f '%Su %Lp' "$sc_key" 2>/dev/null) || continue
printf 'Readable CA private-key sibling: %s (owner mode: %s; header only checked)\n' "$sc_key" "$sc_meta"
;;
authorizedprincipalsfile|authorizedprincipalscommand)
ssh_ca_principals_seen=1
printf '%s %s (%s:%s; %s)\n' "$sc_keyword" "$sc_value" "$sc_file" "$sc_number" "$ssh_ca_context"
;;
permitrootlogin)
ssh_ca_root_login_seen=1
printf 'PermitRootLogin %s (%s:%s; %s)\n' "$sc_value" "$sc_file" "$sc_number" "$ssh_ca_context"
;;
esac
;;
esac
done < "$sc_file"
}
ssh_ca_trust_correlation() {
local sc_config='/etc/ssh/sshd_config' sc_output
[ "$SEARCH_IN_FOLDER" ] && sc_config="${ROOT_FOLDER%/}$sc_config"
[ -f "$sc_config" ] && [ -r "$sc_config" ] || return 0
sc_output=$(
ssh_ca_files=0
ssh_ca_context='before Match in inspected files'
ssh_ca_principals_seen=0
ssh_ca_root_login_seen=0
ssh_ca_scan_config "$sc_config" 0
[ "$ssh_ca_principals_seen" -eq 0 ] &&
printf 'No AuthorizedPrincipalsFile/Command observed in inspected files.\n'
[ "$ssh_ca_root_login_seen" -eq 0 ] &&
printf 'No PermitRootLogin observed in inspected files.\n'
)
case "$sc_output" in *'TrustedUserCAKeys '*) ;; *) return 0 ;; esac
print_3title 'SSH user CA trust (partial config inspection)' 'T1552.004,T1021.004'
printf '%s\n' "$sc_output"
printf '%s\n' 'If both principal sources are absent from the effective config, certificate principals must match account names.'
printf '%s\n' 'Earlier directives, Match, unvisited Includes, and runtime options may change these values; key headers do not prove a CA match or root login.'
echo ''
}
# Show only a bounded portion of the readable server config. Match headings
# retain the scope of subsequent directives; this text is not sshd's policy.
ssh_forwarding_config_review() {
print_3title 'SSH server directives (review effective SSH policy)' 'T1021.004'
if [ ! -f "$1" ] || [ ! -r "$1" ]; then
printf '%s\n' 'SSH server config unreadable; forwarding policy unknown.'
return 0
fi
awk '
NR > 512 { exit }
{
line = substr($0, 1, 1024)
sub(/[[:space:]]*#.*/, "", line)
sub(/^[[:space:]]*/, "", line)
sub(/[[:space:]]*$/, "", line)
if (line == "") next
split(line, fields, /[[:space:]]+/)
key = tolower(fields[1])
if (key == "match") {
context = line
printf "%d: %s\n", NR, line
} else if (key == "include" || key == "permitrootlogin" ||
key == "challengeresponseauthentication" ||
key == "passwordauthentication" || key == "usepam" ||
key == "port" || key == "permitemptypasswords" ||
key == "pubkeyauthentication" || key == "listenaddress" ||
key == "forwardagent" || key == "allowagentforwarding" ||
key == "authorizedkeysfile" || key == "authorizedkeyscommand" ||
key == "authorizedkeyscommanduser" || key == "allowtcpforwarding" ||
key == "disableforwarding" || key == "permitopen" ||
key == "forcecommand" || key == "chrootdirectory") {
printf "%d [%s]: %s\n", NR, context, line
}
}
BEGIN { context = "global" }
' "$1"
printf '%s\n' 'Text review only (first 512 lines, 1024 characters per line). Include targets, Match applicability, command-line overrides, and authorized-key restrictions are unknown here; key-helper directives do not prove execution or vulnerability.'
echo ''
}
# Without timeout, inspect only direct files in /root. Cap the number and size
# before reading a header; report metadata, never key material.
ssh_root_key_headers() {
local sc_root="$1" sc_file sc_count=0 sc_size sc_header sc_meta
[ "$(id -u 2>/dev/null)" = 0 ] || return 0
[ -d "$sc_root" ] && [ -r "$sc_root" ] && [ -x "$sc_root" ] || return 0
for sc_file in "$sc_root"/* "$sc_root"/.[!.]* "$sc_root"/..?*; do
[ -f "$sc_file" ] && [ -r "$sc_file" ] && [ ! -L "$sc_file" ] || continue
sc_count=$((sc_count + 1))
[ "$sc_count" -le 64 ] || break
sc_size=$(stat -c '%s' "$sc_file" 2>/dev/null) ||
sc_size=$(stat -f '%z' "$sc_file" 2>/dev/null) || continue
case "$sc_size" in ''|*[!0-9]*) continue ;; esac
[ "$sc_size" -le 1048576 ] || continue
sc_header=$(dd if="$sc_file" bs=64 count=1 2>/dev/null) || continue
case "$sc_header" in
'-----BEGIN OPENSSH PRIVATE KEY-----'*|'-----BEGIN RSA PRIVATE KEY-----'*|\
'-----BEGIN EC PRIVATE KEY-----'*|'-----BEGIN DSA PRIVATE KEY-----'*|\
'-----BEGIN PRIVATE KEY-----'*|'-----BEGIN ENCRYPTED PRIVATE KEY-----'*) ;;
*) continue ;;
esac
sc_meta=$(stat -c '%U %a' "$sc_file" 2>/dev/null) ||
sc_meta=$(stat -f '%Su %Lp' "$sc_file" 2>/dev/null) || continue
printf '%s (owner mode: %s; header only checked)\n' "$sc_file" "$sc_meta"
done
}
print_2title "Searching ssl/ssh files" "T1552.004,T1021.004"
if [ "$PSTORAGE_CERTSB4" ]; then certsb4_grep=$(grep -L "\"\|'\|(" $PSTORAGE_CERTSB4 2>/dev/null); fi
@@ -36,7 +240,12 @@ fi
peass{SSH}
grep "PermitRootLogin \|ChallengeResponseAuthentication \|PasswordAuthentication \|UsePAM \|Port\|PermitEmptyPasswords\|PubkeyAuthentication\|ListenAddress\|ForwardAgent\|AllowAgentForwarding\|AuthorizedKeysFile" /etc/ssh/sshd_config 2>/dev/null | grep -v "#" | sed -${E} "s,PermitRootLogin.*es|PermitEmptyPasswords.*es|ChallengeResponseAuthentication.*es|FordwardAgent.*es,${SED_RED},"
if [ "$SEARCH_IN_FOLDER" ]; then
ssh_forwarding_config_review "${ROOT_FOLDER%/}/etc/ssh/sshd_config"
else
ssh_forwarding_config_review '/etc/ssh/sshd_config'
fi
ssh_ca_trust_correlation
if ! [ "$SEARCH_IN_FOLDER" ]; then
if [ "$TIMEOUT" ]; then
@@ -47,6 +256,7 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
else
privatekeyfilesetc=$(grep -rl '\-\-\-\-\-BEGIN .* PRIVATE KEY\-\-\-\-\-' /etc 2>/dev/null) #If there is tons of files linpeas gets frozen here without a timeout
privatekeyfileshome=$(grep -rl '\-\-\-\-\-BEGIN .* PRIVATE KEY\-\-\-\-\-' $HOME/.ssh 2>/dev/null)
privatekeyfilesroot=$(ssh_root_key_headers /root)
fi
else
# If $SEARCH_IN_FOLDER lets just search for private keys in the whole firmware
@@ -83,6 +293,7 @@ if [ "$agent_sockets" ]; then
printf "%s\n" "$agent_sockets" | sed -${E} "s,.*,${SED_RED},"
echo ""
fi
ssh_control_socket_candidates "$HOME"
if ssh-add -l 2>/dev/null | grep -qv 'no identities'; then
print_3title "Listing SSH Agents" "T1552.004,T1021.004"
ssh-add -l
@@ -7,24 +7,84 @@
# Version: 1.0
# Mitre: T1563
# Functions Used: print_2title, print_info
# Global Variables: $DEBUG, $SEARCH_IN_FOLDER, $wgroups
# Global Variables: $DEBUG, $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables: $tmuxdefsess, $tmuxnondefsess, $tmuxsess2
# Generated Global Variables: $tmuxdefsess, $tmuxnondefsess, $tmuxsess2, $tmux_scan_output, $tmux_socket_candidates, $tmux_scan_partial, $tmux_root, $tmux_root_seen, $tmux_dir, $tmux_socket, $tmux_dir_count, $tmux_entry_count, $tmux_partial, $tmux_uid, $tmux_groups, $tmux_meta, $tmux_owner_uid, $tmux_owner_gid, $tmux_rest, $tmux_mode, $tmux_mode_writable, $TMUX_TMPDIR
# Fat linpeas: 0
# Small linpeas: 1
# The standard socket lives directly below tmux-UID. Inspect only that shape,
# never recurse through /tmp or attach to a session. The second root is used
# only when TMUX_TMPDIR names a real absolute directory.
tmux_socket_review() (
tmux_uid=$(id -u 2>/dev/null) || exit 0
tmux_groups=$(id -G 2>/dev/null) || exit 0
tmux_dir_count=0
tmux_entry_count=0
tmux_partial=
tmux_root_seen=0
for tmux_root in "$1" "$2"; do
[ -n "$tmux_root" ] || continue
case "$tmux_root" in /*) ;; *) continue ;; esac
[ -d "$tmux_root" ] && [ ! -L "$tmux_root" ] || continue
if [ "$tmux_root_seen" -gt 0 ] && [ "$tmux_root" = "$1" ]; then continue; fi
tmux_root_seen=$((tmux_root_seen + 1))
for tmux_dir in "$tmux_root"/tmux-*; do
[ -d "$tmux_dir" ] && [ ! -L "$tmux_dir" ] || continue
if [ "$tmux_dir_count" -ge 32 ]; then tmux_partial=1; break 2; fi
tmux_dir_count=$((tmux_dir_count + 1))
printf 'DIR %s\n' "$tmux_dir"
for tmux_socket in "$tmux_dir"/*; do
[ -e "$tmux_socket" ] || [ -L "$tmux_socket" ] || continue
if [ "$tmux_entry_count" -ge 64 ]; then tmux_partial=1; break 3; fi
tmux_entry_count=$((tmux_entry_count + 1))
[ ! -L "$tmux_socket" ] && [ -S "$tmux_socket" ] || continue
tmux_meta=$(stat -c '%u:%g:%A' "$tmux_socket" 2>/dev/null) ||
tmux_meta=$(stat -f '%u:%g:%Sp' "$tmux_socket" 2>/dev/null) || continue
case "$tmux_meta" in *:*:*) ;; *) continue ;; esac
tmux_owner_uid=${tmux_meta%%:*}
tmux_rest=${tmux_meta#*:}
tmux_owner_gid=${tmux_rest%%:*}
tmux_mode=${tmux_rest#*:}
[ "$tmux_owner_uid" != "$tmux_uid" ] || continue
tmux_mode_writable=
case "$tmux_mode" in ????????w?) tmux_mode_writable=1 ;; esac
case "$tmux_mode" in
?????w????)
case " $tmux_groups " in *" $tmux_owner_gid "*) tmux_mode_writable=1 ;; esac
;;
esac
[ "$tmux_mode_writable" ] && [ -w "$tmux_socket" ] || continue
printf 'SOCKET %s\n' "$tmux_socket"
done
done
done
[ "$tmux_partial" ] && printf 'PARTIAL\n'
return 0
)
tmuxdefsess=$(tmux ls 2>/dev/null)
tmuxnondefsess=$(ps auxwww | grep "tmux " | grep -v grep)
tmuxsess2=$(find /tmp -type d -path "/tmp/tmux-*" 2>/dev/null)
if ([ "$tmuxdefsess" ] || [ "$tmuxnondefsess" ] || [ "$tmuxsess2" ] || [ "$DEBUG" ]) && ! [ "$SEARCH_IN_FOLDER" ]; then
TMUX_TMPDIR=${TMUX_TMPDIR:-}
tmux_scan_output=
if ! [ "$SEARCH_IN_FOLDER" ]; then
tmux_scan_output=$(tmux_socket_review /tmp "$TMUX_TMPDIR")
fi
tmuxsess2=$(printf '%s\n' "$tmux_scan_output" | sed -n 's/^DIR //p')
tmux_socket_candidates=$(printf '%s\n' "$tmux_scan_output" | sed -n 's/^SOCKET //p')
tmux_scan_partial=$(printf '%s\n' "$tmux_scan_output" | sed -n '/^PARTIAL$/p')
if ([ "$tmuxdefsess" ] || [ "$tmuxnondefsess" ] || [ "$tmuxsess2" ] || [ "$tmux_scan_partial" ] || [ "$DEBUG" ]) && ! [ "$SEARCH_IN_FOLDER" ]; then
print_2title "Searching tmux sessions"$N
print_info "https://book.hacktricks.wiki/en/linux-hardening/linux-basics/linux-privilege-escalation/index.html#open-shell-sessions"
tmux -V
printf "$tmuxdefsess\n$tmuxnondefsess\n$tmuxsess2" | sed -${E} "s,.*,${SED_RED}," | sed -${E} "s,no server running on.*,${C}[32m&${C}[0m,"
printf '%s\n%s\n%s\n' "$tmuxdefsess" "$tmuxnondefsess" "$tmuxsess2" | sed -${E} "s,.*,${SED_RED}," | sed -${E} "s,no server running on.*,${C}[32m&${C}[0m,"
find /tmp -type s -path "/tmp/tmux*" -not -user $USER '(' '(' -perm -o=w ')' -or '(' -perm -g=w -and '(' $wgroups ')' ')' ')' 2>/dev/null | while read f; do
echo "Other user tmux socket is writable: $f" | sed "s,$f,${SED_RED_YELLOW},"
done
if [ "$tmux_socket_candidates" ]; then
printf '%s\n' "$tmux_socket_candidates" | sed "s,.*,Other user tmux socket is writable: ${SED_RED_YELLOW}&,"
fi
if [ "$tmux_scan_partial" ]; then
echo "Tmux socket review partial: stopped after 32 directories or 64 direct entries."
fi
echo ""
fi
fi
@@ -1,25 +1,32 @@
# Title: Software Information - Vault-ssh
# ID: SI_Vault_ssh
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Searching Vault-ssh files
# Last Update: 09-10-2026
# Description: Report cached Vault CLI token paths independently of SSH helper configuration
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1552.004
# Functions Used: print_2title
# Global Variables: $DEBUG
# Global Variables: $DEBUG, $E, $SED_RED
# Cached storage inputs (builder): $PSTORAGE_VAULT_SSH_HELPER, $PSTORAGE_VAULT_SSH_TOKEN
# Initial Functions:
# Generated Global Variables:
# Fat linpeas: 0
# Small linpeas: 1
if [ "$PSTORAGE_VAULT_SSH_TOKEN" ]; then
print_2title "Vault CLI token file paths" "T1552.004"
printf '%s\n' "$PSTORAGE_VAULT_SSH_TOKEN" | awk 'NR <= 10 { print } END { if (NR > 10) print "Additional token paths omitted (partial inventory)" }' | sed -${E} "1,10s,.*,${SED_RED},"
echo "Paths only; token policy, SSH role, and login reachability require separate review."
echo ""
fi
if [ "$PSTORAGE_VAULT_SSH_HELPER" ] || [ "$DEBUG" ]; then
print_2title "Searching Vault-ssh files" "T1552.004"
printf "$PSTORAGE_VAULT_SSH_HELPER\n"
printf "%s\n" "$PSTORAGE_VAULT_SSH_HELPER" | while read f; do cat "$f" 2>/dev/null; vault-ssh-helper -verify-only -config "$f" 2>/dev/null; done
echo ""
vault secrets list 2>/dev/null
printf "%s\n" "$PSTORAGE_VAULT_SSH_TOKEN" | sed -${E} "s,.*,${SED_RED}," 2>/dev/null
fi
echo ""
echo ""
@@ -1,10 +1,10 @@
# Title: Interesting Permissions Files - Writable files by ownership or all
# ID: IP_Writable_files_owner_all
# Author: Carlos Polop
# Last Update: 22-08-2023
# Last Update: 09-10-2026
# Description: Interesting writable files owned by me or writable by everyone (not in Home)
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1574.009,T1574.010
# Functions Used: print_2title, print_info
# Global Variables: $HOME, $IAMROOT, $ITALIC, $notExtensions, $ROOT_FOLDER, $USER, $writeVB, $writeB
@@ -18,7 +18,16 @@ if ! [ "$IAMROOT" ]; then
print_2title "Interesting writable files owned by me or writable by everyone (not in Home) (max 200)" "T1574.009,T1574.010"
print_info "https://book.hacktricks.wiki/en/linux-hardening/linux-basics/linux-privilege-escalation/index.html#writable-files"
#In the next file, you need to specify type "d" and "f" to avoid fake link files apparently writable by all
obmowbe=$(find $ROOT_FOLDER '(' -type f -or -type d ')' '(' '(' -user $USER ')' -or '(' -perm -o=w ')' ')' ! -path "/proc/*" ! -path "/sys/*" ! -path "/dev/*" ! -path "/snap/*" ! -path "$HOME/*" 2>/dev/null | grep -Ev "$notExtensions" | sort | uniq | awk -F/ '{line_init=$0; if (!cont){ cont=0 }; $NF=""; act=$0; if (act == pre){(cont += 1)} else {cont=0}; if (cont < 5){ print line_init; } if (cont == "5"){print "#)You_can_write_even_more_files_inside_last_directory\n"}; pre=act }' | head -n 200)
# These trees were already excluded from results. Prune them before walking
# their descendants so a large home or virtual filesystem cannot dominate
# this scan.
obmowbe=$(find $ROOT_FOLDER \
'(' -path /proc -o -path /sys -o -path /dev -o -path /snap ')' -prune \
'(' -type f -o -type d ')' '(' -user "$USER" -o -perm -o=w ')' -print -o \
'(' -path "$HOME" -prune '(' -type f -o -type d ')' '(' -user "$USER" -o -perm -o=w ')' -print ')' -o \
'(' '(' -type f -o -type d ')' '(' -user "$USER" -o -perm -o=w ')' \
! -path "/proc/*" ! -path "/sys/*" ! -path "/dev/*" ! -path "/snap/*" ! -path "$HOME/*" -print ')' \
2>/dev/null | grep -Ev "$notExtensions" | sort | uniq | awk -F/ '{line_init=$0; if (!cont){ cont=0 }; $NF=""; act=$0; if (act == pre){(cont += 1)} else {cont=0}; if (cont < 5){ print line_init; } if (cont == "5"){print "#)You_can_write_even_more_files_inside_last_directory\n"}; pre=act }' | head -n 200)
printf "%s\n" "$obmowbe" | while read l; do
if echo "$l" | grep -q "You_can_write_even_more_files_inside_last_directory"; then printf $ITALIC"$l\n"$NC;
elif echo "$l" | grep -qE "$writeVB"; then
@@ -28,4 +37,4 @@ if ! [ "$IAMROOT" ]; then
fi
done
echo ""
fi
fi
@@ -1,10 +1,10 @@
# Title: Interesting Permissions Files - Interesting writable files by group
# ID: IP_Writable_files_group
# Author: Carlos Polop
# Last Update: 22-08-2023
# Last Update: 09-10-2026
# Description: Interesting GROUP writable files (not in Home)
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1574.009,T1574.010
# Functions Used: print_2title, print_info
# Global Variables: $DEBUG, $HOME, $IAMROOT, $notExtensions, $ROOT_FOLDER, $writeVB, $writeB
@@ -18,7 +18,13 @@ if ! [ "$IAMROOT" ]; then
print_2title "Interesting GROUP writable files (not in Home) (max 200)" "T1574.009,T1574.010"
print_info "https://book.hacktricks.wiki/en/linux-hardening/linux-basics/linux-privilege-escalation/index.html#writable-files"
for g in $(groups); do
iwfbg=$(find $ROOT_FOLDER '(' -type f -or -type d ')' -group $g -perm -g=w ! -path "/proc/*" ! -path "/sys/*" ! -path "$HOME/*" 2>/dev/null | grep -Ev "$notExtensions" | awk -F/ '{line_init=$0; if (!cont){ cont=0 }; $NF=""; act=$0; if (act == pre){(cont += 1)} else {cont=0}; if (cont < 5){ print line_init; } if (cont == "5"){print "#)You_can_write_even_more_files_inside_last_directory\n"}; pre=act }' | head -n 200)
iwfbg=$(find $ROOT_FOLDER \
'(' -path /proc -o -path /sys ')' -prune \
'(' -type f -o -type d ')' -group "$g" -perm -g=w -print -o \
'(' -path "$HOME" -prune '(' -type f -o -type d ')' -group "$g" -perm -g=w -print ')' -o \
'(' '(' -type f -o -type d ')' -group "$g" -perm -g=w \
! -path "/proc/*" ! -path "/sys/*" ! -path "$HOME/*" -print ')' \
2>/dev/null | grep -Ev "$notExtensions" | awk -F/ '{line_init=$0; if (!cont){ cont=0 }; $NF=""; act=$0; if (act == pre){(cont += 1)} else {cont=0}; if (cont < 5){ print line_init; } if (cont == "5"){print "#)You_can_write_even_more_files_inside_last_directory\n"}; pre=act }' | head -n 200)
if [ "$iwfbg" ] || [ "$DEBUG" ]; then
printf " Group $GREEN$g:\n$NC";
printf "%s\n" "$iwfbg" | while read l; do
@@ -32,4 +38,4 @@ if ! [ "$IAMROOT" ]; then
fi
done
echo ""
fi
fi
@@ -1,15 +1,15 @@
# Title: Interesting Permissions Files - SUID
# ID: IP_SUID
# Author: Carlos Polop, HT Bot
# Last Update: 30-09-2026
# Description: SUID - Check easy privesc, exploits, write perms, and risky file placement
# Last Update: 09-10-2026
# Description: SUID - Check easy privesc, exploits, write perms, risky file placement, and identity-helper candidates
# License: GNU GPL
# Version: 1.2
# Version: 1.5
# Mitre: T1548.001
# Functions Used: check_privileged_file_location, echo_not_found, print_2title, print_info
# Global Variables: $IAMROOT, $LDD, $ROOT_FOLDER, $READELF, $sidB, $sidG1, $sidG2, $sidG3, $sidG4, $sidVB, $sidVB2, $STRACE, $STRINGS, $TIMEOUT, $Wfolders, $cfuncs
# Initial Functions:
# Generated Global Variables: $suids_files, $sfile, $sname, $sowner, $sline_first, $sline, $OLD_LD_LIBRARY_PATH, $LD_LIBRARY_PATH
# Generated Global Variables: $suids_files, $sfile, $sname, $sowner, $sline_first, $sline, $OLD_LD_LIBRARY_PATH, $LD_LIBRARY_PATH, $ndsudo_uid, $ndsudo_nnp, $ndsudo_mount_options, $ndsudo_uncertainty, $pinns_uid, $pinns_nnp, $pinns_mount_options, $pinns_uncertainty, $jjs_nnp, $jjs_mount_options, $gosu_nnp, $gosu_mount_options
# Fat linpeas: 0
# Small linpeas: 1
@@ -32,12 +32,84 @@ printf "%s\n" "$suids_files" | while IFS= read -r sfile; do
# Keep the path returned by find: parsing it from ls output breaks on whitespace.
sname="$sfile"
sowner="$(echo "$s" | awk '{print $3}')"
if [ "${sname##*/}" = "pinns" ] && [ "$(uname -s 2>/dev/null)" = "Linux" ]; then
# The privileged CRI-O helper is a lead only; never invoke it to test.
pinns_uid=$(stat -c '%u' "$sname" 2>/dev/null || stat -f '%u' "$sname" 2>/dev/null)
if [ "$IAMROOT" ] || [ "$pinns_uid" != "0" ] || ! [ -u "$sname" ] || ! [ -x "$sname" ]; then
echo " pinns: no local privilege-escalation candidate for this user (requires root ownership, SUID, and execute access)."
else
pinns_nnp=$(awk '/^NoNewPrivs:/ {print $2; exit}' /proc/self/status 2>/dev/null)
pinns_mount_options=$(findmnt -no OPTIONS -T "$sname" 2>/dev/null | head -n 1)
if [ "$pinns_nnp" = "1" ]; then
echo " pinns: SUID transition blocked by NoNewPrivs for this process tree."
elif echo ",$pinns_mount_options," | grep -q ',nosuid,'; then
echo " pinns: SUID transition blocked by nosuid mount options."
else
pinns_uncertainty=""
[ "$pinns_nnp" = "0" ] || pinns_uncertainty="NoNewPrivs unknown; "
[ -n "$pinns_mount_options" ] || pinns_uncertainty="${pinns_uncertainty}mount SUID policy unknown; "
echo " pinns review candidate (CVE-2022-0811): root-owned SUID executable by current user; NoNewPrivs=${pinns_nnp:-unknown}; mount options=${pinns_mount_options:-unknown}."
echo " ${pinns_uncertainty}Installed helper build and vendor fixes unknown; verify the exact binary and host sysctl policy. No helper execution performed."
fi
fi
fi
if [ "$sname" = "." ] || [ "$sname" = ".." ]; then
true #Don't do nothing
elif echo "$sname" | grep -qE '/netdata/plugins[.]d/ndsudo$'; then
# Keep this branch passive: even the generic non-FAST SUID probe must not run ndsudo.
echo "$s"
ndsudo_uid=$(stat -c '%u' "$sname" 2>/dev/null || stat -f '%u' "$sname" 2>/dev/null)
if [ "$ndsudo_uid" != "0" ] || ! [ -u "$sname" ] || ! [ -x "$sname" ]; then
echo " ndsudo: no privilege-escalation candidate for this user (requires root ownership, SUID, and execute access)."
else
ndsudo_nnp=$(awk '/^NoNewPrivs:/ {print $2; exit}' /proc/self/status 2>/dev/null)
ndsudo_mount_options=$(findmnt -no OPTIONS -T "$sname" 2>/dev/null | head -n 1)
if [ "$ndsudo_nnp" = "1" ]; then
echo " ndsudo: SUID transition blocked by NoNewPrivs for this process tree."
elif echo ",$ndsudo_mount_options," | grep -q ',nosuid,'; then
echo " ndsudo: SUID transition blocked by nosuid mount options."
else
ndsudo_uncertainty=""
[ "$ndsudo_nnp" = "0" ] || ndsudo_uncertainty="NoNewPrivs unknown; "
[ -n "$ndsudo_mount_options" ] || ndsudo_uncertainty="${ndsudo_uncertainty}mount SUID policy unknown; "
echo " ndsudo candidate (CVE-2024-32019): root-owned SUID executable by current user; NoNewPrivs=${ndsudo_nnp:-unknown}; mount options=${ndsudo_mount_options:-unknown}."
echo " ${ndsudo_uncertainty}Installed build and vendor backports unknown; confirm affected version and PATH lookup manually. Vendor fixes: v1.45.3 and v1.45.0-169. No helper execution performed."
fi
fi
elif ! [ "$IAMROOT" ] && [ -O "$sname" ]; then
echo "You own the SUID file: $sname" | sed -${E} "s,.*,${SED_RED},"
elif ! [ "$IAMROOT" ] && [ -w "$sname" ]; then #If write permision, win found (no check exploits)
echo "You can write SUID file: $sname" | sed -${E} "s,.*,${SED_RED_YELLOW},"
elif [ "${sname##*/}" = jjs ] && [ ! "$IAMROOT" ] &&
[ -u "$sname" ] && [ -x "$sname" ] &&
[ "$(stat -c '%u' "$sname" 2>/dev/null || stat -f '%u' "$sname" 2>/dev/null)" = 0 ]; then
# Nashorn's file APIs can retain the SUID identity even if a spawned shell
# drops it. Check only the exact executable; never run the JVM here.
echo "$s"
jjs_nnp=$(awk '/^NoNewPrivs:/ {print $2; exit}' /proc/self/status 2>/dev/null)
jjs_mount_options=$(findmnt -no OPTIONS -T "$sname" 2>/dev/null | head -n 1)
if [ "$jjs_nnp" = 1 ]; then
echo " jjs: SUID transition blocked by NoNewPrivs for this process tree."
elif echo ",$jjs_mount_options," | grep -q ',nosuid,'; then
echo " jjs: SUID transition blocked by nosuid mount options."
else
echo " jjs SUID file-access review candidate: root-owned and caller-executable; verify effective UID, mount/NoNewPrivs policy, and installed JVM behavior without running it."
fi
elif [ "${sname##*/}" = gosu ] && [ ! "$IAMROOT" ] &&
[ -u "$sname" ] && [ -x "$sname" ] &&
[ "$(stat -c '%u' "$sname" 2>/dev/null || stat -f '%u' "$sname" 2>/dev/null)" = 0 ]; then
# A SUID identity-switching helper is a lead; never invoke it to test.
echo "$s"
gosu_nnp=$(awk '/^NoNewPrivs:/ {print $2; exit}' /proc/self/status 2>/dev/null)
gosu_mount_options=$(findmnt -no OPTIONS -T "$sname" 2>/dev/null | head -n 1)
if [ "$gosu_nnp" = 1 ]; then
echo " gosu: SUID transition blocked by NoNewPrivs for this process tree."
elif echo ",$gosu_mount_options," | grep -q ',nosuid,'; then
echo " gosu: SUID transition blocked by nosuid mount options."
else
echo " gosu SUID review candidate: root-owned and caller-executable; NoNewPrivs=${gosu_nnp:-unknown}; mount options=${gosu_mount_options:-unknown}."
echo " Verify effective identity, user-namespace mapping, and installed helper behavior; this does not establish host-root access. No helper execution performed."
fi
else
c="a"
for b in $sidB; do
@@ -2,14 +2,14 @@
# ID: IP_Capabilities
# Author: Carlos Polop
# Last Update: 14-08-2026
# Description: Capabilities, including set-capabilities snap-confine exposure to CVE-2026-8933
# Description: Capabilities, including snap-confine race prerequisites and CVE-2026-8933
# License: GNU GPL
# Version: 1.1
# Mitre: T1548.001,T1068
# Functions Used: checkSnapConfineCVE20268933, echo_not_found, print_2title, print_info, print_3title
# Global Variables: $capsB, $capsVB, $IAMROOT, $SEARCH_IN_FOLDER
# Functions Used: checkSnapConfineCVE20263888, checkSnapConfineCVE20268933, echo_not_found, print_2title, print_info, print_3title
# Global Variables: $capsB, $capsVB, $IAMROOT, $SEARCH_IN_FOLDER, $STRINGS
# Initial Functions:
# Generated Global Variables: $cap_name, $cap_value, $cap_line, $cap_status_file, $cap_default_sep, $cap_sep, $cap_color, $capVB, $capname, $capbins, $capsVB_vuln, $proc_status, $proc_pid, $proc_name, $proc_uid, $user_name, $proc_inh, $proc_prm, $proc_eff, $proc_bnd, $proc_amb, $proc_inh_dec, $proc_prm_dec, $proc_eff_dec, $proc_bnd_dec, $proc_amb_dec
# Generated Global Variables: $cap_name, $cap_value, $cap_line, $cap_status_file, $cap_default_sep, $cap_sep, $cap_color, $capVB, $capname, $capbins, $capsVB_vuln, $proc_status, $proc_pid, $proc_name, $proc_uid, $user_name, $proc_inh, $proc_prm, $proc_eff, $proc_bnd, $proc_amb, $proc_inh_dec, $proc_prm_dec, $proc_eff_dec, $proc_bnd_dec, $proc_amb_dec, $binfmt_probe_count, $cap_inventory_count, $cap_path, $cap_rights, $cap_size
# Fat linpeas: 0
# Small linpeas: 1
@@ -47,7 +47,7 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
# so we redirect stderr to prevent error propagation
echo "$cap_name$cap_sep$(capsh --decode=0x"$cap_value" 2>/dev/null | sed -${E} "s,$capsB,${cap_color},")"
else
echo "$cap_name$cap_sep[Invalid capability format]"
echo "$cap_name${cap_sep}[Invalid capability format]"
fi
done
}
@@ -104,8 +104,22 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
fi
echo ""
echo "Files with capabilities (limited to 50):"
getcap -r / 2>/dev/null | head -n 50 | while read cb; do
binfmt_probe_count=0
cap_inventory_count=0
getcap -r / 2>/dev/null | head -n 51 | while read cb; do
cap_inventory_count=$((cap_inventory_count + 1))
if [ "$cap_inventory_count" -gt 50 ]; then
echo "Capability inventory is partial: more than 50 files have capabilities."
break
fi
capsVB_vuln=""
# Match the executable basename exactly; broad capsVB patterns also match wrappers.
case "$cb" in
*/gdb\ *cap_sys_ptrace*)
echo "$cb" | sed -${E} "s,.*,${SED_RED_YELLOW},"
continue
;;
esac
for capVB in $capsVB; do
capname="$(echo $capVB | cut -d ':' -f 1)"
@@ -120,10 +134,33 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
if ! [ "$capsVB_vuln" ]; then
echo "$cb" | sed -${E} "s,$capsB,${SED_RED},"
fi
# Reuse the capped getcap results. A helper that embeds the exact
# registration path is only a static lead; never run it or read its input.
cap_path=${cb%% *}
cap_rights=${cb#* }
cap_rights=${cap_rights#= }
if [ "$binfmt_probe_count" -lt 8 ] && [ ! "$IAMROOT" ] && [ "$STRINGS" ] &&
[ -f "$cap_path" ] && [ ! -L "$cap_path" ] &&
[ -r "$cap_path" ] && [ -x "$cap_path" ] &&
printf '%s\n' "$cap_rights" | grep -Eq '(^|[ ,])cap_dac_override(,cap_[a-z0-9_]+)*[+=][eip]*e[eip]*($|[[:space:]])'; then
cap_size=$(stat -c %s "$cap_path" 2>/dev/null || stat -f %z "$cap_path" 2>/dev/null)
case "$cap_size" in
''|*[!0-9]*) ;;
*)
if [ "$cap_size" -le 1048576 ]; then
binfmt_probe_count=$((binfmt_probe_count + 1))
if LC_ALL=C "$STRINGS" -a "$cap_path" 2>/dev/null | grep -Fxq '/proc/sys/fs/binfmt_misc/register'; then
printf ' binfmt_misc helper review candidate: %s (file cap_dac_override effective flag and embedded register path; effective helper privilege, caller control, writable proc mount, kernel policy, interpreter access, and SUID transition unverified).\n' "$cap_path"
fi
fi
;;
esac
fi
if ! [ "$IAMROOT" ] && [ -w "$(echo $cb | cut -d" " -f1)" ]; then
echo "$cb is writable" | sed -${E} "s,.*,${SED_RED},"
fi
done
echo ""
checkSnapConfineCVE20268933
checkSnapConfineCVE20263888
fi
@@ -1,15 +1,15 @@
# Title: Interesting Permissions Files - Misconfigured ld.so
# ID: IP_Misconfigured_ldso
# Author: Carlos Polop
# Last Update: 22-08-2023
# Last Update: 09-10-2026
# Description: Checking misconfigurations of ld.so
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1574.006
# Functions Used: print_2title, print_info
# Global Variables: $IAMROOT, $ITALIC, $SEARCH_IN_FOLDER, $USER, $Wfolders, $ldsoconfdG, $wgroups
# Initial Functions:
# Generated Global Variables: $ini_path, $fpath
# Generated Global Variables: $ini_path, $fpath, $ldso_writable_files
# Fat linpeas: 0
# Small linpeas: 1
@@ -43,8 +43,9 @@ if ! [ "$SEARCH_IN_FOLDER" ] && ! [ "$IAMROOT" ]; then
printf $GREEN$ITALIC"$fpath\n"$NC;
fi
if [ "$(find "$fpath" -type f '(' '(' -user "$USER" ')' -or '(' -perm -o=w ')' -or '(' -perm -g=w -and '(' $wgroups ')' ')' ')' 2>/dev/null)" ]; then
echo "You have write privileges over $(find "$fpath" -type f '(' '(' -user "$USER" ')' -or '(' -perm -o=w ')' -or '(' -perm -g=w -and '(' $wgroups ')' ')' ')' 2>/dev/null)" | sed -${E} "s,.*,${SED_RED_YELLOW},";
ldso_writable_files=$(find "$fpath" -type f '(' '(' -user "$USER" ')' -or '(' -perm -o=w ')' -or '(' -perm -g=w -and '(' $wgroups ')' ')' ')' 2>/dev/null) || :
if [ "$ldso_writable_files" ]; then
echo "You have write privileges over $ldso_writable_files" | sed -${E} "s,.*,${SED_RED_YELLOW},";
fi
for f in $ini_path; do
@@ -1,19 +1,38 @@
# Title: Interesting Permissions Files - /etc/profile.d/
# ID: IP_Files_etc_profile_d
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Files (scripts) in /etc/profile.d/
# Last Update: 09-10-2026
# Description: Global shell startup files and bounded virtual-environment source cues
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1546.004
# Functions Used: check_critial_root_path, echo_not_found, print_2title, print_info
# Global Variables: $IAMROOT, $MACPEAS, $profiledG, $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables:
# Generated Global Variables: $global_startup, $global_activation
# Fat linpeas: 0
# Small linpeas: 1
check_global_venv_activation() {
global_startup=$1
[ -f "$global_startup" ] && [ -r "$global_startup" ] || return 0
# Read only the first 64 KiB of each fixed startup file. Emit paths, never
# source lines: a startup file may contain credentials alongside commands.
dd if="$global_startup" bs=65536 count=1 2>/dev/null |
awk '($1 == "source" || $1 == ".") && NF >= 2 {
path = $2
if (path ~ /^".*"$/ || path ~ /^\047.*\047$/)
path = substr(path, 2, length(path) - 2)
if (path ~ /^\/[[:alnum:]_.+\/-]+\/bin\/activate$/ && ++hits <= 8)
print path
}' |
while IFS= read -r global_activation; do
printf ' %s sources %s (review who runs this startup file and whether the target is writable)\n' "$global_startup" "$global_activation"
ls -ld "$global_activation" 2>/dev/null || :
done
}
if ! [ "$SEARCH_IN_FOLDER" ]; then
print_2title "Files (scripts) in /etc/profile.d/" "T1546.004"
print_info "https://book.hacktricks.wiki/en/linux-hardening/linux-basics/shell-startup-aliases-and-history.html#review-startup-and-history-files"
@@ -21,6 +40,9 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
(ls -la /etc/profile.d/ 2>/dev/null | sed -${E} "s,$profiledG,${SED_GREEN},") || echo_not_found "/etc/profile.d/"
check_critial_root_path "/etc/profile"
check_critial_root_path "/etc/profile.d/"
for global_startup in /etc/profile /etc/bash.bashrc /etc/bashrc; do
check_global_venv_activation "$global_startup"
done
fi
echo ""
fi
fi
@@ -1,15 +1,15 @@
# Title: Interesting Files - Files inside /home
# ID: IF_Others_homes
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Files inside /home
# Last Update: 09-10-2026
# Description: Files inside home directories and HTTPie session paths
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1552.001
# Functions Used: echo_not_found, print_2title
# Global Variables: $HOMESEARCH, $SEARCH_IN_FOLDER, $USER
# Global Variables: $HOME, $HOMESEARCH, $SEARCH_IN_FOLDER, $USER
# Initial Functions:
# Generated Global Variables:
# Generated Global Variables: $httpie_home, $httpie_home_count, $httpie_config, $httpie_app, $httpie_sessions, $httpie_dir, $httpie_dir_count, $httpie_file, $httpie_file_count, $httpie_session_paths
# Fat linpeas: 0
# Small linpeas: 0
@@ -18,4 +18,48 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
print_2title "Files inside others home (limit 20)" "T1552.001"
(find $HOMESEARCH -type f 2>/dev/null | grep -v -i "/"$USER | head -n 20) || echo_not_found
echo ""
fi
# Fixed-depth globbing under at most 65 login homes; no JSON content reads.
httpie_list_session_paths() {
httpie_home_count=0
httpie_dir_count=0
httpie_file_count=0
while IFS= read -r httpie_home; do
[ "$httpie_home_count" -lt 65 ] || return
httpie_home_count=$((httpie_home_count + 1))
case "$httpie_home" in
/*) ;;
*) continue ;;
esac
case "$httpie_home/" in
*'/../'*|*'/./'*|*'//'*) continue ;;
esac
httpie_config="$httpie_home/.config"
httpie_app="$httpie_config/httpie"
httpie_sessions="$httpie_app/sessions"
[ -d "$httpie_home" ] && [ -x "$httpie_home" ] && [ ! -L "$httpie_home" ] || continue
[ -d "$httpie_config" ] && [ -x "$httpie_config" ] && [ ! -L "$httpie_config" ] || continue
[ -d "$httpie_app" ] && [ -x "$httpie_app" ] && [ ! -L "$httpie_app" ] || continue
[ -d "$httpie_sessions" ] && [ -x "$httpie_sessions" ] && [ ! -L "$httpie_sessions" ] || continue
for httpie_dir in "$httpie_sessions"/*; do
[ -d "$httpie_dir" ] && [ -x "$httpie_dir" ] && [ ! -L "$httpie_dir" ] || continue
[ "$httpie_dir_count" -lt 32 ] || return
httpie_dir_count=$((httpie_dir_count + 1))
for httpie_file in "$httpie_dir"/*.json; do
[ -f "$httpie_file" ] && [ -r "$httpie_file" ] && [ ! -L "$httpie_file" ] || continue
[ "$httpie_file_count" -lt 24 ] || return
printf '%s\n' "$httpie_file"
httpie_file_count=$((httpie_file_count + 1))
done
done
done
}
httpie_session_paths=$({ printf '%s\n' "$HOME"; awk -F: '$6 ~ /^\// && $7 !~ /(nologin|false)$/ && $6 != "/" && !seen[$6]++ { print $6; if (++n == 64) exit }' /etc/passwd 2>/dev/null; } | httpie_list_session_paths)
if [ -n "$httpie_session_paths" ]; then
print_2title "Readable HTTPie session files (paths only, max 24)" "T1552.001"
printf '%s\n' "$httpie_session_paths"
echo "Session files may contain auth or cookies; path discovery does not prove usable credentials."
echo ""
fi
fi
@@ -1,26 +1,107 @@
# Title: Interesting Files - Backup folders
# ID: IF_Backup_folders
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Backup folders
# Last Update: 09-10-2026
# Description: Backup folders, readable archives owned by other users, and redacted shell password assignments in immediate entries
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Mitre: T1552.001
# Functions Used: print_2title
# Global Variables: $DEBUG, $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables:
# Generated Global Variables: $entry, $backup_uid, $backup_groups, $archive_path, $archive_metadata, $archive_mode, $archive_owner, $archive_group, $archive_size, $archive_access
# Fat linpeas: 0
# Small linpeas: 0
if ! [ "$SEARCH_IN_FOLDER" ]; then
if [ "$PSTORAGE_BACKUPS" ] || [ "$DEBUG" ]; then
backup_uid=$(id -u 2>/dev/null)
backup_groups=$(id -G 2>/dev/null)
report_readable_backup_archive() (
archive_path=$1
[ -n "$backup_uid" ] && [ "$backup_uid" != 0 ] || exit
[ -f "$archive_path" ] && [ ! -L "$archive_path" ] && [ -r "$archive_path" ] || exit
case "$archive_path" in
*.tar|*.tar.gz|*.tgz|*.tar.bz2|*.tbz2|*.tar.xz|*.txz|*.tar.zst|*.zip|*.7z) ;;
*) exit ;;
esac
archive_metadata=$(LC_ALL=C ls -ldn "$archive_path" 2>/dev/null | awk '{print $1, $3, $4, $5}')
set -- $archive_metadata
[ "$#" -eq 4 ] || exit
archive_mode=$1 archive_owner=$2 archive_group=$3 archive_size=$4
[ "$archive_owner" != "$backup_uid" ] || exit
if [ "$archive_owner" != 0 ]; then
# A small archive owned by another user can expose stale credentials.
# Keep the existing root-owned reporting behavior, regardless of size.
case "$archive_size" in ''|*[!0-9]*) exit ;; esac
[ "${#archive_size}" -le 8 ] || exit
[ "$archive_size" -le 10485760 ] || exit
fi
case "$archive_mode" in
???????r*) archive_access=world-readable ;;
????r*)
case " $backup_groups " in
*" $archive_group "*) archive_access=group-readable ;;
*) exit ;;
esac ;;
*) exit ;;
esac
if [ "$archive_owner" = 0 ]; then
printf ' Possible exposure: readable root-owned backup archive: %s (owner=%s group=%s mode=%s size=%s access=%s)\n' \
"$archive_path" "$archive_owner" "$archive_group" "$archive_mode" "$archive_size" "$archive_access"
else
printf ' Possible exposure: readable other-user backup archive: %s (owner=%s group=%s mode=%s size=%s access=%s; contents unverified)\n' \
"$archive_path" "$archive_owner" "$archive_group" "$archive_mode" "$archive_size" "$archive_access"
fi
)
print_2title "Backup folders" "T1552.001"
printf "%s\n" "$PSTORAGE_BACKUPS" | while read b ; do
ls -ld "$b" 2> /dev/null | sed -${E} "s,backups|backup,${SED_RED},g";
ls -l "$b" 2>/dev/null && echo ""
printf "%s\n" "$PSTORAGE_BACKUPS" | while IFS= read -r b; do
[ -d "$b" ] && [ -r "$b" ] && [ -x "$b" ] || continue
ls -ld "$b" 2>/dev/null | sed -${E} "s,backups|backup,${SED_RED},g"
# Prune each child: list immediate entries only and cap metadata calls.
find "$b" ! -path "$b" -prune -print 2>/dev/null | head -n 30 |
while IFS= read -r entry; do
ls -ld "$entry" 2>/dev/null
report_readable_backup_archive "$entry"
done
echo ""
done
echo ""
# Only inspect small, readable regular files in the first few discovered
# backup directories. Never print the assignment value.
print_2title "Shell *_PASS assignments in backup folders (values redacted)" "T1552.001"
printf "%s\n" "$PSTORAGE_BACKUPS" | head -n 12 |
while IFS= read -r b; do
[ -d "$b" ] && [ ! -L "$b" ] && [ -r "$b" ] && [ -x "$b" ] || continue
find "$b" ! -path "$b" -prune -type f -size -65537c -print 2>/dev/null | head -n 30 |
while IFS= read -r entry; do
[ -f "$entry" ] && [ ! -L "$entry" ] && [ -r "$entry" ] || continue
awk '
NR > 200 { exit }
{
if (length($0) > 512) next
line = $0
sub(/^[[:space:]]*/, "", line)
status = "active"
if (line ~ /^#/) {
status = "commented; stale/unknown"
sub(/^#[[:space:]]*/, "", line)
}
sub(/^export[[:space:]]+/, "", line)
if (line !~ /^[A-Za-z_][A-Za-z0-9_]*_PASS=/) next
key = line
sub(/=.*/, "", key)
value = line
sub(/^[^=]*=/, "", value)
if (value !~ /[^[:space:]]/) next
printf " %s:%d: %s=[REDACTED] (%s)\n", FILENAME, NR, key, status
if (++found >= 5) exit
}
' "$entry" 2>/dev/null
done
done | head -n 50
echo ""
fi
fi
fi
@@ -9,7 +9,7 @@
# Functions Used: print_2title
# Global Variables: $DEBUG, $HOME, $MACPEAS
# Initial Functions:
# Generated Global Variables: $FILECMD, $SQLITEPYTHON, $tables, $columns, $INTCOLUMN
# Generated Global Variables: $FILECMD, $SQLITEPYTHON, $tables, $columns, $INTCOLUMN, $escaped_t, $escaped_sql_t
# Fat linpeas: 0
# Small linpeas: 0
@@ -25,45 +25,54 @@ fi
if [ "$PSTORAGE_DATABASE" ] || [ "$DEBUG" ]; then
print_2title "Searching tables inside readable .db/.sql/.sqlite files (limit 100)" "T1005"
FILECMD="$(command -v file 2>/dev/null || echo -n '')"
printf "%s\n" "$PSTORAGE_DATABASE" | while read f; do
FILECMD=$(command -v file 2>/dev/null)
printf "%s\n" "$PSTORAGE_DATABASE" | while IFS= read -r f; do
[ -n "$f" ] || continue
if [ "$FILECMD" ]; then
echo "Found "$(file "$f") | sed -${E} "s,\.db|\.sql|\.sqlite|\.sqlite3,${SED_RED},g";
printf 'Found %s\n' "$(file "$f")" | sed -${E} "s,\.db|\.sql|\.sqlite|\.sqlite3,${SED_RED},g";
else
echo "Found $f" | sed -${E} "s,\.db|\.sql|\.sqlite|\.sqlite3,${SED_RED},g";
printf 'Found %s\n' "$f" | sed -${E} "s,\.db|\.sql|\.sqlite|\.sqlite3,${SED_RED},g";
fi
done
SQLITEPYTHON=""
echo ""
printf "%s\n" "$PSTORAGE_DATABASE" | while read f; do
printf "%s\n" "$PSTORAGE_DATABASE" | while IFS= read -r f; do
[ -n "$f" ] || continue
SQLITEPYTHON=""
if ([ -r "$f" ] && [ "$FILECMD" ] && file "$f" | grep -qi sqlite) || ([ -r "$f" ] && [ ! "$FILECMD" ]); then #If readable and filecmd and sqlite, or readable and not filecmd
if [ "$(command -v sqlite3 2>/dev/null || echo -n '')" ]; then
tables=$(sqlite3 $f ".tables" 2>/dev/null)
if command -v sqlite3 >/dev/null 2>&1; then
tables=$(sqlite3 "$f" "SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'" 2>/dev/null)
#printf "$tables\n" | sed "s,user.*\|credential.*,${SED_RED},g"
elif [ "$(command -v python 2>/dev/null || echo -n '')" ] || [ "$(command -v python3 2>/dev/null || echo -n '')" ]; then
SQLITEPYTHON=$(command -v python 2>/dev/null || command -v python3 2>/dev/null || echo -n '')
tables=$($SQLITEPYTHON -c "print('\n'.join([t[0] for t in __import__('sqlite3').connect('$f').cursor().execute('SELECT name FROM sqlite_master WHERE type=\'table\' and tbl_name NOT like \'sqlite_%\';').fetchall()]))" 2>/dev/null)
elif command -v python >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1; then
SQLITEPYTHON=$(command -v python 2>/dev/null || command -v python3 2>/dev/null)
tables=$("$SQLITEPYTHON" -c 'import sqlite3,sys; db=sqlite3.connect(sys.argv[1]); print("\n".join(row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type=? AND name NOT LIKE ?", ("table", "sqlite_%"))))' "$f" 2>/dev/null)
#printf "$tables\n" | sed "s,user.*\|credential.*,${SED_RED},g"
else
tables=""
fi
if [ "$tables" ] || [ "$DEBUG" ]; then
printf $GREEN" -> Extracting tables from$NC $f $DG(limit 20)\n"$NC
printf "%s\n" "$tables" | while read t; do
printf "%s\n" "$tables" | while IFS= read -r t; do
columns=""
# Search for credentials inside the table using sqlite3
if [ -z "$SQLITEPYTHON" ]; then
columns=$(sqlite3 $f ".schema $t" 2>/dev/null | grep "CREATE TABLE")
escaped_sql_t=$(printf '%s' "$t" | sed "s/'/''/g")
columns=$(sqlite3 "$f" "SELECT sql FROM sqlite_master WHERE name='$escaped_sql_t' AND sql IS NOT NULL" 2>/dev/null)
# Search for credentials inside the table using python
else
columns=$($SQLITEPYTHON -c "print(__import__('sqlite3').connect('$f').cursor().execute('SELECT sql FROM sqlite_master WHERE type!=\'meta\' AND sql NOT NULL AND name =\'$t\';').fetchall()[0][0])" 2>/dev/null)
columns=$("$SQLITEPYTHON" -c 'import sqlite3,sys; db=sqlite3.connect(sys.argv[1]); row=db.execute("SELECT sql FROM sqlite_master WHERE name=? AND sql IS NOT NULL", (sys.argv[2],)).fetchone(); print(row[0] if row else "")' "$f" "$t" 2>/dev/null)
fi
#Check found columns for interesting fields
INTCOLUMN=$(echo "$columns" | grep -i "username\|passw\|credential\|email\|hash\|salt")
if [ "$INTCOLUMN" ]; then
printf ${BLUE}" --> Found interesting column names in$NC $t $DG(output limit 10)\n"$NC | sed -${E} "s,user.*|credential.*,${SED_RED},g"
printf "$columns\n" | sed -${E} "s,username|passw|credential|email|hash|salt|$t,${SED_RED},g"
(sqlite3 $f "select * from $t" || $SQLITEPYTHON -c "print(', '.join([str(x) for x in __import__('sqlite3').connect('$f').cursor().execute('SELECT * FROM \'$t\';').fetchall()[0]]))") 2>/dev/null | head
printf "%s\n" "$columns" | sed -${E} "s,username|passw|credential|email|hash|salt,${SED_RED},g"
if [ -z "$SQLITEPYTHON" ]; then
escaped_t=$(printf '%s' "$t" | sed 's/"/""/g')
sqlite3 "$f" "SELECT * FROM \"$escaped_t\" LIMIT 10" 2>/dev/null | head
else
"$SQLITEPYTHON" -c 'import sqlite3,sys; db=sqlite3.connect(sys.argv[1]); table=sys.argv[2].replace("\"", "\"\""); print("\n".join("|".join(str(v) if v is not None else "" for v in row) for row in db.execute("SELECT * FROM \""+table+"\" LIMIT 10")))' "$f" "$t" 2>/dev/null | head
fi
echo ""
fi
done
@@ -76,4 +85,4 @@ echo ""
if [ "$MACPEAS" ]; then
print_2title "Downloaded Files" "T1005"
sqlite3 ~/Library/Preferences/com.apple.LaunchServices.QuarantineEventsV2 'select LSQuarantineAgentName, LSQuarantineDataURLString, LSQuarantineOriginURLString, date(LSQuarantineTimeStamp + 978307200, "unixepoch") as downloadedDate from LSQuarantineEvent order by LSQuarantineTimeStamp' | sort | grep -Ev "\|\|\|"
fi
fi
@@ -7,7 +7,7 @@
# Version: 1.0
# Mitre: T1005
# Functions Used: print_2title
# Global Variables: $SEARCH_IN_FOLDER
# Global Variables: $SEARCH_IN_FOLDER, $FAST, $SUPERFAST, $TIMEOUT
# Initial Functions:
# Generated Global Variables:
# Fat linpeas: 0
@@ -22,3 +22,254 @@ if ! [ "$SEARCH_IN_FOLDER" ]; then
ls -alhR /opt/lampp/htdocs/ 2>/dev/null | head
echo ""
fi
if ! [ "$FAST" ] && ! [ "$SUPERFAST" ] && [ "$TIMEOUT" ] && command -v python3 >/dev/null 2>&1; then
print_2title "Django settings and FileBasedCache permissions (bounded passive check)" "T1005"
(
if [ "$SEARCH_IN_FOLDER" ]; then
set -- "$SEARCH_IN_FOLDER"
else
set -- /var/www /srv/www /opt /usr/src/app/app/settings.py
fi
"$TIMEOUT" 8 python3 -I -S - "$@" <<'DJANGO_FILE_CACHE_CHECK'
import ast
import os
import stat
import sys
import time
if sys.version_info < (3, 6):
raise SystemExit(0) # Scoped scandir context managers are unavailable.
BACKEND = "django.core.cache.backends.filebased.FileBasedCache"
COOKIE_BACKEND = "django.contrib.sessions.backends.signed_cookies"
PICKLE_SERIALIZER = "django.contrib.sessions.serializers.PickleSerializer"
MAX_CANDIDATES = 30
MAX_BYTES = 262144
MAX_DIRS = 240
MAX_ENTRIES = 5000
MAX_OUTPUT = 10
DEADLINE = time.monotonic() + 6
def literal(node):
if hasattr(ast, "Constant") and isinstance(node, ast.Constant):
return node.value if isinstance(node.value, str) else None
legacy_string = getattr(ast, "Str", ())
return node.s if isinstance(node, legacy_string) else None
def signed_pickle_session(source):
if b"signed_cookies" not in source or b"PickleSerializer" not in source:
return False
try:
tree = ast.parse(source)
except (SyntaxError, ValueError):
return False
names = ("SESSION_ENGINE", "SESSION_SERIALIZER")
writes = {name: 0 for name in names}
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names):
return False
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("exec", "eval", "globals", "locals"):
return False
if isinstance(node, ast.Name) and node.id in writes and isinstance(node.ctx, (ast.Store, ast.Del)):
writes[node.id] += 1
if any(writes[name] != 1 for name in names):
return False
settings = {}
for node in tree.body:
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
name = node.targets[0].id
if name in writes:
settings[name] = literal(node.value)
return settings.get("SESSION_ENGINE") == COOKIE_BACKEND and settings.get("SESSION_SERIALIZER") == PICKLE_SERIALIZER
def cache_entries(source):
try:
tree = ast.parse(source)
except (SyntaxError, ValueError):
return [], "settings syntax could not be parsed"
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names):
return [], "settings uses a wildcard import"
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("exec", "eval", "globals", "locals"):
return [], "settings uses dynamic namespace operations"
assignments = [node for node in tree.body if isinstance(node, ast.Assign)
and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == "CACHES"]
references = [node for node in ast.walk(tree) if isinstance(node, ast.Name) and node.id == "CACHES"]
if len(assignments) != 1 or len(references) != 1:
return [], "CACHES is dynamic or assigned more than once"
caches = assignments[0].value
if not isinstance(caches, ast.Dict):
return [], "CACHES is not a literal dictionary"
aliases = []
seen = set()
for key, value in zip(caches.keys, caches.values):
alias = literal(key)
if alias is None or alias in seen or not isinstance(value, ast.Dict):
return [], "CACHES has a dynamic or duplicate alias"
seen.add(alias)
fields = {}
for field_key, field_value in zip(value.keys, value.values):
field = literal(field_key)
if field is None or field in fields:
return [], "cache entry has a dynamic or duplicate field"
fields[field] = field_value
if literal(fields.get("BACKEND")) == BACKEND:
location = literal(fields.get("LOCATION"))
if location is None or not os.path.isabs(location) or os.path.normpath(location) != location:
aliases.append((alias, None))
else:
aliases.append((alias, location))
return aliases, None
def candidate_files(roots):
stack = [(root, 0) for root in reversed(roots[:3]) if os.path.isdir(root)]
dirs = entries = candidates = 0
while stack and dirs < MAX_DIRS and entries < MAX_ENTRIES and candidates < MAX_CANDIDATES and time.monotonic() < DEADLINE:
directory, depth = stack.pop()
dirs += 1
try:
with os.scandir(directory) as contents:
for entry in contents:
entries += 1
if entries >= MAX_ENTRIES or time.monotonic() >= DEADLINE:
break
try:
if entry.name == "settings.py" and entry.is_file(follow_symlinks=False):
candidates += 1
yield entry.path
if candidates >= MAX_CANDIDATES:
break
elif depth < 5 and entry.is_dir(follow_symlinks=False):
stack.append((entry.path, depth + 1))
except OSError:
continue
except OSError:
continue
if candidates < MAX_CANDIDATES and time.monotonic() < DEADLINE:
for path in roots[3:4]:
if os.path.isfile(path) and not os.path.islink(path):
candidates += 1
yield path
def process_consumer(settings):
package = os.path.basename(os.path.dirname(settings))
project = os.path.realpath(os.path.dirname(os.path.dirname(settings)))
if not package or project == os.path.sep:
return None
try:
with os.scandir("/proc") as processes:
checked = 0
for proc in processes:
if checked >= 400 or time.monotonic() >= DEADLINE:
break
if not proc.name.isdigit():
continue
checked += 1
try:
if os.path.realpath(proc.path + "/cwd") != project:
continue
with open(proc.path + "/cmdline", "rb") as command_file:
command = command_file.read(1024)
if b"gunicorn" not in command.lower() or (package + ".wsgi").encode() not in command:
continue
with open(proc.path + "/status", encoding="ascii", errors="replace") as status_file:
for line in status_file:
if line.startswith("Uid:"):
return int(line.split()[2]), proc.name
except (OSError, ValueError, IndexError):
continue
except OSError:
pass
return None
def cache_metadata(location):
samples = []
try:
with os.scandir(location) as contents:
for index, entry in enumerate(contents):
if index >= 100 or len(samples) >= 3:
break
if entry.name.endswith(".djcache"):
try:
info = entry.stat(follow_symlinks=False)
if stat.S_ISREG(info.st_mode):
samples.append("uid=%s mode=%04o" % (info.st_uid, stat.S_IMODE(info.st_mode)))
except OSError:
pass
except OSError:
pass
return ", ".join(samples) if samples else "none sampled"
def inspect(settings):
try:
info = os.stat(settings, follow_symlinks=False)
if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_BYTES or info.st_size < 1:
return []
with open(settings, "rb") as settings_file:
source = settings_file.read(MAX_BYTES + 1)
if len(source) > MAX_BYTES:
return []
aliases, reason = cache_entries(source)
session_candidate = signed_pickle_session(source)
except (OSError, UnicodeError):
return []
if reason:
findings = ["review Django cache settings %r: %s" % (settings, reason)] if b"FileBasedCache" in source else []
if session_candidate:
findings.append("review signed-cookie pickle session: settings=%r; SECRET_KEY access and active backend require confirmation" % settings)
return findings
findings = []
for alias, location in aliases:
if location is None:
findings.append("review Django cache settings %r alias %r: LOCATION is dynamic or not a literal absolute path" % (settings, alias))
continue
try:
directory = os.stat(location)
if not stat.S_ISDIR(directory.st_mode):
raise OSError("not a directory")
except OSError:
findings.append("review Django cache settings %r alias %r location %r: directory unavailable" % (settings, alias, location))
continue
try:
writable = os.access(location, os.W_OK | os.X_OK, effective_ids=True)
except (TypeError, NotImplementedError):
writable = os.getuid() == os.geteuid() and os.access(location, os.W_OK | os.X_OK)
sticky = bool(directory.st_mode & stat.S_ISVTX)
symlinked = os.path.realpath(location) != os.path.abspath(location)
consumer = process_consumer(settings) if writable else None
consumer_text = "gunicorn pid=%s uid=%s" % (consumer[1], consumer[0]) if consumer else "consumer identity unverified"
evidence = "settings=%r alias=%r location=%r dir_uid=%s mode=%04o access=%s sticky=%s symlink=%s %s cache_files=%s" % (
settings, alias, location, directory.st_uid, stat.S_IMODE(directory.st_mode),
"write+search" if writable else "no write+search", sticky, symlinked,
consumer_text, cache_metadata(location))
if writable and not sticky and not symlinked and consumer and consumer[0] != os.geteuid() and os.geteuid() != 0:
findings.append("potential cross-user Django file cache replacement: " + evidence)
else:
findings.append("review Django file cache candidate: " + evidence)
if session_candidate:
findings.append("review signed-cookie pickle session: settings=%r; SECRET_KEY access and active backend require confirmation" % settings)
return findings
printed = 0
for path in candidate_files(sys.argv[1:]):
for finding in inspect(path):
if printed >= MAX_OUTPUT or time.monotonic() >= DEADLINE:
break
print(finding, flush=True)
printed += 1
if printed >= MAX_OUTPUT:
break
DJANGO_FILE_CACHE_CHECK
)
echo ""
fi
@@ -1,21 +1,101 @@
# Title: Interesting Files - passwords in config PHP files
# Title: Interesting Files - passwords in PHP files
# ID: IF_Passwords_php_files
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Searching passwords in config PHP files
# Description: Searching passwords in PHP config and webroot files
# License: GNU GPL
# Version: 1.0
# Mitre: T1552.001
# Functions Used: print_2title
# Global Variables: $DEBUG
# Global Variables: $DEBUG, $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables:
# Generated Global Variables: $PHP_AUTH_FILES, $php_mysqli_timeout, $php_mysqli_files, $php_mysqli_started
# Fat linpeas: 0
# Small linpeas: 1
if [ "$PSTORAGE_PHP_FILES" ] || [ "$DEBUG" ]; then
print_2title "Searching passwords in config PHP files" "T1552.001"
printf "%s\n" "$PSTORAGE_PHP_FILES" | while read c; do grep -EiIH "(pwd|passwd|password|PASSWD|PASSWORD|dbuser|dbpass).*[=:].+|define ?\('(\w*passw|\w*user|\w*datab)" "$c" 2>/dev/null | grep -Ev "function|password.*= ?\"\"|password.*= ?''" | sed '/^.\{150\}./d' | sort | uniq | sed -${E} "s,[pP][aA][sS][sS][wW]|[dD][bB]_[pP][aA][sS][sS],${SED_RED},g"; done
PHP_AUTH_FILES=""
if ! [ "$SEARCH_IN_FOLDER" ]; then
PHP_AUTH_FILES=$(find /var/www -maxdepth 5 -type f -size -1048576c \( -name login.php -o -name auth.php \) -print 2>/dev/null | head -n 40)
fi
# A positional database password can appear in an ordinary PHP page without a
# password-named key. Keep this pass separate so its output never includes source.
if ! [ "$SEARCH_IN_FOLDER" ] && [ -d /var/www ]; then
php_mysqli_timeout=$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
if [ -z "$php_mysqli_timeout" ]; then
echo "PHP mysqli positional credentials: unknown (timeout unavailable)."
else
php_mysqli_files=$("$php_mysqli_timeout" 5 find /var/www -maxdepth 5 -type f -name '*.php' -size -1048576c -print 2>/dev/null | head -n 40)
if [ -n "$php_mysqli_files" ]; then
print_2title "PHP mysqli positional credential candidates" "T1552.001"
php_mysqli_started=$(date +%s)
printf '%s\n' "$php_mysqli_files" | while IFS= read -r c; do
if [ "$(($(date +%s) - php_mysqli_started))" -ge 5 ]; then
echo "PHP mysqli positional credentials: partial (time limit reached)."
break
fi
[ -f "$c" ] && [ -r "$c" ] || continue
# shellcheck disable=SC2016 # The awk program intentionally reads PHP variables.
"$php_mysqli_timeout" 1 awk '
function trim(s) { sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]+$/, "", s); return s }
{
if (length($0) > 4096) next
line = $0
low = tolower(line)
if (low ~ /^[[:space:]]*(\/\/|#|\*)/) next
if (!match(low, /(^|[^[:alnum:]_])new[[:space:]]+mysqli[[:space:]]*\(/)) next
prefix = substr(line, 1, RSTART)
prefix_quote = ""; prefix_escaped = 0; commented = 0
for (j = 1; j <= length(prefix); j++) {
ch = substr(prefix, j, 1)
if (prefix_quote != "") {
if (prefix_escaped) prefix_escaped = 0
else if (ch == "\\") prefix_escaped = 1
else if (ch == prefix_quote) prefix_quote = ""
} else if (ch == "\"" || ch == sprintf("%c", 39)) prefix_quote = ch
else if (ch == "#" || (ch == "/" && substr(prefix, j + 1, 1) == "/")) {
commented = 1; break
}
}
if (commented || prefix_quote != "") next
start = RSTART + RLENGTH
quote = ""; escaped = 0; depth = 0; argc = 1; arg = ""; third = ""; closed = 0
for (i = start; i <= length(line); i++) {
ch = substr(line, i, 1)
if (quote != "") {
arg = arg ch
if (escaped) escaped = 0
else if (ch == "\\") escaped = 1
else if (ch == quote) quote = ""
} else if (ch == "\"" || ch == sprintf("%c", 39)) {
quote = ch; arg = arg ch
} else if (ch == "(" || ch == "[" || ch == "{") {
depth++; arg = arg ch
} else if (ch == ")" && depth == 0) {
if (argc == 3) third = trim(arg)
closed = 1; break
} else if (ch == ")" || ch == "]" || ch == "}") {
depth--; arg = arg ch
} else if (ch == "," && depth == 0) {
if (argc == 3) third = trim(arg)
argc++; arg = ""
} else arg = arg ch
}
if (!closed || argc < 3 || third == "" || tolower(third) == "null" || third == "\"\"" || third == sprintf("%c%c", 39, 39)) next
kind = "expression"
if (third ~ /^\$[[:alpha:]_][[:alnum:]_]*$/) kind = "variable"
else if (third ~ /^\047.*\047$/ || third ~ /^".*"$/) kind = "literal"
printf "%s:%d: mysqli positional credentials candidate (third argument: %s)\n", FILENAME, FNR, kind
}
' "$c" 2>/dev/null
done
echo ""
fi
fi
fi
if [ "$PSTORAGE_PHP_FILES" ] || [ "$PHP_AUTH_FILES" ] || [ "$DEBUG" ]; then
print_2title "Searching passwords in PHP files" "T1552.001"
printf "%s\n%s\n" "$PSTORAGE_PHP_FILES" "$PHP_AUTH_FILES" | sed '/^$/d' | sort -u | while IFS= read -r c; do grep -EiIH "(pwd|passwd|password|PASSWD|PASSWORD|dbuser|dbpass).*[=:].+|define ?\('(\w*passw|\w*user|\w*datab)" "$c" 2>/dev/null | grep -Ev "function|password.*= ?\"\"|password.*= ?''" | sed '/^.\{150\}./d' | sort | uniq | sed -"${E}" "s,[pP][aA][sS][sS][wW]|[dD][bB]_[pP][aA][sS][sS],${SED_RED},g"; done
echo ""
fi
fi
@@ -2,20 +2,81 @@
# ID: IF_Passwords_in_logs
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Passwords inside logs
# Description: Passwords inside logs, including long credential-bearing GET requests
# License: GNU GPL
# Version: 1.0
# Mitre: T1552.001
# Functions Used: print_2title
# Global Variables: $SEARCH_IN_FOLDER
# Initial Functions:
# Generated Global Variables:
# Generated Global Variables: $log_find_exec_end, $lp_log_started, $lp_log_now, $lp_log_seen, $lp_log_file, $lp_log_hit, $lp_log_found, $lp_log_partial
# Fat linpeas: 0
# Small linpeas: 0
lp_http_log_credential_cues() (
lp_log_started=$(date +%s 2>/dev/null || printf 0)
lp_log_seen=0
lp_log_found=0
lp_log_partial=0
for lp_log_file do
[ "$lp_log_seen" -lt 8 ] || { lp_log_partial=1; break; }
lp_log_now=$(date +%s 2>/dev/null || printf 0)
if [ "$lp_log_started" -gt 0 ] && [ "$lp_log_now" -ge "$((lp_log_started + 5))" ]; then
lp_log_partial=1
break
fi
[ -f "$lp_log_file" ] && [ -r "$lp_log_file" ] && [ ! -L "$lp_log_file" ] || continue
lp_log_seen=$((lp_log_seen + 1))
# The final 64 KiB is enough to inspect recent requests without reading a
# whole access log. Never pass raw request lines or parameter values onward.
lp_log_hit=$(tail -c 65536 "$lp_log_file" 2>/dev/null | awk '
length($0) > 4096 { next }
{
quote = index($0, "\"")
if (!quote) next
request = substr($0, quote + 1)
if (request !~ /^GET[[:space:]]/) next
split(request, fields, /[[:space:]]+/)
uri = fields[2]
mark = index(uri, "?")
if (!mark) next
query = substr(uri, mark + 1)
sub(/#.*/, "", query)
count = split(query, params, "&")
for (i = 1; i <= count; i++) {
equals = index(params[i], "=")
if (equals < 2 || equals == length(params[i])) continue
key = tolower(substr(params[i], 1, equals - 1))
if (key == "pass" || key == "pwd" || key ~ /(^|[_-])pwd$/ ||
key ~ /(password|passwd|passcode)$/) {
print "found"
exit
}
}
}
' 2>/dev/null)
[ "$lp_log_hit" = found ] || continue
if [ "$lp_log_found" -eq 0 ]; then
printf '%s\n' 'HTTP access-log GET credential query parameters (values redacted):'
lp_log_found=1
fi
printf ' %s: credential-bearing GET request present\n' "$lp_log_file"
done
[ "$lp_log_partial" -eq 0 ] || printf '%s\n' 'HTTP access-log credential inspection was partial (file or time cap reached).'
)
if ! [ "$SEARCH_IN_FOLDER" ]; then
print_2title "Searching passwords inside logs (limit 70)" "T1552.001"
(find /var/log/ /var/logs/ /private/var/log -type f -exec grep -R -H -i "pwd\|passw" "{}" \;) 2>/dev/null | sed '/^.\{150\}./d' | sort | uniq | grep -v "File does not exist:\|modules-config/config-set-passwords\|config-set-passwords already ran\|script not found or unable to stat:\|\"GET /.*\" 404" | head -n 70 | sed -${E} "s,pwd|passw,${SED_RED},"
# Probe batching once; use the per-file terminator on older find implementations.
log_find_exec_end='+'
find /dev/null -exec true '{}' + >/dev/null 2>&1 || log_find_exec_end=';'
# shellcheck disable=SC2067
(find /var/log/ /var/logs/ /private/var/log -type f -exec grep -R -H -i "pwd\|passw" "{}" "$log_find_exec_end") 2>/dev/null | sed '/^.\{150\}./d' | sort | uniq | grep -v "File does not exist:\|modules-config/config-set-passwords\|config-set-passwords already ran\|script not found or unable to stat:\|\"GET /.*\" 404" | head -n 70 | sed -${E} "s,pwd|passw,${SED_RED},"
lp_http_log_credential_cues \
/var/log/apache2/access.log /var/log/apache2/access.log.1 \
/var/log/httpd/access_log /var/log/httpd/access_log.1 \
/var/log/nginx/access.log /var/log/nginx/access.log.1 \
/var/www/logs/access_log /var/www/logs/access_log.1
echo ""
fi
@@ -9,7 +9,7 @@
# Functions Used: print_2title
# Global Variables: $HOMESEARCH,$ITALIC, $pwd_in_variables1, $pwd_in_variables2, $pwd_in_variables3, $pwd_in_variables4, $pwd_in_variables5, $pwd_in_variables6, $pwd_in_variables7, $pwd_in_variables8, $pwd_in_variables9, $pwd_in_variables10, $pwd_in_variables11, $SEARCH_IN_FOLDER, $TIMEOUT, $backup_folders_row
# Initial Functions:
# Generated Global Variables: $ppicf
# Generated Global Variables: $ppicf, $password_find_exec_end
# Fat linpeas: 0
# Small linpeas: 1
@@ -18,11 +18,17 @@ if ! [ "$FAST" ] && ! [ "$SUPERFAST" ] && [ "$TIMEOUT" ]; then
##-- IF) Find possible files with passwords
print_2title "Searching possible password variables inside key folders (limit 140)" "T1552.001"
# Probe batching once; use the per-file terminator on older find implementations.
password_find_exec_end='+'
find /dev/null -exec true '{}' + >/dev/null 2>&1 || password_find_exec_end=';'
if ! [ "$SEARCH_IN_FOLDER" ]; then
timeout 150 find $HOMESEARCH -exec grep -HnRiIE "($pwd_in_variables1|$pwd_in_variables2|$pwd_in_variables3|$pwd_in_variables4|$pwd_in_variables5|$pwd_in_variables6|$pwd_in_variables7|$pwd_in_variables8|$pwd_in_variables9|$pwd_in_variables10|$pwd_in_variables11).*[=:].+" '{}' \; 2>/dev/null | sed '/^.\{150\}./d' | grep -Ev "^#" | grep -iv "linpeas" | sort | uniq | head -n 70 | sed -${E} "s,$pwd_in_variables1,${SED_RED},g" | sed -${E} "s,$pwd_in_variables2,${SED_RED},g" | sed -${E} "s,$pwd_in_variables3,${SED_RED},g" | sed -${E} "s,$pwd_in_variables4,${SED_RED},g" | sed -${E} "s,$pwd_in_variables5,${SED_RED},g" | sed -${E} "s,$pwd_in_variables6,${SED_RED},g" | sed -${E} "s,$pwd_in_variables7,${SED_RED},g" | sed -${E} "s,$pwd_in_variables8,${SED_RED},g" | sed -${E} "s,$pwd_in_variables9,${SED_RED},g" | sed -${E} "s,$pwd_in_variables10,${SED_RED},g" | sed -${E} "s,$pwd_in_variables11,${SED_RED},g" &
timeout 150 find /var/www $backup_folders_row /tmp /etc /mnt /private -exec grep -HnRiIE "($pwd_in_variables1|$pwd_in_variables2|$pwd_in_variables3|$pwd_in_variables4|$pwd_in_variables5|$pwd_in_variables6|$pwd_in_variables7|$pwd_in_variables8|$pwd_in_variables9|$pwd_in_variables10|$pwd_in_variables11).*[=:].+" '{}' \; 2>/dev/null | sed '/^.\{150\}./d' | grep -Ev "^#" | grep -iv "linpeas" | sort | uniq | head -n 70 | sed -${E} "s,$pwd_in_variables1,${SED_RED},g" | sed -${E} "s,$pwd_in_variables2,${SED_RED},g" | sed -${E} "s,$pwd_in_variables3,${SED_RED},g" | sed -${E} "s,$pwd_in_variables4,${SED_RED},g" | sed -${E} "s,$pwd_in_variables5,${SED_RED},g" | sed -${E} "s,$pwd_in_variables6,${SED_RED},g" | sed -${E} "s,$pwd_in_variables7,${SED_RED},g" | sed -${E} "s,$pwd_in_variables8,${SED_RED},g" | sed -${E} "s,$pwd_in_variables9,${SED_RED},g" | sed -${E} "s,$pwd_in_variables10,${SED_RED},g" | sed -${E} "s,$pwd_in_variables11,${SED_RED},g" &
# shellcheck disable=SC2067
timeout 150 find $HOMESEARCH ! -type d -exec grep -HnRiIE "($pwd_in_variables1|$pwd_in_variables2|$pwd_in_variables3|$pwd_in_variables4|$pwd_in_variables5|$pwd_in_variables6|$pwd_in_variables7|$pwd_in_variables8|$pwd_in_variables9|$pwd_in_variables10|$pwd_in_variables11).*[=:].+" '{}' "$password_find_exec_end" 2>/dev/null | sed '/^.\{150\}./d' | grep -Ev "^#" | grep -iv "linpeas" | sort | uniq | head -n 70 | sed -${E} "s,$pwd_in_variables1,${SED_RED},g" | sed -${E} "s,$pwd_in_variables2,${SED_RED},g" | sed -${E} "s,$pwd_in_variables3,${SED_RED},g" | sed -${E} "s,$pwd_in_variables4,${SED_RED},g" | sed -${E} "s,$pwd_in_variables5,${SED_RED},g" | sed -${E} "s,$pwd_in_variables6,${SED_RED},g" | sed -${E} "s,$pwd_in_variables7,${SED_RED},g" | sed -${E} "s,$pwd_in_variables8,${SED_RED},g" | sed -${E} "s,$pwd_in_variables9,${SED_RED},g" | sed -${E} "s,$pwd_in_variables10,${SED_RED},g" | sed -${E} "s,$pwd_in_variables11,${SED_RED},g" &
# shellcheck disable=SC2067
timeout 150 find /var/www $backup_folders_row /tmp /etc /mnt /private ! -type d -exec grep -HnRiIE "($pwd_in_variables1|$pwd_in_variables2|$pwd_in_variables3|$pwd_in_variables4|$pwd_in_variables5|$pwd_in_variables6|$pwd_in_variables7|$pwd_in_variables8|$pwd_in_variables9|$pwd_in_variables10|$pwd_in_variables11).*[=:].+" '{}' "$password_find_exec_end" 2>/dev/null | sed '/^.\{150\}./d' | grep -Ev "^#" | grep -iv "linpeas" | sort | uniq | head -n 70 | sed -${E} "s,$pwd_in_variables1,${SED_RED},g" | sed -${E} "s,$pwd_in_variables2,${SED_RED},g" | sed -${E} "s,$pwd_in_variables3,${SED_RED},g" | sed -${E} "s,$pwd_in_variables4,${SED_RED},g" | sed -${E} "s,$pwd_in_variables5,${SED_RED},g" | sed -${E} "s,$pwd_in_variables6,${SED_RED},g" | sed -${E} "s,$pwd_in_variables7,${SED_RED},g" | sed -${E} "s,$pwd_in_variables8,${SED_RED},g" | sed -${E} "s,$pwd_in_variables9,${SED_RED},g" | sed -${E} "s,$pwd_in_variables10,${SED_RED},g" | sed -${E} "s,$pwd_in_variables11,${SED_RED},g" &
else
timeout 150 find $SEARCH_IN_FOLDER -exec grep -HnRiIE "($pwd_in_variables1|$pwd_in_variables2|$pwd_in_variables3|$pwd_in_variables4|$pwd_in_variables5|$pwd_in_variables6|$pwd_in_variables7|$pwd_in_variables8|$pwd_in_variables9|$pwd_in_variables10|$pwd_in_variables11).*[=:].+" '{}' \; 2>/dev/null | sed '/^.\{150\}./d' | grep -Ev "^#" | grep -iv "linpeas" | sort | uniq | head -n 70 | sed -${E} "s,$pwd_in_variables1,${SED_RED},g" | sed -${E} "s,$pwd_in_variables2,${SED_RED},g" | sed -${E} "s,$pwd_in_variables3,${SED_RED},g" | sed -${E} "s,$pwd_in_variables4,${SED_RED},g" | sed -${E} "s,$pwd_in_variables5,${SED_RED},g" | sed -${E} "s,$pwd_in_variables6,${SED_RED},g" | sed -${E} "s,$pwd_in_variables7,${SED_RED},g" | sed -${E} "s,$pwd_in_variables8,${SED_RED},g" | sed -${E} "s,$pwd_in_variables9,${SED_RED},g" | sed -${E} "s,$pwd_in_variables10,${SED_RED},g" | sed -${E} "s,$pwd_in_variables11,${SED_RED},g" &
# shellcheck disable=SC2067
timeout 150 find $SEARCH_IN_FOLDER ! -type d -exec grep -HnRiIE "($pwd_in_variables1|$pwd_in_variables2|$pwd_in_variables3|$pwd_in_variables4|$pwd_in_variables5|$pwd_in_variables6|$pwd_in_variables7|$pwd_in_variables8|$pwd_in_variables9|$pwd_in_variables10|$pwd_in_variables11).*[=:].+" '{}' "$password_find_exec_end" 2>/dev/null | sed '/^.\{150\}./d' | grep -Ev "^#" | grep -iv "linpeas" | sort | uniq | head -n 70 | sed -${E} "s,$pwd_in_variables1,${SED_RED},g" | sed -${E} "s,$pwd_in_variables2,${SED_RED},g" | sed -${E} "s,$pwd_in_variables3,${SED_RED},g" | sed -${E} "s,$pwd_in_variables4,${SED_RED},g" | sed -${E} "s,$pwd_in_variables5,${SED_RED},g" | sed -${E} "s,$pwd_in_variables6,${SED_RED},g" | sed -${E} "s,$pwd_in_variables7,${SED_RED},g" | sed -${E} "s,$pwd_in_variables8,${SED_RED},g" | sed -${E} "s,$pwd_in_variables9,${SED_RED},g" | sed -${E} "s,$pwd_in_variables10,${SED_RED},g" | sed -${E} "s,$pwd_in_variables11,${SED_RED},g" &
fi
wait
echo ""
@@ -0,0 +1,43 @@
# Title: Container - checkDockerOverlayStorage
# ID: checkDockerOverlayStorage
# Author: Carlos Polop
# Last Update: 09-10-2026
# Description: List a bounded set of already-mounted Docker overlay roots traversable by this unprivileged account.
# License: GNU GPL
# Version: 1.0
# Functions Used:
# Global Variables:
# Initial Functions:
# Generated Global Variables: $cdos_mounts, $cdos_path, $cdos_root
# Fat linpeas: 0
# Small linpeas: 1
checkDockerOverlayStorage() {
# Optional paths allow a synthetic mount table and directory tree in tests.
cdos_root="${1:-/var/lib/docker}"
cdos_mounts="${2:-/proc/self/mountinfo}"
[ "$(id -u 2>/dev/null)" = "0" ] && return
[ -d "$cdos_root/overlay2" ] && [ -x "$cdos_root" ] &&
[ -x "$cdos_root/overlay2" ] && [ -r "$cdos_mounts" ] || return 0
# The mount table gives exact active paths even when overlay2 is not listable.
# Accept only overlay filesystem mounts at 64-hex-id merged roots.
# Stop after twelve candidates.
awk -v prefix="$cdos_root/overlay2/" '
index($5, prefix) == 1 {
for (i = 7; i <= NF; i++) if ($i == "-") break
if (i >= NF || $(i + 1) != "overlay") next
suffix = substr($5, length(prefix) + 1)
if (length(suffix) != 71 || substr(suffix, 1, 64) !~ /^[0-9a-f]+$/ ||
substr(suffix, 65) != "/merged") next
if ($6 ~ /(^|,)nosuid(,|$)/ || $6 ~ /(^|,)noexec(,|$)/) next
if (!seen[$5]++) {
print $5
if (++count >= 12) exit
}
}
' "$cdos_mounts" 2>/dev/null | while IFS= read -r cdos_path; do
[ -d "$cdos_path" ] && [ -x "$cdos_path" ] && printf '%s\n' "$cdos_path"
done
}
@@ -2,13 +2,13 @@
# ID: checkDockerVersionExploits
# Author: Carlos Polop
# Last Update: 22-08-2023
# Description: Check if the container is vulnerable to CVE-2019-13139 and CVE-2019-5736
# Description: Compare Docker version with the CVE-2019-13139, CVE-2019-5736, and CVE-2021-41091 fix thresholds.
# License: GNU GPL
# Version: 1.0
# Functions Used: echo_no, echo_not_found
# Global Variables: $dockerVersion
# Initial Functions:
# Generated Global Variables: $VULN_CVE_2019_13139, $VULN_CVE_2019_5736, $VULN_CVE_2021_41091
# Generated Global Variables: $docker_2019_status, $docker_41091_status, $VULN_CVE_2019_13139, $VULN_CVE_2019_5736, $VULN_CVE_2021_41091
# Fat linpeas: 0
# Small linpeas: 1
@@ -21,18 +21,51 @@ checkDockerVersionExploits() {
return
fi
VULN_CVE_2019_13139="$(echo_no)"
if [ "$(echo $dockerVersion | sed 's,\.,,g')" -lt "1895" ]; then
VULN_CVE_2019_13139="Yes"
fi
# Docker fixed the build-ref issue in 18.09.4 and bundled a fixed runc in
# 18.09.2. Compare components; concatenation misses zero-padded minor
# releases such as 18.09.1. A separately packaged runc still needs review.
docker_2019_status="$(printf '%s\n' "$dockerVersion" | awk '
length($0) > 64 { exit }
match($0, /^[0-9]+\.[0-9]+\.[0-9]+/) {
suffix = substr($0, RLENGTH + 1)
if (suffix ~ /^-(rc|alpha|beta|dev)/ ||
(suffix != "" && suffix !~ /^[+-][A-Za-z0-9._-]+$/)) exit
split(substr($0, 1, RLENGTH), version, ".")
if (length(version[1]) > 4 || length(version[2]) > 4 ||
length(version[3]) > 6) exit
build = version[1] < 18 || (version[1] == 18 &&
(version[2] < 9 || (version[2] == 9 && version[3] < 4)))
runtime = version[1] < 18 || (version[1] == 18 &&
(version[2] < 9 || (version[2] == 9 && version[3] < 2)))
print (build ? "Yes" : "No") ":" (runtime ? "Yes" : "No")
}
')"
case "$docker_2019_status" in
Yes:Yes) VULN_CVE_2019_13139="Yes"; VULN_CVE_2019_5736="Yes" ;;
Yes:No) VULN_CVE_2019_13139="Yes"; VULN_CVE_2019_5736="$(echo_no)" ;;
No:No) VULN_CVE_2019_13139="$(echo_no)"; VULN_CVE_2019_5736="$(echo_no)" ;;
*) VULN_CVE_2019_13139="$(echo_not_found)"; VULN_CVE_2019_5736="$(echo_not_found)" ;;
esac
VULN_CVE_2019_5736="$(echo_no)"
if [ "$(echo $dockerVersion | sed 's,\.,,g')" -lt "1893" ]; then
VULN_CVE_2019_5736="Yes"
fi
VULN_CVE_2021_41091="$(echo_no)"
if [ "$(echo $dockerVersion | sed 's,\.,,g')" -lt "20109" ]; then
VULN_CVE_2021_41091="Yes"
fi
# Numeric components avoid treating 24.0.9 as older than 20.10.9.
# Package suffixes such as +dfsg1 do not change the upstream version.
docker_41091_status="$(printf '%s\n' "$dockerVersion" | awk '
length($0) <= 64 && match($0, /^[0-9]+\.[0-9]+\.[0-9]+/) {
suffix = substr($0, RLENGTH + 1)
if (suffix ~ /^-(rc|alpha|beta|dev)/ || (suffix != "" &&
suffix !~ /^[+-][A-Za-z0-9._-]+$/)) exit
split(substr($0, 1, RLENGTH), version, ".")
if (length(version[1]) > 4 || length(version[2]) > 4 ||
length(version[3]) > 6) exit
if (version[1] < 20 ||
(version[1] == 20 && (version[2] < 10 ||
(version[2] == 10 && version[3] < 9)))) print "Yes"
else print "No"
}
')"
case "$docker_41091_status" in
Yes) VULN_CVE_2021_41091="Yes" ;;
No) VULN_CVE_2021_41091="$(echo_no)" ;;
*) VULN_CVE_2021_41091="$(echo_not_found)" ;;
esac
}
@@ -0,0 +1,54 @@
# Title: Functions - checkGlibcTunablesCVE20234911
# ID: checkGlibcTunablesCVE20234911
# Author: PEASS contributors
# Last Update: 09-10-2026
# Description: Review installed GNU C Library package against vendor fixes for CVE-2023-4911 without executing a malformed tunables probe.
# License: GNU GPL
# Version: 1.0
# Mitre: T1068
# Functions Used: print_3title, print_info
# Global Variables: $ROOT_FOLDER
# Initial Functions:
# Generated Global Variables: $gt4911_root, $gt4911_release, $gt4911_id, $gt4911_codename, $gt4911_fixed, $gt4911_record, $gt4911_version
# Fat linpeas: 0
# Small linpeas: 1
checkGlibcTunablesCVE20234911() {
command -v dpkg-query >/dev/null 2>&1 || return 0
command -v dpkg >/dev/null 2>&1 || return 0
gt4911_root="${ROOT_FOLDER:-/}"
case "$gt4911_root" in */) ;; *) gt4911_root="$gt4911_root/" ;; esac
gt4911_release="${gt4911_root}etc/os-release"
[ -r "$gt4911_release" ] || return 0
gt4911_id="$(sed -n 's/^ID=//p' "$gt4911_release" | head -n 1 | tr -d '"')"
gt4911_codename="$(sed -n 's/^VERSION_CODENAME=//p' "$gt4911_release" | head -n 1 | tr -d '"')"
# Compare vendor package releases, not upstream glibc versions: Debian
# bullseye backported the affected parser into its older 2.31 package.
case "$gt4911_id:$gt4911_codename" in
ubuntu:jammy) gt4911_fixed="2.35-0ubuntu3.4" ;;
ubuntu:lunar) gt4911_fixed="2.37-0ubuntu2.1" ;;
ubuntu:mantic|ubuntu:noble) gt4911_fixed="2.38-1ubuntu6" ;;
debian:bullseye) gt4911_fixed="2.31-13+deb11u7" ;;
debian:bookworm) gt4911_fixed="2.36-9+deb12u3" ;;
*) return 0 ;;
esac
gt4911_record="$(dpkg-query --admindir="${gt4911_root}var/lib/dpkg" -W -f='$''{Status}|$''{Version}\n' libc6 2>/dev/null | head -n 1)"
case "$gt4911_record" in
'install ok installed|'*) gt4911_version="${gt4911_record#*|}" ;;
*) return 0 ;;
esac
[ -n "$gt4911_version" ] || return 0
print_3title "GNU C Library tunables package review (CVE-2023-4911)" "T1068"
print_info "https://ubuntu.com/security/CVE-2023-4911"
print_info "https://security-tracker.debian.org/tracker/CVE-2023-4911"
echo "Installed libc6: $gt4911_version; vendor fixed threshold for $gt4911_id $gt4911_codename: $gt4911_fixed"
if dpkg --compare-versions "$gt4911_version" lt "$gt4911_fixed"; then
echo "Package below vendor fix: review candidate only. Confirm installed loader, package patch state, a reachable SUID target, and NoNewPrivs/nosuid or other mitigations; no exploit probe was run."
else
echo "Installed package meets this vendor's fixed threshold; confirm the running loader after package updates."
fi
}
@@ -0,0 +1,58 @@
# Title: Functions - checkLibblockdevCVE20256019
# ID: checkLibblockdevCVE20256019
# Author: PEASS-ng
# Last Update: 08-10-2026
# Description: Passive udisks/libblockdev and polkit evidence for CVE-2025-6019.
# License: GNU GPL
# Version: 1.0
# Mitre: T1068
# Functions Used: lp_suse_numeric_version_lt, print_3title, print_info
# Global Variables: $ROOT_FOLDER
# Initial Functions:
# Generated Global Variables: $bd6019_root, $bd6019_daemon, $bd6019_candidate, $bd6019_service, $bd6019_policy, $bd6019_package, $bd6019_os, $bd6019_id, $bd6019_version
# Fat linpeas: 0
# Small linpeas: 1
checkLibblockdevCVE20256019() { (
[ "$(uname -s 2>/dev/null)" = Linux ] || return 0
bd6019_root="${ROOT_FOLDER:-/}"
case "$bd6019_root" in */) ;; *) bd6019_root="$bd6019_root/" ;; esac
bd6019_daemon=""
for bd6019_candidate in usr/libexec/udisks2/udisksd usr/lib/udisks2/udisksd lib/udisks2/udisksd; do
if [ -x "${bd6019_root}${bd6019_candidate}" ]; then
bd6019_daemon="${bd6019_root}${bd6019_candidate}"
break
fi
done
[ -n "$bd6019_daemon" ] || return 0
bd6019_service="${bd6019_root}usr/share/dbus-1/system-services/org.freedesktop.UDisks2.service"
[ -r "$bd6019_service" ] || return 0
sed -n '1,100p;101q' "$bd6019_service" | grep -Eq '^Name=org[.]freedesktop[.]UDisks2$' || return 0
[ -x "${bd6019_root}usr/sbin/xfs_growfs" ] || [ -x "${bd6019_root}sbin/xfs_growfs" ] || return 0
bd6019_policy="${bd6019_root}usr/share/polkit-1/actions/org.freedesktop.udisks2.policy"
[ -r "$bd6019_policy" ] || return 0
# The resize path is useful to an active user only when this action permits it.
sed -n '1,1000p;1001q' "$bd6019_policy" | awk '
/<action id="org[.]freedesktop[.]udisks2[.]modify-device">/ { in_action=1 }
in_action && /<allow_active>[[:space:]]*yes[[:space:]]*<\/allow_active>/ { found=1 }
in_action && /<\/action>/ { in_action=0 }
END { exit !found }
' || return 0
bd6019_os="${bd6019_root}etc/os-release"
[ -r "$bd6019_os" ] || return 0
bd6019_id="$(sed -n '1,100{s/^ID=[" ]*\([^" ]*\).*/\1/p;};101q' "$bd6019_os" | head -n 1)"
bd6019_version="$(sed -n '1,100{s/^VERSION_ID=[" ]*\([^" ]*\).*/\1/p;};101q' "$bd6019_os" | head -n 1)"
case "$bd6019_id:$bd6019_version" in
sles:15.6|sles:15.7|sled:15.6|sled:15.7|opensuse-leap:15.6|opensuse:15.6) ;;
*) return 0 ;;
esac
command -v timeout >/dev/null 2>&1 && command -v rpm >/dev/null 2>&1 || return 0
bd6019_package="$(timeout 3 rpm --root "$bd6019_root" -q --qf '%{VERSION}-%{RELEASE}\n' libbd_fs2 2>/dev/null | awk 'NR == 1 && /^[0-9]/ { print }')"
lp_suse_numeric_version_lt "$bd6019_package" "2.26-150400.3.5.1" || return 0
print_3title "udisks/libblockdev XFS resize path (CVE-2025-6019)" "T1068"
print_info "https://www.suse.com/security/cve/CVE-2025-6019.html"
echo "udisks D-Bus activation, xfs_growfs, and allow_active=yes modify-device policy are present"
printf 'libbd_fs2 package: %s (below SUSE fixed version 2.26-150400.3.5.1)\n' "$bd6019_package"
echo "Effective patch status: unknown; verify vendor libblockdev advisory and local overrides"
echo ""
) ; }
@@ -0,0 +1,79 @@
# Title: Functions - checkPamAllowActiveCVE20256018
# ID: checkPamAllowActiveCVE20256018
# Author: PEASS-ng
# Last Update: 08-10-2026
# Description: Passive SUSE 15 PAM stack evidence for CVE-2025-6018.
# License: GNU GPL
# Version: 1.0
# Mitre: T1068
# Functions Used: lp_suse_numeric_version_lt, print_3title, print_info
# Global Variables: $ROOT_FOLDER
# Initial Functions:
# Generated Global Variables: $pa6018_root, $pa6018_os, $pa6018_id, $pa6018_version, $pa6018_auth, $pa6018_session, $pa6018_sshd, $pa6018_env_line, $pa6018_pam, $pa6018_pam_config, $pa6018_config, $pa6018_status
# Fat linpeas: 0
# Small linpeas: 1
checkPamAllowActiveCVE20256018() { (
[ "$(uname -s 2>/dev/null)" = Linux ] || return 0
pa6018_root="${ROOT_FOLDER:-/}"
case "$pa6018_root" in */) ;; *) pa6018_root="$pa6018_root/" ;; esac
pa6018_os="${pa6018_root}etc/os-release"
[ -r "$pa6018_os" ] || return 0
pa6018_id="$(sed -n '1,100{s/^ID=[" ]*\([^" ]*\).*/\1/p;};101q' "$pa6018_os" | head -n 1)"
pa6018_version="$(sed -n '1,100{s/^VERSION_ID=[" ]*\([^" ]*\).*/\1/p;};101q' "$pa6018_os" | head -n 1)"
case "$pa6018_id:$pa6018_version" in
sles:15*|sled:15*|opensuse-leap:15*|opensuse:15*) ;;
*) return 0 ;;
esac
pa6018_auth="${pa6018_root}etc/pam.d/common-auth"
pa6018_session="${pa6018_root}etc/pam.d/common-session"
pa6018_sshd="${pa6018_root}etc/pam.d/sshd"
[ -r "$pa6018_auth" ] && [ -r "$pa6018_session" ] && [ -r "$pa6018_sshd" ] || return 0
# Inspect only the named SSH stack files. A user supplied PAM environment is
# relevant only when pam_env precedes pam_systemd in this stack.
sed -n '1,200p;201q' "$pa6018_sshd" | grep -Eq '^[[:space:]]*(auth[[:space:]]+(include|substack)[[:space:]]+common-auth|@include[[:space:]]+common-auth)' || return 0
sed -n '1,200p;201q' "$pa6018_sshd" | grep -Eq '^[[:space:]]*(session[[:space:]]+(include|substack)[[:space:]]+common-session|@include[[:space:]]+common-session)' || return 0
pa6018_env_line="$(sed -n '1,200p;201q' "$pa6018_auth" | grep -E '^[[:space:]]*auth[[:space:]].*pam_env[.]so([[:space:]]|$)' | head -n 1)"
[ -n "$pa6018_env_line" ] || return 0
sed -n '1,200p;201q' "$pa6018_session" | grep -Eq '^[[:space:]]*session[[:space:]].*pam_systemd[.]so([[:space:]]|$)' || return 0
if printf '%s\n' "$pa6018_env_line" | grep -Eq '(^|[[:space:]])user_readenv=0($|[[:space:]])'; then
return 0
elif printf '%s\n' "$pa6018_env_line" | grep -Eq '(^|[[:space:]])user_readenv=1($|[[:space:]])'; then
pa6018_config="explicit user_readenv=1"
else
pa6018_config="implicit user_readenv; effective default unknown"
fi
pa6018_pam="unknown"
pa6018_pam_config="unknown"
if command -v timeout >/dev/null 2>&1 && command -v rpm >/dev/null 2>&1; then
pa6018_pam="$(timeout 3 rpm --root "$pa6018_root" -q --qf '%{VERSION}-%{RELEASE}\n' pam 2>/dev/null | awk 'NR == 1 && /^[0-9]/ { print }')"
[ -n "$pa6018_pam" ] || pa6018_pam="unknown"
pa6018_pam_config="$(timeout 3 rpm --root "$pa6018_root" -q --qf '%{VERSION}-%{RELEASE}\n' pam-config 2>/dev/null | awk 'NR == 1 && /^[0-9]/ { print }')"
[ -n "$pa6018_pam_config" ] || pa6018_pam_config="unknown"
fi
pa6018_status="unknown"
case "$pa6018_id:$pa6018_version" in
sles:15.6|sles:15.7|sled:15.6|sled:15.7|opensuse-leap:15.6|opensuse:15.6)
if [ "$pa6018_config" = "explicit user_readenv=1" ]; then
# The PAM update changes the default; an explicit setting overrides it.
pa6018_status="explicit user_readenv=1 in the effective auth stack"
elif lp_suse_numeric_version_lt "$pa6018_pam" "1.3.0-150000.6.83.1"; then
# The package that controls pam_env's default is below SUSE's fix.
# The actual stack still has pam_env in auth, regardless of whether
# a newer pam-config package has been installed since it was written.
pa6018_status="pam below SUSE fixed package version"
else
return 0
fi
;;
*) return 0 ;;
esac
print_3title "SUSE PAM allow_active path (CVE-2025-6018)" "T1068"
print_info "https://www.suse.com/security/cve/CVE-2025-6018.html"
echo "SSH includes common-auth and common-session; common-auth loads pam_env before common-session loads pam_systemd"
echo "pam_env: $pa6018_config; pam package: $pa6018_pam; pam-config package: $pa6018_pam_config"
echo "Package evidence: $pa6018_status; effective patch status: unknown, verify vendor advisories and PAM configuration"
echo ""
) ; }
@@ -0,0 +1,159 @@
# Title: Function - checkRootWritableProcessPaths
# ID: checkRootWritableProcessPaths
# Author: PEASS-ng
# Last Update: 2026-10-09
# Description: Passively correlate observed UID 0 commands with modifiable executable, script, or literal PHP include paths.
# License: GNU GPL
# Version: 1.0
# Mitre: T1574
# Functions Used: print_3title
# Global Variables:
# Initial Functions:
# Generated Global Variables: $rwpp_proc_root, $rwpp_uid, $rwpp_seen, $rwpp_found, $rwpp_php_seen, $rwpp_entry, $rwpp_pid, $rwpp_effective_uid, $rwpp_kind, $rwpp_path, $rwpp_cmd, $rwpp_parent, $rwpp_reason, $rwpp_parent_uid, $rwpp_entry_uid, $rwpp_php_base, $rwpp_php_script, $rwpp_php_pid, $rwpp_php_lines, $rwpp_php_relative, $rwpp_php_target, $rwpp_php_walk, $rwpp_php_rest, $rwpp_php_safe, $rwpp_php_part, $rwpp_php_found
# Fat linpeas: 0
# Small linpeas: 1
# Inspect literal includes in root PHP consumers. Do not follow dynamic
# expressions, symlinks, parent traversal, or more than four consumers and
# eight 8 KiB source prefixes per document root.
rwpp_php_literal_includes() (
rwpp_php_base=$1
rwpp_php_script=$2
rwpp_php_pid=$3
case "$rwpp_php_base:$rwpp_php_script" in *[!A-Za-z0-9_./:-]*) exit 0 ;; esac
case "$rwpp_php_base:$rwpp_php_script" in *'//'*|*'/./'*|*'/../'*|*'/.'|*'/..') exit 0 ;; esac
[ -f "$rwpp_php_script" ] && [ -r "$rwpp_php_script" ] || exit 0
[ -L "$rwpp_php_script" ] && exit 0
rwpp_php_lines=$(LC_ALL=C dd if="$rwpp_php_script" bs=8192 count=1 2>/dev/null | sed -n '1,64p' |
sed -nE "s/^[[:space:]]*(include|include_once|require|require_once)[[:space:]]*\\(?[[:space:]]*['\"]([A-Za-z0-9_./-]+\\.php)['\"][[:space:]]*\\)?[[:space:]]*;.*/\\2/p" |
sed -n '1,4p')
[ -n "$rwpp_php_lines" ] || exit 0
rwpp_php_found=0
for rwpp_php_relative in $rwpp_php_lines; do
case "$rwpp_php_relative" in *'//'*|*'/./'*|*'/../'*|*'/.'|*'/..'|/*) continue ;; esac
rwpp_php_target=${rwpp_php_script%/*}/$rwpp_php_relative
[ -f "$rwpp_php_target" ] && [ -w "$rwpp_php_target" ] || continue
rwpp_php_walk=''
rwpp_php_rest=${rwpp_php_target#/}
rwpp_php_safe=1
while [ -n "$rwpp_php_rest" ]; do
rwpp_php_part=${rwpp_php_rest%%/*}
rwpp_php_walk=$rwpp_php_walk/$rwpp_php_part
if [ -L "$rwpp_php_walk" ]; then rwpp_php_safe=0; break; fi
case "$rwpp_php_rest" in */*) rwpp_php_rest=${rwpp_php_rest#*/} ;; *) rwpp_php_rest='' ;; esac
done
[ "$rwpp_php_safe" -eq 1 ] || continue
printf 'PID %s: root PHP source %s includes writable PHP file %s (literal path; execution path and access policy require review)\n' \
"$rwpp_php_pid" "$rwpp_php_script" "$rwpp_php_target"
rwpp_php_found=$((rwpp_php_found + 1))
[ "$rwpp_php_found" -lt 2 ] || break
done
)
checkRootWritableProcessPaths() {
[ "$(uname -s 2>/dev/null)" = Linux ] || return 0
rwpp_uid=$(id -u 2>/dev/null) || return 0
[ "$rwpp_uid" != 0 ] || return 0
rwpp_proc_root=${1:-/proc}
[ -d "$rwpp_proc_root" ] || return 0
rwpp_seen=0
rwpp_found=0
rwpp_php_seen=0
for rwpp_entry in "$rwpp_proc_root"/[0-9]*; do
[ -d "$rwpp_entry" ] || continue
rwpp_pid=${rwpp_entry##*/}
case "$rwpp_pid" in *[!0-9]*|'') continue ;; esac
rwpp_seen=$((rwpp_seen + 1))
[ "$rwpp_seen" -le 200 ] || break
[ -r "$rwpp_entry/status" ] && [ -r "$rwpp_entry/cmdline" ] || continue
rwpp_effective_uid=$(awk '$1 == "Uid:" { print $3; exit }' "$rwpp_entry/status" 2>/dev/null)
[ "$rwpp_effective_uid" = 0 ] || continue
# od preserves NUL argument boundaries. Reading 1025 bytes lets awk reject
# a truncated command instead of interpreting an incomplete script operand.
while IFS='|' read -r rwpp_kind rwpp_path rwpp_cmd; do
case "$rwpp_kind" in
phpdocroot)
[ "$rwpp_php_seen" -lt 4 ] && [ -d "$rwpp_path" ] && [ ! -L "$rwpp_path" ] || continue
rwpp_php_seen=$((rwpp_php_seen + 1))
find "$rwpp_path" -maxdepth 1 -type f -name '*.php' -print 2>/dev/null | head -n 8 |
while IFS= read -r rwpp_php_script; do
rwpp_php_literal_includes "$rwpp_path" "$rwpp_php_script" "$rwpp_pid"
done
continue ;;
phpscript)
[ "$rwpp_php_seen" -lt 4 ] || continue
rwpp_php_seen=$((rwpp_php_seen + 1))
rwpp_php_literal_includes "${rwpp_path%/*}" "$rwpp_path" "$rwpp_pid"
continue ;;
esac
[ -f "$rwpp_path" ] || continue
rwpp_reason=''
if [ -w "$rwpp_path" ]; then
rwpp_reason='file writable'
fi
rwpp_parent=${rwpp_path%/*}
[ -n "$rwpp_parent" ] || rwpp_parent=/
if [ -d "$rwpp_parent" ] && [ -w "$rwpp_parent" ] && [ -x "$rwpp_parent" ]; then
# For sticky directories either the directory or the entry must belong
# to the caller. stat without -L gets the entry owner for symlinks.
if [ -k "$rwpp_parent" ]; then
rwpp_parent_uid=$(stat -Lc '%u' "$rwpp_parent" 2>/dev/null)
rwpp_entry_uid=$(stat -c '%u' "$rwpp_path" 2>/dev/null)
if [ "$rwpp_parent_uid" = "$rwpp_uid" ] || [ "$rwpp_entry_uid" = "$rwpp_uid" ]; then
rwpp_reason="${rwpp_reason:+$rwpp_reason; }parent allows replacement"
fi
else
rwpp_reason="${rwpp_reason:+$rwpp_reason; }parent allows replacement"
fi
fi
[ -n "$rwpp_reason" ] || continue
if [ "$rwpp_found" -eq 0 ]; then
print_3title 'Observed UID 0 process paths modifiable by current user' 'T1574'
fi
printf 'PID %s: %s | %s: %s (%s)\n' "$rwpp_pid" "$rwpp_cmd" "$rwpp_kind" "$rwpp_path" "$rwpp_reason"
rwpp_found=$((rwpp_found + 1))
[ "$rwpp_found" -lt 12 ] || break
done <<EOF
$(dd if="$rwpp_entry/cmdline" bs=1025 count=1 2>/dev/null | od -An -v -tu1 | awk '
{
for (i = 1; i <= NF; i++) {
byte = $i + 0; count++
if (count > 1024) invalid = 1
if (byte == 0) {
argc++; arg[argc] = current; current = ""; ended = 1
} else {
ended = 0
if (byte < 32 || byte > 126) invalid = 1
current = current sprintf("%c", byte)
}
}
}
END {
if (invalid || !ended || !argc) exit
exe = arg[1]
name = exe; sub(/^.*\//, "", name)
command = exe
for (i = 2; i <= argc && i <= 3; i++) command = command " " arg[i]
gsub(/\|/, "?", command)
if (exe ~ /^\// && exe !~ /\|/) print "executable|" exe "|" command
if (name == "php" && exe ~ /^\//) {
if (arg[2] == "-S" && arg[4] == "-t" && arg[5] ~ /^\/[A-Za-z0-9_.\/-]+$/)
print "phpdocroot|" arg[5] "|" command
else if (arg[2] == "-f" && arg[3] ~ /^\/[A-Za-z0-9_.\/-]+\.php$/)
print "phpscript|" arg[3] "|" command
else if (arg[2] ~ /^\/[A-Za-z0-9_.\/-]+\.php$/)
print "phpscript|" arg[2] "|" command
}
if (name ~ /^(sh|bash|dash|ash|ksh|zsh|perl|ruby|php)$/ || name ~ /^python([23]([.][0-9]+)?)?$/) {
i = 2
if (arg[i] == "--") i++
if (arg[i] ~ /^\// && arg[i] !~ /\|/) print "script|" arg[i] "|" command
}
}')
EOF
[ "$rwpp_found" -lt 12 ] || break
done
[ "$rwpp_found" -eq 0 ] || echo ''
}
@@ -0,0 +1,105 @@
# Title: Functions - checkSnapConfineCVE20263888
# ID: checkSnapConfineCVE20263888
# Author: Chack Agent
# Last Update: 09-10-2026
# Description: Passive prerequisites for the snap-confine and tmpfiles race (CVE-2026-3888).
# License: GNU GPL
# Version: 1.0
# Mitre: T1068
# Functions Used: print_3title, print_info
# Global Variables: $ROOT_FOLDER, $E, $SED_RED_YELLOW
# Initial Functions:
# Generated Global Variables: $sc3888_root, $sc3888_path, $sc3888_priv, $sc3888_dir, $sc3888_version, $sc3888_release, $sc3888_fixed, $sc3888_state, $sc3888_rule, $sc3888_age, $sc3888_timer
# Fat linpeas: 0
# Small linpeas: 1
checkSnapConfineCVE20263888() {
sc3888_root="${ROOT_FOLDER:-/}"
case "$sc3888_root" in */) ;; *) sc3888_root="${sc3888_root}/" ;; esac
sc3888_path="${sc3888_root}usr/lib/snapd/snap-confine"
[ -f "$sc3888_path" ] || return 0
sc3888_priv=""
if [ -u "$sc3888_path" ]; then
sc3888_priv="setuid"
elif command -v getcap >/dev/null 2>&1 &&
getcap "$sc3888_path" 2>/dev/null | grep -q 'cap_sys_admin'; then
sc3888_priv="file capabilities"
fi
[ -n "$sc3888_priv" ] || return 0
sc3888_release="$(sed -n 's/^VERSION_ID="\{0,1\}\([0-9][0-9.]*\)"\{0,1\}$/\1/p' "${sc3888_root}etc/os-release" 2>/dev/null | head -n 1)"
sc3888_version=""
if command -v dpkg-query >/dev/null 2>&1; then
sc3888_version="$(dpkg-query --admindir="${sc3888_root}var/lib/dpkg" -W -f='$''{Status} $''{Version}\n' snapd 2>/dev/null | awk 'NR==1 && $1=="install" && $2=="ok" && $3=="installed" {print $4; exit}')"
fi
sc3888_fixed=""
case "$sc3888_release" in
16.04) sc3888_fixed="2.61.4ubuntu0.16.04.1+esm2" ;;
18.04) sc3888_fixed="2.61.4ubuntu0.18.04.1+esm2" ;;
20.04) sc3888_fixed="2.67.1+20.04ubuntu1~esm1" ;;
22.04) sc3888_fixed="2.73+ubuntu22.04.1" ;;
24.04) sc3888_fixed="2.73+ubuntu24.04.2" ;;
25.10) sc3888_fixed="2.73+ubuntu25.10.1" ;;
26.04) sc3888_fixed="2.74.1+ubuntu26.04.3" ;;
esac
sc3888_state="unknown"
# Compare complete Debian versions only for Ubuntu packages on a known release.
# A local rebuild, epoch, or unexpected suffix can contain a backported fix.
if grep -q '^ID=\("\{0,1\}\)ubuntu\1$' "${sc3888_root}etc/os-release" 2>/dev/null &&
[ -n "$sc3888_version" ] && [ -n "$sc3888_fixed" ] &&
command -v dpkg >/dev/null 2>&1; then
case "$sc3888_version" in
*"$sc3888_release"*|*"ubuntu${sc3888_release}"*)
if dpkg --compare-versions "$sc3888_version" ge "$sc3888_fixed"; then
sc3888_state="at-or-above-fix"
else
sc3888_state="below-fix"
fi
;;
esac
fi
# Inspect the usual effective tmp.conf by directory precedence. Other rule
# names and runtime unit state need a full tmpfiles/systemd evaluation.
sc3888_rule=""
for sc3888_dir in etc run usr/local/lib usr/lib lib; do
if [ -r "${sc3888_root}${sc3888_dir}/tmpfiles.d/tmp.conf" ]; then
sc3888_rule="${sc3888_root}${sc3888_dir}/tmpfiles.d/tmp.conf"
break
fi
done
sc3888_age=""
if [ -n "$sc3888_rule" ]; then
sc3888_age="$(awk 'NR > 100 {exit} $1 ~ /^[dDqQ][!~+=-]*$/ && $2 == "/tmp" && $6 ~ /^[0-9]+[smhdwMy]*$/ {print $6; exit}' "$sc3888_rule" 2>/dev/null)"
fi
sc3888_timer="unit not found"
if [ -e "${sc3888_root}usr/lib/systemd/system/systemd-tmpfiles-clean.timer" ] ||
[ -e "${sc3888_root}lib/systemd/system/systemd-tmpfiles-clean.timer" ]; then
sc3888_timer="unit present; runtime activation unverified"
fi
if [ -e "${sc3888_root}etc/systemd/system/timers.target.wants/systemd-tmpfiles-clean.timer" ]; then
sc3888_timer="enable link present; runtime activation unverified"
fi
print_3title "snap-confine/tmpfiles race prerequisites (CVE-2026-3888)" "T1068"
print_info "https://ubuntu.com/security/CVE-2026-3888"
echo "snap-confine: $sc3888_priv ($sc3888_path)"
echo "snapd package: ${sc3888_version:-unknown}; Ubuntu release: ${sc3888_release:-unknown}"
case "$sc3888_state" in
at-or-above-fix) echo "Package version is at or above the Ubuntu advisory fix ($sc3888_fixed); verify local package provenance" ;;
below-fix) echo "Package version is below the Ubuntu advisory fix ($sc3888_fixed); verify backports" ;;
*) echo "Package fix status unclassified; verify distribution patches and package provenance" ;;
esac
if [ -n "$sc3888_age" ]; then
echo "/tmp cleanup rule: age $sc3888_age ($sc3888_rule)"
else
echo "/tmp cleanup rule: not confirmed in standard tmp.conf locations"
fi
echo "systemd-tmpfiles-clean.timer: $sc3888_timer"
if [ "$sc3888_state" = "below-fix" ] && [ -n "$sc3888_age" ] &&
[ "$sc3888_timer" != "unit not found" ]; then
echo "Potential CVE-2026-3888 prerequisites observed; timer activity, snap layout, and backports remain unverified" | sed -${E} "s,.*,${SED_RED_YELLOW},"
fi
echo ""
}
@@ -0,0 +1,99 @@
# Title: Function - checkSystemdWritableDropins
# ID: checkSystemdWritableDropins
# Author: PEASS-ng contributors
# Last Update: 08-10-2026
# Description: Passively identify writable systemd unit and service drop-in directories.
# License: GNU GPL
# Version: 1.0
# Mitre: T1543.002
# Functions Used: print_3title, print_info
# Global Variables: $E, $IAMROOT, $SED_RED_YELLOW
# Initial Functions:
# Generated Global Variables: $sdwdd_system_dir, $sdwdd_runtime_dir, $sdwdd_dir, $sdwdd_unit, $sdwdd_checked, $sdwdd_properties, $sdwdd_property, $sdwdd_active, $sdwdd_loaded, $sdwdd_user, $sdwdd_user_seen, $sdwdd_dynamic_user, $sdwdd_root_dir, $sdwdd_root_image, $sdwdd_private_users, $sdwdd_found
# Fat linpeas: 0
# Small linpeas: 1
checkSystemdWritableDropins() {
[ "$(uname -s 2>/dev/null)" = "Linux" ] || return 0
[ -z "${IAMROOT:-}" ] || return 0
[ "$(id -u 2>/dev/null)" != "0" ] || return 0
command -v systemctl >/dev/null 2>&1 || return 0
# Optional directory arguments let tests use fixtures without touching
# the host's systemd tree. Normal calls inspect fixed systemd paths only.
sdwdd_system_dir=${1:-/etc/systemd/system}
sdwdd_runtime_dir=${2:-/run/systemd/system}
[ -d "$sdwdd_system_dir" ] || return 0
# A user may create a new unit even without directory read permission.
# This is only a lead until a privileged manager loads and starts that unit.
if [ -d "$sdwdd_runtime_dir" ] && [ ! -L "$sdwdd_system_dir" ] &&
[ -w "$sdwdd_system_dir" ] && [ -x "$sdwdd_system_dir" ]; then
print_3title "Writable systemd system unit directory" "T1543.002"
print_info "https://book.hacktricks.wiki/en/linux-hardening/interesting-files-permissions/write-to-root.html#service--socket-files"
echo "$sdwdd_system_dir is writable and searchable; verify effective sudo start/restart policy, unit loading, and service identity before treating a new unit as executable" |
sed -"${E}" "s,.*,${SED_RED_YELLOW},"
echo ""
fi
command -v timeout >/dev/null 2>&1 || return 0
sdwdd_checked=0
sdwdd_found=""
for sdwdd_dir in "$sdwdd_system_dir"/*.service.d; do
[ -d "$sdwdd_dir" ] && [ ! -L "$sdwdd_dir" ] || continue
[ -w "$sdwdd_dir" ] && [ -x "$sdwdd_dir" ] || continue
# A writable directory can accept a new .conf even if it is empty and
# every existing unit file is protected. Bound systemctl to 20 candidates.
[ "$sdwdd_checked" -lt 20 ] || break
sdwdd_checked=$((sdwdd_checked + 1))
sdwdd_unit=${sdwdd_dir##*/}
sdwdd_unit=${sdwdd_unit%.d}
sdwdd_properties=$(timeout -k 1 2 systemctl show \
-p LoadState -p ActiveState -p User -p DynamicUser \
-p RootDirectory -p RootImage -p PrivateUsers \
-- "$sdwdd_unit" 2>/dev/null) || continue
sdwdd_active=""
sdwdd_loaded=""
sdwdd_user=""
sdwdd_user_seen=""
sdwdd_dynamic_user=""
sdwdd_root_dir=""
sdwdd_root_image=""
sdwdd_private_users=""
while IFS= read -r sdwdd_property; do
case "$sdwdd_property" in
ActiveState=*) sdwdd_active=${sdwdd_property#ActiveState=} ;;
LoadState=*) sdwdd_loaded=${sdwdd_property#LoadState=} ;;
User=*) sdwdd_user=${sdwdd_property#User=}; sdwdd_user_seen=1 ;;
DynamicUser=*) sdwdd_dynamic_user=${sdwdd_property#DynamicUser=} ;;
RootDirectory=*) sdwdd_root_dir=${sdwdd_property#RootDirectory=} ;;
RootImage=*) sdwdd_root_image=${sdwdd_property#RootImage=} ;;
PrivateUsers=*) sdwdd_private_users=${sdwdd_property#PrivateUsers=} ;;
esac
done <<EOF
$sdwdd_properties
EOF
[ "$sdwdd_loaded" = "loaded" ] && [ "$sdwdd_active" = "active" ] || continue
[ -n "$sdwdd_user_seen" ] || continue
case "$sdwdd_user" in ""|root|0) ;; *) continue ;; esac
[ "$sdwdd_dynamic_user" != "yes" ] || continue
[ -z "$sdwdd_root_dir" ] && [ -z "$sdwdd_root_image" ] || continue
[ "$sdwdd_private_users" != "yes" ] || continue
if [ -z "$sdwdd_found" ]; then
print_3title "Writable systemd drop-in directories for active root services" "T1543.002"
print_info "https://book.hacktricks.wiki/en/linux-hardening/linux-basics/linux-privilege-escalation/index.html#services"
sdwdd_found=1
fi
echo "$sdwdd_unit: $sdwdd_dir (a new .conf could run on a later reload and restart)" |
sed -"${E}" "s,.*,${SED_RED_YELLOW},"
ls -ld "$sdwdd_dir" 2>/dev/null
done
[ -z "$sdwdd_found" ] || echo ""
}
@@ -1,14 +1,14 @@
# Title: Interesting Perms Files - check_critial_root_path
# ID: check_critial_root_path
# Author: Carlos Polop
# Last Update: 22-08-2023
# Last Update: 09-10-2026
# Description: Check if you have write privileges over critical root paths
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Functions Used:
# Global Variables: $USER, $wgroups
# Initial Functions:
# Generated Global Variables: folder_path
# Generated Global Variables: folder_path, folder_writable_files, folder_nonroot_files
# Fat linpeas: 0
# Small linpeas: 1
@@ -16,6 +16,8 @@
check_critial_root_path(){
folder_path="$1"
if [ -w "$folder_path" ]; then echo "You have write privileges over $folder_path" | sed -${E} "s,.*,${SED_RED_YELLOW},"; fi
if [ "$(find $folder_path -type f '(' '(' -user $USER ')' -or '(' -perm -o=w ')' -or '(' -perm -g=w -and '(' $wgroups ')' ')' ')' 2>/dev/null)" ]; then echo "You have write privileges over $(find $folder_path -type f '(' '(' -user $USER ')' -or '(' -perm -o=w ')' -or '(' -perm -g=w -and '(' $wgroups ')' ')' ')')" | sed -${E} "s,.*,${SED_RED_YELLOW},"; fi
if [ "$(find $folder_path -type f -not -user root 2>/dev/null)" ]; then echo "The following files aren't owned by root: $(find $folder_path -type f -not -user root 2>/dev/null)"; fi
}
folder_writable_files=$(find "$folder_path" -type f '(' '(' -user "$USER" ')' -or '(' -perm -o=w ')' -or '(' -perm -g=w -and '(' $wgroups ')' ')' ')' 2>/dev/null) || :
if [ "$folder_writable_files" ]; then echo "You have write privileges over $folder_writable_files" | sed -${E} "s,.*,${SED_RED_YELLOW},"; fi
folder_nonroot_files=$(find "$folder_path" -type f -not -user root 2>/dev/null) || :
if [ "$folder_nonroot_files" ]; then echo "The following files aren't owned by root: $folder_nonroot_files"; fi
}
@@ -0,0 +1,207 @@
# Title: Function - check_pg_basebackup_boundary
# ID: check_pg_basebackup_boundary
# Author: PEASS-ng
# Last Update: 2026-10-09
# Description: Correlate bounded root cron backups with writable PostgreSQL data and executable output.
# License: GNU GPL
# Version: 1.0
# Functions Used: pgbb_mount_options
# Global Variables: $PG_BASEBACKUP_DESTS
# Initial Functions:
# Generated Global Variables: $pgbb_command, $pgbb_count, $pgbb_cron, $pgbb_depth, $pgbb_dest, $pgbb_dest_meta, $pgbb_file, $pgbb_file_count, $pgbb_host, $pgbb_line, $pgbb_line_count, $pgbb_meta, $pgbb_opts, $pgbb_parent, $pgbb_path, $pgbb_size, $pgbb_source, $pgbb_source_meta, $pgbb_total, $pgbb_wrapper
# Fat linpeas: 0
# Small linpeas: 1
pgbb_metadata() {
stat -c '%u %a' "$1" 2>/dev/null || stat -f '%u %Lp' "$1" 2>/dev/null
}
pgbb_literal_path() {
[ "${#1}" -le 512 ] || return 1
case "$1" in
/*) case "$1" in *[!a-zA-Z0-9_./+-]*|*//*|*/../*|*/./*|*/..|*/.) return 1;; esac ;;
*) return 1 ;;
esac
pgbb_path=$1
pgbb_depth=0
while [ "$pgbb_path" != / ]; do
pgbb_depth=$((pgbb_depth + 1))
[ "$pgbb_depth" -le 32 ] || return 1
pgbb_path=${pgbb_path%/*}
[ -n "$pgbb_path" ] || pgbb_path=/
done
return 0
}
pgbb_no_symlinks() {
pgbb_path=$1
while [ "$pgbb_path" != / ] && [ "${pgbb_path%/}" != "$pgbb_path" ]; do pgbb_path=${pgbb_path%/}; done
while [ "$pgbb_path" != / ]; do
[ ! -L "$pgbb_path" ] || return 1
pgbb_path=${pgbb_path%/*}
[ -n "$pgbb_path" ] || pgbb_path=/
done
}
pgbb_parse_command() (
case "$1" in
*\$*|*\`*|*\;*|*\&*|*\|*|*\(*|*\)*|*\<*|*\>*|*\\*|*\"*|*\'*|*\**|*\?*|*\[*|*\]*) return 1 ;;
esac
set -f
set -- $1
[ "$1" = exec ] && shift
case "$1" in /usr/bin/pg_basebackup|/usr/local/bin/pg_basebackup|pg_basebackup) shift ;; *) return 1 ;; esac
pgbb_dest=
pgbb_host=
while [ "$#" -gt 0 ]; do
case "$1" in
-D|--pgdata) shift; [ "$#" -gt 0 ] || return 1; [ -z "$pgbb_dest" ] || return 1; pgbb_dest=$1 ;;
--pgdata=*) [ -z "$pgbb_dest" ] || return 1; pgbb_dest=${1#*=} ;;
-h|--host) shift; [ "$#" -gt 0 ] || return 1; [ -z "$pgbb_host" ] || return 1; pgbb_host=$1 ;;
--host=*) [ -z "$pgbb_host" ] || return 1; pgbb_host=${1#*=} ;;
-d|--dbname|--dbname=*) return 1 ;;
-F|--format) shift; [ "$1" = p ] || [ "$1" = plain ] || return 1 ;;
--format=plain|--format=p) ;;
-Fp) ;;
-F*|--format=*) return 1 ;;
-D*) return 1 ;;
esac
shift
done
[ -n "$pgbb_dest" ] || return 1
pgbb_literal_path "$pgbb_dest" || return 1
pgbb_literal_path "$pgbb_host" || return 1
while [ "$pgbb_dest" != / ] && [ "${pgbb_dest%/}" != "$pgbb_dest" ]; do pgbb_dest=${pgbb_dest%/}; done
[ "$pgbb_dest" != / ] || return 1
printf '%s\n' "$pgbb_dest"
)
pgbb_source_from_process() {
ps -eo args 2>/dev/null | awk '
NR > 256 { exit }
{ for (i=1; i<NF-1; i++) if ($i ~ /(^|\/)postgres$/ && $(i+1)=="-D" && $(i+2) ~ /^\/[a-zA-Z0-9_.\/+\-]+$/) { found++; source=$(i+2) } }
END { if (found == 1) print source }
'
}
pgbb_review_job() {
pgbb_cron=$1
pgbb_command=$2
pgbb_source=$3
pgbb_dest=$(pgbb_parse_command "$pgbb_command") || {
printf 'PostgreSQL base-backup review lead: %s: literal plain destination could not be established\n' "$pgbb_cron"
return 0
}
pgbb_literal_path "$pgbb_source" || return 0
pgbb_no_symlinks "$pgbb_source" || return 0
pgbb_no_symlinks "$pgbb_dest" || {
printf 'PostgreSQL base-backup review lead: %s: destination contains a symlink\n' "$pgbb_cron"
return 0
}
[ -d "$pgbb_source" ] && [ -w "$pgbb_source" ] && [ -x "$pgbb_source" ] || return 0
pgbb_source_meta=$(pgbb_metadata "$pgbb_source") || return 0
pgbb_parent=$pgbb_dest
if [ -d "$pgbb_dest" ]; then
[ -x "$pgbb_dest" ] && [ ! -w "$pgbb_dest" ] || return 0
pgbb_dest_meta=$(pgbb_metadata "$pgbb_dest") || return 0
elif [ ! -e "$pgbb_dest" ]; then
pgbb_parent=${pgbb_dest%/*}
[ -n "$pgbb_parent" ] || pgbb_parent=/
[ -d "$pgbb_parent" ] && [ -x "$pgbb_parent" ] && [ ! -w "$pgbb_parent" ] || return 0
pgbb_dest_meta=$(pgbb_metadata "$pgbb_parent") || return 0
[ "${pgbb_dest_meta%% *}" = 0 ] || return 0
printf 'PostgreSQL base-backup review lead: %s: output does not exist; resulting directory mode cannot be confirmed\n' "$pgbb_cron"
return 0
else
return 0
fi
[ "${pgbb_dest_meta%% *}" = 0 ] || return 0
pgbb_path=$pgbb_parent
while [ "$pgbb_path" != / ]; do
[ -x "$pgbb_path" ] || return 0
pgbb_path=${pgbb_path%/*}
[ -n "$pgbb_path" ] || pgbb_path=/
done
pgbb_opts=$(pgbb_mount_options "$pgbb_dest")
case ",$pgbb_opts," in
,,) printf 'PostgreSQL base-backup review lead: %s: destination mount options unknown\n' "$pgbb_cron"; return 0 ;;
*,nosuid,*|*,noexec,*|*,ro,*) return 0 ;;
esac
case ",$pgbb_opts," in *,rw,*) : ;; *) return 0 ;; esac
printf 'PostgreSQL base-backup review candidate: %s\n' "$pgbb_cron"
printf ' command: %s\n writable source: %s (uid mode %s)\n root output: %s (uid mode %s; mount %s)\n' "$pgbb_command" "$pgbb_source" "$pgbb_source_meta" "$pgbb_dest" "$pgbb_dest_meta" "$pgbb_opts"
printf ' reason: root plain-format backup may copy lower-trust files and set-ID modes into an executable path; verify mode preservation\n'
PG_BASEBACKUP_DESTS="${PG_BASEBACKUP_DESTS}${PG_BASEBACKUP_DESTS:+
}$pgbb_dest"
}
pgbb_scan_file() {
pgbb_file=$1
pgbb_source=$2
[ -f "$pgbb_file" ] && [ -r "$pgbb_file" ] && [ ! -L "$pgbb_file" ] && [ ! -w "$pgbb_file" ] || return 0
pgbb_meta=$(pgbb_metadata "$pgbb_file") || return 0
[ "${pgbb_meta%% *}" = 0 ] || return 0
pgbb_size=$(stat -c %s "$pgbb_file" 2>/dev/null || stat -f %z "$pgbb_file" 2>/dev/null) || return 0
case "$pgbb_size" in ''|*[!0-9]*) return 0 ;; esac
[ "$pgbb_size" -le 65536 ] || return 0
pgbb_line_count=0
while IFS= read -r pgbb_line; do
pgbb_line_count=$((pgbb_line_count + 1))
[ "$pgbb_line_count" -le 200 ] || break
[ "${#pgbb_line}" -le 1024 ] || continue
case "$pgbb_line" in ''|\#*|[[:space:]]\#*) continue ;; esac
pgbb_total=$((pgbb_total + 1))
[ "$pgbb_total" -le 400 ] || return 0
set -f
set -- $pgbb_line
set +f
case "$pgbb_file" in
*/cron.d/*|/etc/crontab) [ "$#" -ge 7 ] && [ "$6" = root ] || continue; shift 6 ;;
*/crontabs/root|*/cron/root) [ "$#" -ge 6 ] || continue; shift 5 ;;
/etc/anacrontab) [ "$#" -ge 4 ] || continue; shift 3 ;;
*) continue ;;
esac
pgbb_command="$*"
case "$pgbb_command" in *pg_basebackup*) : ;; *)
set -- $pgbb_command
case "$1" in /bin/sh|/bin/bash|/usr/bin/sh|/usr/bin/bash) shift ;; esac
[ "$#" -eq 1 ] || continue
pgbb_wrapper=$1
pgbb_literal_path "$pgbb_wrapper" || continue
pgbb_no_symlinks "$pgbb_wrapper" || continue
[ -f "$pgbb_wrapper" ] && [ -r "$pgbb_wrapper" ] && [ ! -L "$pgbb_wrapper" ] && [ ! -w "$pgbb_wrapper" ] || continue
pgbb_meta=$(pgbb_metadata "$pgbb_wrapper") || continue
[ "${pgbb_meta%% *}" = 0 ] || continue
pgbb_size=$(stat -c %s "$pgbb_wrapper" 2>/dev/null || stat -f %z "$pgbb_wrapper" 2>/dev/null) || continue
case "$pgbb_size" in ''|*[!0-9]*) continue ;; esac
[ "$pgbb_size" -le 8192 ] || continue
pgbb_command=$(head -c 8192 "$pgbb_wrapper" 2>/dev/null | awk 'NR<=80 && /(^|[[:space:]\/])pg_basebackup([[:space:]]|$)/ { print; exit }')
[ -n "$pgbb_command" ] || continue
;;
esac
pgbb_count=$((pgbb_count + 1))
[ "$pgbb_count" -le 20 ] || return 0
pgbb_review_job "$pgbb_file:$pgbb_line_count" "$pgbb_command" "$pgbb_source"
done < "$pgbb_file"
}
pgbb_scan_files() {
pgbb_source=$1
shift
pgbb_count=0
pgbb_total=0
pgbb_file_count=0
for pgbb_file do
pgbb_file_count=$((pgbb_file_count + 1))
[ "$pgbb_file_count" -le 64 ] || break
pgbb_scan_file "$pgbb_file" "$pgbb_source"
[ "$pgbb_count" -lt 20 ] && [ "$pgbb_total" -lt 400 ] || break
done
}
check_pg_basebackup_boundary() {
[ "$(id -u 2>/dev/null)" != 0 ] || return 0
pgbb_source=$(pgbb_source_from_process)
[ -n "$pgbb_source" ] || return 0
pgbb_scan_files "$pgbb_source" /etc/crontab /var/spool/cron/crontabs/root /var/spool/cron/root /etc/anacrontab /etc/cron.d/*
}
@@ -5,10 +5,10 @@
# Description: Report risky placement, ownership, and parent permissions for SUID and SGID files.
# License: GNU GPL
# Version: 1.0
# Functions Used:
# Global Variables: $E, $IAMROOT, $ROOT_FOLDER, $SED_RED, $SED_RED_YELLOW
# Functions Used: pgbb_mount_options
# Global Variables: $E, $IAMROOT, $PG_BASEBACKUP_DESTS, $ROOT_FOLDER, $SED_RED, $SED_RED_YELLOW
# Initial Functions:
# Generated Global Variables: $priv_file_name, $priv_file_owner, $priv_file_parent, $priv_file_type, $priv_file_unusual
# Generated Global Variables: $priv_file_name, $priv_file_owner, $priv_file_parent, $priv_file_type, $priv_file_unusual, $priv_backup_dest, $priv_backup_opts
# Fat linpeas: 0
# Small linpeas: 1
@@ -26,6 +26,23 @@ check_privileged_file_location() {
;;
esac
if [ "$priv_file_owner" = root ] && [ -f "$priv_file_name" ] && [ ! -L "$priv_file_name" ] && [ -x "$priv_file_name" ]; then
while IFS= read -r priv_backup_dest; do
[ -n "$priv_backup_dest" ] || continue
case "$priv_file_name" in
"$priv_backup_dest"/*)
priv_backup_opts=$(pgbb_mount_options "$priv_file_name")
case ",$priv_backup_opts," in ,,|*,nosuid,*|*,noexec,*) continue ;; esac
echo "$priv_file_type root-owned file under a reviewed PostgreSQL backup destination: $priv_file_name" | sed -${E} "s,.*,${SED_RED_YELLOW},"
priv_file_unusual=1
break
;;
esac
done <<EOF_PRIV_BACKUP_DESTS
$PG_BASEBACKUP_DESTS
EOF_PRIV_BACKUP_DESTS
fi
priv_file_parent="$(dirname "$priv_file_name")"
if ! [ "$IAMROOT" ] && [ -d "$priv_file_parent" ] && [ -w "$priv_file_parent" ] && [ -x "$priv_file_parent" ] && ! [ -k "$priv_file_parent" ]; then
echo "You can replace entries in the $priv_file_type file's parent directory: $priv_file_parent" | sed -${E} "s,.*,${SED_RED_YELLOW},"
@@ -0,0 +1,135 @@
# Title: Function - check_sudo_terraform_override
# ID: check_sudo_terraform_override
# Author: PEASS-ng contributors
# Last Update: 09-10-2026
# Description: Passively correlate a root Terraform sudo rule with a caller-writable provider override.
# License: GNU GPL
# Version: 1.0
# Functions Used:
# Global Variables: $HOME, $ROOT_FOLDER
# Initial Functions:
# Generated Global Variables: $tf_policy, $tf_rules, $tf_config, $tf_config_size, $tf_rule_dir, $tf_source, $tf_override_dir, $tf_provider_name, $tf_binary, $tf_binary_access, $tf_project_file, $tf_project_size, $tf_project_count, $tf_source_seen, $tf_walk, $tf_rest, $tf_part
# Fat linpeas: 0
# Small linpeas: 1
tf_plain_path() {
case "$1" in /*) ;; *) return 1 ;; esac
case "$1" in *'//'*|*'/./'*|*'/../'*|*'/.'|*'/..') return 1 ;; esac
tf_walk=
tf_rest=${1#/}
while [ "$tf_rest" ]; do
tf_part=${tf_rest%%/*}
tf_walk="$tf_walk/$tf_part"
[ ! -L "$tf_walk" ] || return 1
case "$tf_rest" in
*/*) tf_rest=${tf_rest#*/} ;;
*) break ;;
esac
done
}
check_sudo_terraform_override() { (
[ -z "$ROOT_FOLDER" ] || [ "$ROOT_FOLDER" = / ] || return 0
[ -n "$HOME" ] && [ -d "$HOME" ] || return 0
# A selected alternate config, or a home-changing Defaults entry, makes the
# caller's HOME config an unreliable candidate for this narrow check.
env | grep -q '^TF_CLI_CONFIG_FILE=.' && return 0
tf_policy=$(printf '%s\n%s\n%s\n' "$1" "$2" "$3" | sed -n '1,400p;401q')
printf '%s\n' "$tf_policy" | grep -Eq '(^|[[:space:],])!env_reset([[:space:],]|$)' || return 0
printf '%s\n' "$tf_policy" | grep -Eq '(^|[[:space:],])(always_set_home|set_home)([[:space:],]|$)' && return 0
# Parse only an exact root-capable terraform -chdir=... apply command.
# The output is a review clue; sudo's effective policy and HOME remain to be verified.
tf_rules=$(printf '%s\n' "$tf_policy" | awk '
/^[[:space:]]*\([^)]*\)[[:space:]]/ {
line = $0
runas = line
sub(/^[^(]*\(/, "", runas); sub(/\).*/, "", runas)
split(runas, parts, ":")
users = parts[1]
if (users ~ /!root/ || users !~ /(^|[[:space:],])(root|ALL)([[:space:],]|$)/) next
sub(/^.*\)[[:space:]]*/, "", line)
n = split(line, commands, /,[[:space:]]*/)
for (i = 1; i <= n; i++) {
command = commands[i]
sub(/^[[:space:]]*/, "", command)
while (command ~ /^(NOPASSWD|PASSWD|SETENV|NOSETENV):[[:space:]]*/)
sub(/^[A-Z_]+:[[:space:]]*/, "", command)
sub(/[[:space:]]*$/, "", command)
if (command ~ /^\/[A-Za-z0-9_.\/-]*\/terraform -chdir=\/[A-Za-z0-9_.\/-]+ apply$/ &&
command !~ /\/\.\.?\// && ++count <= 4) {
sub(/^.* -chdir=/, "", command); sub(/ apply$/, "", command)
if (!seen[command]++) print command
}
}
}
')
[ -n "$tf_rules" ] || return 0
tf_config="$HOME/.terraformrc"
tf_plain_path "$tf_config" || return 0
[ -f "$tf_config" ] && [ -r "$tf_config" ] && [ -w "$tf_config" ] && [ ! -L "$tf_config" ] || return 0
tf_config_size=$(stat -c %s "$tf_config" 2>/dev/null) ||
tf_config_size=$(stat -f %z "$tf_config" 2>/dev/null) || return 0
case "$tf_config_size" in ''|*[!0-9]*) return 0 ;; esac
[ "$tf_config_size" -le 32768 ] || return 0
# Only report literal source-to-directory mappings inside dev_overrides.
# Never print unrelated CLI configuration, which may contain credentials.
sed -n '1,256p;257q' "$tf_config" 2>/dev/null | awk '
/^[[:space:]]*provider_installation[[:space:]]*\{/ { installation = 1; next }
installation && /^[[:space:]]*dev_overrides[[:space:]]*\{/ { overrides = 1; next }
overrides && /^[[:space:]]*\}/ { overrides = 0; next }
installation && /^[[:space:]]*\}/ { installation = 0; next }
overrides && /^[[:space:]]*"[A-Za-z0-9._\/-]+"[[:space:]]*=[[:space:]]*"\/[A-Za-z0-9._\/-]+"[[:space:]]*(#.*)?$/ {
split($0, fields, "\"")
if (++count <= 8) print fields[2] "|" fields[4]
}
' | while IFS='|' read -r tf_source tf_override_dir; do
tf_plain_path "$tf_override_dir" || continue
[ -d "$tf_override_dir" ] && [ -w "$tf_override_dir" ] &&
[ -x "$tf_override_dir" ] && [ ! -L "$tf_override_dir" ] || continue
tf_provider_name=${tf_source##*/}
tf_binary="$tf_override_dir/terraform-provider-$tf_provider_name"
tf_plain_path "$tf_binary" || continue
if [ -e "$tf_binary" ] || [ -L "$tf_binary" ]; then
[ -f "$tf_binary" ] && [ -w "$tf_binary" ] &&
[ -x "$tf_binary" ] && [ ! -L "$tf_binary" ] || continue
tf_binary_access='caller-writable executable exists'
else
tf_binary_access='caller can create executable in override directory'
fi
printf '%s\n' "$tf_rules" | while IFS= read -r tf_rule_dir; do
tf_plain_path "$tf_rule_dir" || continue
[ -d "$tf_rule_dir" ] && [ ! -L "$tf_rule_dir" ] || continue
tf_source_seen=
tf_project_count=0
for tf_project_file in "$tf_rule_dir"/*.tf; do
tf_plain_path "$tf_project_file" || continue
[ -f "$tf_project_file" ] && [ ! -L "$tf_project_file" ] || continue
tf_project_count=$((tf_project_count + 1))
[ "$tf_project_count" -le 8 ] || break
tf_project_size=$(stat -c %s "$tf_project_file" 2>/dev/null) ||
tf_project_size=$(stat -f %z "$tf_project_file" 2>/dev/null) || continue
case "$tf_project_size" in ''|*[!0-9]*) continue ;; esac
[ "$tf_project_size" -le 32768 ] || continue
if sed -n '1,256p;257q' "$tf_project_file" 2>/dev/null | awk -v wanted="$tf_source" '
/^[[:space:]]*source[[:space:]]*=/ {
split($0, fields, "\"")
if (fields[2] == wanted) found = 1
}
END { exit !found }
'; then
tf_source_seen=1
break
fi
done
[ "$tf_source_seen" ] || continue
printf 'Terraform sudo/provider override review candidate: root rule uses -chdir=%s apply; current HOME config %s is caller-writable\n' "$tf_rule_dir" "$tf_config"
printf 'Provider source %s matches a bounded .tf file; override directory %s; %s: %s\n' "$tf_source" "$tf_override_dir" "$tf_binary_access" "$tf_binary"
ls -ld "$tf_config" "$tf_override_dir" "$tf_binary" 2>/dev/null
printf 'Verify effective sudo HOME, policy, provider loading, and execution context; this is candidate evidence, not proven root privilege.\n'
done
done
); }
@@ -0,0 +1,28 @@
# Title: Functions - lp_suse_numeric_version_lt
# ID: lp_suse_numeric_version_lt
# Author: PEASS-ng
# Last Update: 08-10-2026
# Description: Compare numeric SUSE package version-release strings; return 2 when the format is unknown.
# License: GNU GPL
# Version: 1.0
# Mitre: T1068
# Functions Used:
# Global Variables:
# Initial Functions:
# Generated Global Variables:
# Fat linpeas: 0
# Small linpeas: 1
lp_suse_numeric_version_lt() {
case "$1" in *[!0-9.-]*|"") return 2 ;; esac
case "$2" in *[!0-9.-]*|"") return 2 ;; esac
awk -v installed="$1" -v fixed="$2" 'BEGIN {
n = split(installed, a, /[.:-]/)
m = split(fixed, b, /[.:-]/)
for (i = 1; i <= n || i <= m; i++) {
if (a[i] + 0 < b[i] + 0) exit 0
if (a[i] + 0 > b[i] + 0) exit 1
}
exit 1
}'
}
@@ -0,0 +1,42 @@
# Title: Function - lp_trusted_version_path
# ID: lp_trusted_version_path
# Author: PEASS-ng contributors
# Last Update: 09-10-2026
# Description: Resolve configuration-supplied version probes to protected root-owned executables.
# License: GNU GPL
# Version: 1.0
# Functions Used:
# Global Variables:
# Initial Functions:
# Generated Global Variables: $lp_version_path, $lp_version_uid, $lp_version_walk, $lp_version_count
# Fat linpeas: 0
# Small linpeas: 1
lp_trusted_version_path() { (
# Never execute a program solely because a readable configuration names it.
# Execute the checked canonical path, not a replaceable symlink spelling.
case "$1" in /*) ;; *) exit 1 ;; esac
lp_version_path=$(readlink -f "$1" 2>/dev/null) ||
lp_version_path=$(realpath "$1" 2>/dev/null) || exit 1
case "$lp_version_path" in /*) ;; *) exit 1 ;; esac
[ "${#lp_version_path}" -le 4096 ] &&
[ -f "$lp_version_path" ] && [ -x "$lp_version_path" ] || exit 1
lp_version_uid=$(id -u 2>/dev/null) || exit 1
lp_version_walk=$lp_version_path
lp_version_count=0
while :; do
lp_version_count=$((lp_version_count + 1))
[ "$lp_version_count" -le 64 ] && [ ! -L "$lp_version_walk" ] || exit 1
# Numeric ownership and symbolic mode work on GNU, BSD and BusyBox ls.
LC_ALL=C ls -ldn "$lp_version_walk" 2>/dev/null | awk '
NR == 1 && $3 == "0" && length($1) >= 10 &&
substr($1, 6, 1) != "w" && substr($1, 9, 1) != "w" { safe = 1 }
END { exit !safe }
' || exit 1
if [ "$lp_version_uid" != 0 ] && [ -w "$lp_version_walk" ]; then exit 1; fi
[ "$lp_version_walk" != / ] || break
lp_version_walk=${lp_version_walk%/*}
[ -n "$lp_version_walk" ] || lp_version_walk=/
done
printf '%s\n' "$lp_version_path"
) }
@@ -0,0 +1,23 @@
# Title: Function - pgbb_mount_options
# ID: pgbb_mount_options
# Author: PEASS-ng
# Last Update: 2026-10-09
# Description: Read the effective Linux mount options for a literal path.
# License: GNU GPL
# Version: 1.0
# Functions Used:
# Global Variables:
# Initial Functions:
# Generated Global Variables:
# Fat linpeas: 0
# Small linpeas: 1
pgbb_mount_options() {
[ -r /proc/mounts ] || return 1
awk -v path="$1" '
$2 !~ /\\\\/ && (path == $2 || $2 == "/" || index(path, $2 "/") == 1) {
if (length($2) > best) { best=length($2); opts=$4 }
}
END { if (best) print opts }
' /proc/mounts
}
@@ -14,11 +14,11 @@
print_ps(){
(ls -d /proc/*/ 2>/dev/null | while read f; do
CMDLINE=$(cat $f/cmdline 2>/dev/null | grep -av "seds,"); #Delete my own sed processess
(ls -d /proc/*/ 2>/dev/null | while read -r f; do
CMDLINE=$(tr '\000' ' ' < "$f/cmdline" 2>/dev/null | grep -av "seds,"); #Delete my own sed processess
if [ "$CMDLINE" ];
then var USER2=ls -ld $f | awk '{print $3}'; PID=$(echo $f | cut -d "/" -f3);
then USER2=$(ls -ld "$f" 2>/dev/null | awk '{print $3}'); PID=$(echo "$f" | cut -d "/" -f3);
printf " %-13s %-8s %s\n" "$USER2" "$PID" "$CMDLINE";
fi;
done) 2>/dev/null | sort -r
}
}
@@ -125,7 +125,7 @@ ${NC}This tool enum and search possible misconfigurations$DG (known vulns, user,
${YELLOW} -N${BLUE} Do not use colours
${YELLOW} -z <N>${BLUE} Set number of threads for background checks (default: auto-detected CPU count, fallback: 2; must be >= 1)$NC"
while getopts ":h?asd:p:i:P:qo:T:LMwNDterVf:F:z:" opt; do
while getopts ":h?asd:p:i:P:qno:T:LMwNDterVf:F:z:" opt; do
case "$opt" in
h|\?) printf "%s\n\n" "$HELP$NC"; exit 0;;
a) FAST="";EXTRA_CHECKS="1";ONLINE_VULN_CHECKS="1";;
@@ -24,4 +24,4 @@ cap_setfcap:python|perl|ruby|php|node|lua|bash \
cap_setpcap:python|perl|ruby|php|node|lua|bash \
cap_setuid:peass{CAP_SETUID_HERE} \
cap_setgid:peass{CAP_SETGID_HERE} \
cap_net_raw:python|tcpdump|dumpcap|tcpflow"
cap_net_raw:python|tcpdump|dumpcap|tcpflow|reaver"
@@ -108,7 +108,6 @@ CVE-2021-27365 linux-iscsi pkg=linux-kernel,ver<=5.11.3,CONFIG_SLAB_FREELIST_HAR
CVE-2021-3490 eBPF ALU32 bounds tracking for bitwise ops pkg=linux-kernel,ver>=5.7,ver<5.12,CONFIG_BPF_SYSCALL=y,sysctl:kernel.unprivileged_bpf_disabled!=1 ubuntu=20.04{kernel:5.8.0-(25|26|27|28|29|30|31|32|33|34|35|36|37|38|39|40|41|42|43|44|45|46|47|48|49|50|51|52)-*},ubuntu=21.04{kernel:5.11.0-16-*} 5 CONFIG_BPF_SYSCALL needs to be set && kernel.unprivileged_bpf_disabled != 1
CVE-2021-3493 Ubuntu OverlayFS pkg=linux-kernel,ver>=3.13,ver<5.14,x86_64 ubuntu=(14.04|16.04|18.04|20.04|20.10) 1 Only Ubuntu is affected.
CVE-2021-22555 Netfilter heap out-of-bounds write pkg=linux-kernel,ver>=2.6.19,ver<=5.12-rc6 ubuntu=20.04{kernel:5.8.0-*} 1 ip_tables kernel module must be loaded
CVE-2022-0847 DirtyPipe pkg=linux-kernel,ver>=5.8,ver<=5.16.11 ubuntu=(20.04|21.04),debian=11 1
CVE-2022-0995 watch_queue pkg=linux-kernel,ver>=5.8,ver<5.16.5,x86_64 ubuntu=21.10{kernel:5.13.0.37-generic} 1 Not 100% reliable, may need to be run a couple of times. It rare cases it may panic the kernel.
CVE-2022-2586 nft_object UAF pkg=linux-kernel,ver>=5.12,ver<5.19,CONFIG_USER_NS=y,sysctl:kernel.unprivileged_userns_clone==1 ubuntu=(20.04){kernel:5.12.13} 1 kernel.unprivileged_userns_clone=1 required (to obtain CAP_NET_ADMIN)
CVE-2022-32250 nft_object UAF (NFT_MSG_NEWSET) pkg=linux-kernel,ver<5.18.1,CONFIG_USER_NS=y,sysctl:kernel.unprivileged_userns_clone==1 ubuntu=(22.04){kernel:5.15.0-27-generic} 1 kernel.unprivileged_userns_clone=1 required (to obtain CAP_NET_ADMIN)
@@ -462,10 +461,10 @@ CVE-2023-2593 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token
CVE-2023-2598 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token from example repos; no matching rule defined in source suggesters
CVE-2023-26083 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token from example repos; no matching rule defined in source suggesters
CVE-2023-2612 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token from example repos; no matching rule defined in source suggesters
CVE-2023-2640 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token from example repos; no matching rule defined in source suggesters
CVE-2023-2640 catalog_reference_only 9999.9999.9999 0 Reference-only source token; narrow Ubuntu generic matcher is in KERNEL_CVE_DATA_26
CVE-2023-31248 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token from example repos; no matching rule defined in source suggesters
CVE-2023-32233 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token from example repos; no matching rule defined in source suggesters
CVE-2023-32629 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token from example repos; no matching rule defined in source suggesters
CVE-2023-32629 catalog_reference_only 9999.9999.9999 0 Reference-only source token; narrow Ubuntu generic matcher is in KERNEL_CVE_DATA_26
CVE-2023-3269 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token from example repos; no matching rule defined in source suggesters
CVE-2023-32832 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token from example repos; no matching rule defined in source suggesters
CVE-2023-32837 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token from example repos; no matching rule defined in source suggesters
@@ -653,6 +652,9 @@ EOF_DATA_22
)"
KERNEL_CVE_DATA_23="$(cat <<'EOF_DATA_23'
CVE-2022-0847 DirtyPipe pkg=linux-kernel,ver>=5.8,ver<5.10.102 upstream-branch-candidate 1 Upstream stable 5.10.102 fixes this branch; vendor backports and readable target-file prerequisites remain unverified
CVE-2022-0847 DirtyPipe pkg=linux-kernel,ver>=5.11,ver<5.15.25 upstream-branch-candidate 1 Upstream stable 5.15.25 fixes this branch; vendor backports and readable target-file prerequisites remain unverified
CVE-2022-0847 DirtyPipe pkg=linux-kernel,ver>=5.16,ver<5.16.11 upstream-branch-candidate 1 Upstream stable 5.16.11 fixes this branch; vendor backports and readable target-file prerequisites remain unverified
CVE-2026-43499 GhostLock rtmutex UAF pkg=linux-kernel,ver>=2.6.39,ver<5.10.261,CONFIG_FUTEX_PI=y 1 Fixed in stable 5.10.261; priority-inheritance futexes must be enabled
CVE-2026-43499 GhostLock rtmutex UAF pkg=linux-kernel,ver>=5.11,ver<5.15.212,CONFIG_FUTEX_PI=y 1 Fixed in stable 5.15.212; priority-inheritance futexes must be enabled
CVE-2026-43499 GhostLock rtmutex UAF pkg=linux-kernel,ver>=5.16,ver<6.1.175,CONFIG_FUTEX_PI=y 1 Fixed in stable 6.1.175; priority-inheritance futexes must be enabled
@@ -731,6 +733,7 @@ EOF_DATA_25
)"
KERNEL_CVE_DATA_26="$(cat <<'EOF_DATA_26'
CVE-2023-2640,CVE-2023-32629 Ubuntu OverlayFS userns copy-up pkg=linux-kernel,ver>=6.2.0-1,ver<6.2.0-26,CONFIG_USER_NS=y,cmd:grep -q '^ID=ubuntu$' /etc/os-release && grep -Eq '^VERSION_ID="?(22[.]04|23[.]04)"?$' /etc/os-release && uname -r | grep -Eq '^6[.]2[.]0-[0-9]+-generic$',cmd:test -r /proc/sys/kernel/unprivileged_userns_clone && test "$(cat /proc/sys/kernel/unprivileged_userns_clone)" = 1 1 Candidate only for Ubuntu 22.04 HWE 6.2 or 23.04 generic before 6.2.0-26; Canonical backports and Livepatch may alter effective status; verify running kernel package
CVE-2026-53264 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token with a public net/sched privilege-escalation exploit; dedicated LinPEAS check handles prerequisites
CVE-2026-68121 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token with a public PPPoE privilege-escalation exploit; no stable matcher added
CVE-2026-74469 catalog_reference_only 9999.9999.9999 0 Reference-only CVE token with a public SCTP diagnostics privilege-escalation exploit; dedicated LinPEAS check handles prerequisites
@@ -0,0 +1,15 @@
# Title: Variables - PostgreSQL base-backup destinations
# ID: pg_basebackup_dests
# Author: PEASS-ng
# Last Update: 2026-10-09
# Description: Share confirmed backup destinations with the existing SUID and SGID checks.
# License: GNU GPL
# Version: 1.0
# Functions Used:
# Global Variables:
# Initial Functions:
# Generated Global Variables: $PG_BASEBACKUP_DESTS
# Fat linpeas: 0
# Small linpeas: 1
PG_BASEBACKUP_DESTS=
@@ -13,4 +13,4 @@
# Small linpeas: 1
processesVB='jdwp|tmux |screen | inspect |--inspect=|--inspect |--inspect$|--inpect-brk|--remote-debugging-port'
processesVB='jdwp|tmux |screen | inspect |(^|[[:space:]])--inspect(-brk)?([=[:space:]]|$)|--remote-debugging-port|(^|[[:space:]/])chromedriver([[:space:]]|$)'
@@ -1,10 +1,10 @@
# Title: Variables - sidB
# ID: sidB
# Author: Carlos Polop
# Last Update: 05-09-2026
# Last Update: 09-10-2026
# Description: Dangerous sid binaries
# License: GNU GPL
# Version: 1.0
# Version: 1.1
# Functions Used:
# Global Variables:
# Initial Functions:
@@ -26,8 +26,6 @@ sidB="/apache2$%Read_root_passwd__apache2_-f_/etc/shadow\(CVE-2019-0211\)\
/dtappgather$%Solaris_7_<_11_\(SPARC/x86\)\(CVE-2017-3622\)\
/dtprintinfo$%Solaris_10_\(x86\)_and_lower_versions_also_SunOS_5.7_to_5.10\
/dtsession$%Oracle_Solaris_10_1/13_and_earlier\(CVE-2020-2696\)\
/enlightenment_backlight$%Before_0.25.4_\(CVE-2022-37706\)\
/enlightenment_ckpasswd$%Before_0.25.4_\(CVE-2022-37706\)\
/enlightenment_sys$%Before_0.25.4_\(CVE-2022-37706\)\
/eject$%FreeBSD_mcweject_0.9/SGI_IRIX_6.2\
/ibstat$%IBM_AIX_Version_6.1/7.1\(09-2013\)\
@@ -54,9 +52,10 @@ sidB="/apache2$%Read_root_passwd__apache2_-f_/etc/shadow\(CVE-2019-0211\)\
/rdist$%Solaris_10/OpenSolaris\
/rsh$%Apple_Mac_OSX_10.9.5/10.10.5\(09-2015\)\
/screen$%GNU_Screen_4.5.0\
/screen-4\.5\.0$%GNU_Screen_logfile_policy_candidate_verify_SUID_and_vendor_patch\
/sdtcm_convert$%Sun_Solaris_7.0\
/sendmail$%Sendmail_8.10.1/Sendmail_8.11.x/Linux_Kernel_2.2.x_2.4.0-test1_\(SGI_ProPack_1.2/1.3\)\
/snap-confine$%Ubuntu_snapd<2.37_dirty_sock_Local_Privilege_Escalation\(CVE-2019-7304\)\
/snap-confine$%snap-confine_SUID_helper_review_effective_identity_and_mount_policy\
/sudo%check_if_the_sudo_version_is_vulnerable\
/Serv-U%FTP_Server<15.1.7(CVE-2019-12181)\
/sudoedit$%Sudo/SudoEdit_1.6.9p21/1.7.2p4/\(RHEL_5/6/7/Ubuntu\)/Sudo<=1.8.14\
@@ -12,4 +12,4 @@
# Fat linpeas: 0
# Small linpeas: 1
sudoB="$(whoami)|ALL:ALL|ALL : ALL|ALL|env_keep|NOPASSWD|SETENV|/apache2|/cryptsetup|/mount|/restic|/usermod|/sbin/ldconfig|/usr/sbin/ldconfig|ldconfig -f|--password-command|--password-file|-o ProxyCommand|-o PreferredAuthentications"
sudoB="$(whoami)|ALL:ALL|ALL : ALL|ALL|env_keep|NOPASSWD|SETENV|/apache2|/cryptsetup|/mount|/restic|/usermod|/sbin/ldconfig|/usr/sbin/ldconfig|ldconfig -f|--password-command|--password-file|-o ProxyCommand|-o PreferredAuthentications|(^|[[:space:],])/usr/bin/ssh([[:space:],]|\$)|(^|[[:space:],])/(usr/)?bin/systemctl[[:space:]]+status([[:space:],]|\$)|(^|[[:space:],])(/[[:alnum:]_.+-]+)*/nginx([[:space:],]|\$)|(^|[[:space:],])(/[[:alnum:]_.+-]+)*/ansible-playbook([[:space:]]*([,]|\$)|[[:space:]]+\*[[:space:]]*([,]|\$))|(^|[[:space:],])(/[[:alnum:]_.+-]+)*/dotnet([[:space:]]*([,]|\$))|(^|[[:space:],])(/[[:alnum:]_.+-]+)*/easy_install([[:space:]]*([,]|\$))|(^|[[:space:],])(/[[:alnum:]_.+-]+)*/tcpdump([[:space:]]*([,]|\$))|(^|[[:space:],])(/[[:alnum:]_.+-]+)*/git[[:space:]]+\^?apply([[:space:],]|\$)|(^|[[:space:],])(/[[:alnum:]_.+-]+)*/clamscan[[:space:]]+\^?--debug([[:space:],]|\$)|(^|[[:space:],])(/[[:alnum:]_.+-]+)*/pip[0-9.]*[[:space:]]+\^?download([[:space:],]|\$)|(^|[[:space:],])(/[[:alnum:]_.+-]+)*/luvit([[:space:]]*([,]|\$))|(^|[[:space:],])(/[[:alnum:]_.+-]+)*/chattr([[:space:],]|\$)"
@@ -13,5 +13,5 @@
# Small linpeas: 1
sudoVB1=" \*|env_keep\W*\+=.*LD_PRELOAD|env_keep\W*\+=.*LD_LIBRARY_PATH|env_keep\W*\+=.*BASH_ENV|env_keep\W*\+=.* ENV|env_keep\W*\+=.*PATH|!env_reset|!requiretty|peass{SUDOVB1_HERE}"
sudoVB1=" \*|env_keep\W*\+=.*LD_PRELOAD|env_keep\W*\+=.*LD_LIBRARY_PATH|env_keep\W*\+=.*BASH_ENV|env_keep\W*\+=.* ENV|env_keep\W*\+=.*PATH|!env_reset|!requiretty|/apport-cli([[:space:],]|$)|peass{SUDOVB1_HERE}"
sudoVB2="peass{SUDOVB2_HERE}"
+4 -1
View File
@@ -36,9 +36,12 @@ class FileRecord:
def __resolve_search_in(self, search_in):
""" Resolve spacial values to the correct directories """
# The YAML catalog is cached globally; do not consume its selector list.
search_in = list(search_in)
if "all" in search_in:
search_in.remove("all")
search_in = ROOT_FOLDER
search_in = list(ROOT_FOLDER)
if "common" in search_in:
search_in.remove("common")
+86 -48
View File
@@ -1,8 +1,11 @@
import gzip
import io
import re
import requests
import base64
import os
from pathlib import Path
import tarfile
import zlib
from .peasLoaded import PEASLoaded
from .peassRecord import PEASRecord
@@ -175,28 +178,28 @@ class LinpeasBuilder:
if type == "d":
find_line += "-type d "
bash_find_var = f"FIND_DIR_{r[1:].replace('.','').replace('-','_').replace('{ROOT_FOLDER}','').upper()}"
bash_find_var = f"FIND_DIR_{r[1:].replace('.','').replace('-','_').replace('/','_').replace('{ROOT_FOLDER}','').upper()}"
self.bash_find_d_vars.add(bash_find_var)
all_folder_regexes += regexes
else:
bash_find_var = f"FIND_{r[1:].replace('.','').replace('-','_').replace('{ROOT_FOLDER}','').upper()}"
bash_find_var = f"FIND_{r[1:].replace('.','').replace('-','_').replace('/','_').replace('{ROOT_FOLDER}','').upper()}"
self.bash_find_f_vars.add(bash_find_var)
all_file_regexes += regexes
find_line += '-name \\"' + '\\" -o -name \\"'.join(regexes) + '\\"'
find_line += '\\( -name \\"' + '\\" -o -name \\"'.join(regexes) + '\\" \\)'
find_line = FIND_TEMPLATE.replace(FIND_LINE_MARKUP, find_line)
find_line = f"{bash_find_var}={find_line}"
finds.append(find_line)
# Buid folder and files finds when searching in a custom folder
all_folder_regexes = list(set(all_folder_regexes))
find_line = '$SEARCH_IN_FOLDER -type d -name \\"' + '\\" -o -name \\"'.join(all_folder_regexes) + '\\"'
find_line = '$SEARCH_IN_FOLDER -type d \\( -name \\"' + '\\" -o -name \\"'.join(all_folder_regexes) + '\\" \\)'
find_line = FIND_TEMPLATE.replace(FIND_LINE_MARKUP, find_line)
find_line = f"FIND_DIR_CUSTOM={find_line}"
finds_custom.append(find_line)
all_file_regexes = list(set(all_file_regexes))
find_line = '$SEARCH_IN_FOLDER -name \\"' + '\\" -o -name \\"'.join(all_file_regexes) + '\\"'
find_line = '$SEARCH_IN_FOLDER \\( -name \\"' + '\\" -o -name \\"'.join(all_file_regexes) + '\\" \\)'
find_line = FIND_TEMPLATE.replace(FIND_LINE_MARKUP, find_line)
find_line = f"FIND_CUSTOM={find_line}"
finds_custom.append(find_line)
@@ -348,52 +351,87 @@ class LinpeasBuilder:
return bin_b64
def __get_gtfobins_lists(self) -> tuple:
bins = []
api_url = "https://api.github.com/repos/GTFOBins/GTFOBins.github.io/contents/_gtfobins?per_page=100"
while api_url:
r = requests.get(api_url, timeout=10)
if not r.ok:
break
data = r.json()
for entry in data:
if entry.get("type") == "file" and entry.get("name"):
bins.append(entry["name"])
api_url = None
link = r.headers.get("Link", "")
for part in link.split(","):
if 'rel="next"' in part:
api_url = part.split(";")[0].strip().strip("<>")
break
if not bins:
r = requests.get("https://github.com/GTFOBins/GTFOBins.github.io/tree/master/_gtfobins", timeout=10)
bins = re.findall(r'_gtfobins/([a-zA-Z0-9_ \-]+)(?:\\.md)?', r.text)
# One repository archive replaces a directory listing and hundreds of
# sequential raw-file requests. Every build uses the current archive.
archive_url = "https://codeload.github.com/GTFOBins/GTFOBins.github.io/tar.gz/refs/heads/master"
try:
response = requests.get(archive_url, timeout=(3, 8), stream=True)
try:
response.raise_for_status()
content = bytearray()
for chunk in response.iter_content(chunk_size=64 * 1024):
if len(content) + len(chunk) > 2 * 1024 * 1024:
raise ValueError("GTFOBins archive exceeds size limit")
content.extend(chunk)
finally:
response.close()
categories = self.__gtfobins_archive_categories(content)
self.__validate_gtfobins_categories(categories)
except (requests.RequestException, tarfile.TarError, OSError, ValueError, EOFError, zlib.error) as exc:
raise RuntimeError(
"Cannot build GTFOBins lists: failed to download or validate the current archive"
) from exc
sudoVB = []
suidVB = []
capsVB = []
# sudoers aliases and sudo -l may list several commands on one line.
sudo_command_boundary = "([[:space:]]*[,]|$)"
for b in bins:
try:
rb = requests.get(f"https://raw.githubusercontent.com/GTFOBins/GTFOBins.github.io/master/_gtfobins/{b}", timeout=5)
except:
try:
rb = requests.get(f"https://raw.githubusercontent.com/GTFOBins/GTFOBins.github.io/master/_gtfobins/{b}", timeout=5)
except:
rb = requests.get(f"https://raw.githubusercontent.com/GTFOBins/GTFOBins.github.io/master/_gtfobins/{b}", timeout=5)
if "sudo:" in rb.text:
if len(b) <= 3:
# Reduce false positives for short names.
sudoVB.append("[^a-zA-Z0-9]" + b + sudo_command_boundary)
else:
sudoVB.append(b + sudo_command_boundary)
if "suid:" in rb.text:
suidVB.append("/"+b+"$")
if "capabilities:" in rb.text:
capsVB.append(b)
sudoVB = [
("[^a-zA-Z0-9]" if len(name) <= 3 else "") + name + sudo_command_boundary
for name in categories["sudo"]
]
suidVB = ["/" + name + "$" for name in categories["suid"]]
capsVB = categories["capabilities"]
return (suidVB, sudoVB, capsVB)
@staticmethod
def __gtfobins_archive_categories(content: bytes) -> dict:
categories = {"sudo": [], "suid": [], "capabilities": []}
seen = set()
members = 0
total_bytes = 0
# Bound the whole tar stream before parsing: extension headers and
# padding are not included in the member sizes yielded by tarfile.
with gzip.GzipFile(fileobj=io.BytesIO(content)) as compressed:
expanded = compressed.read(16 * 1024 * 1024 + 1)
if len(expanded) > 16 * 1024 * 1024:
raise ValueError("GTFOBins archive exceeds expanded size limit")
with tarfile.open(fileobj=io.BytesIO(expanded), mode="r:") as archive:
for member in archive:
members += 1
if members > 2048:
raise ValueError("GTFOBins archive has too many entries")
# Skipped members must also fit the expanded-size budget:
# tarfile traverses their payload to reach the next header.
total_bytes += member.size
if total_bytes > 16 * 1024 * 1024:
raise ValueError("GTFOBins archive exceeds expanded size limit")
marker = "/_gtfobins/"
if not member.isfile() or marker not in member.name or member.size > 128 * 1024:
continue
name = member.name.split(marker, 1)[1]
if not re.fullmatch(r"[A-Za-z0-9_ .+-]{1,80}", name) or name in seen:
continue
member_file = archive.extractfile(member)
if member_file is None:
continue
seen.add(name)
body = member_file.read().decode("utf-8", "replace")
for category in categories:
if category + ":" in body:
categories[category].append(name)
for category in categories:
categories[category].sort()
return categories
@staticmethod
def __validate_gtfobins_categories(categories: dict) -> None:
limits = {"suid": 185, "sudo": 250, "capabilities": 2}
for category, minimum in limits.items():
names = categories.get(category)
if not isinstance(names, list) or len(names) <= minimum or names != sorted(set(names)):
raise ValueError(f"Incomplete GTFOBins {category} list")
if not all(isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9_ .+-]{1,80}", name)
for name in names):
raise ValueError(f"Invalid GTFOBins {category} entry")
def __generate_regexes_search(self) -> str:
regexes = REGEXES_LOADED["regular_expresions"]
+4 -2
View File
@@ -8,7 +8,9 @@ class PEASLoaded:
self.peasrecords = []
for record in to_search:
record_value = record["value"]
if "linpeas" in str(record_value["config"].get("disable","")).lower():
# Shared YAML uses value.disable; accept the older config.disable too.
if any("linpeas" in str(disabled).lower() for disabled in (
record_value.get("disable"), record_value["config"].get("disable"))):
continue
filerecords = []
@@ -28,4 +30,4 @@ class PEASLoaded:
exec=record_value["config"].get("exec", DEFAULTS["exec"]),
filerecords=filerecords
)
)
)
+9
View File
@@ -0,0 +1,9 @@
# A commented password must not be reported.
# "commented" MSCHAPV2 "not-a-secret" [2]
* PEAP,TTLS,TLS,FAST
"certificate-user" TLS [2]
"hashed-user" MSCHAPV2 hash:000102030405060708090a0b0c0d0e0f [2]
"phase-one" MD5 "phase-one-secret"
"wireless-admin" MSCHAPV2 "wireless-secret" [2]
"tunnel-user" TTLS-PAP "tunnel-secret" [2]
"malformed" MSCHAPV2 "trailing-secret" [2] unexpected
@@ -0,0 +1,116 @@
import subprocess
import tempfile
import unittest
from pathlib import Path
MODULE = (
Path(__file__).resolve().parents[1]
/ "builder/linpeas_parts/9_interesting_files/27_Passwords_in_logs.sh"
)
class AccessLogGetCredentialTests(unittest.TestCase):
def run_probe(self, *paths):
source = MODULE.read_text(encoding="utf-8").split(
'\nif ! [ "$SEARCH_IN_FOLDER" ]; then', 1
)[0]
result = subprocess.run(
["dash", "-c", source + '\nlp_http_log_credential_cues "$@"\n', "dash", *map(str, paths)],
capture_output=True,
text=True,
timeout=8,
)
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout
def test_long_get_request_reports_path_only(self):
with tempfile.TemporaryDirectory() as tmp:
log = Path(tmp) / "access.log"
secret = "SENSITIVE_ACCESS_LOG_PASSWORD"
request = (
'127.0.0.1 - - [04/Feb/2025:02:20:11 +0000] '
f'"GET /join.php?loginUsername=user&loginPassword={secret}&loginForm=Login HTTP/1.1" '
'302 329 "http://example.invalid/join.php" '
'"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0"\n'
)
self.assertGreater(len(request), 200)
log.write_text(request, encoding="utf-8")
output = self.run_probe(log)
self.assertIn(f"{log}: credential-bearing GET request present", output)
self.assertEqual(output.count("credential-bearing GET request present"), 1)
self.assertNotIn(secret, output)
self.assertNotIn("loginUsername", output)
self.assertNotIn("/join.php", output)
def test_only_nonempty_query_values_in_get_request_count(self):
with tempfile.TemporaryDirectory() as tmp:
log = Path(tmp) / "access.log"
log.write_text(
'127.0.0.1 "GET /login?password= HTTP/1.1" 200 1\n'
'127.0.0.1 "POST /login?password=hidden HTTP/1.1" 200 1\n'
'127.0.0.1 "GET /plain HTTP/1.1" 200 1 "https://site/?password=hidden" "ua"\n'
'127.0.0.1 "GET /plain HTTP/1.1" 200 1 "ref" "password=hidden"\n'
'127.0.0.1 "GET /login?password HTTP/1.1" 200 1\n',
encoding="utf-8",
)
self.assertEqual(self.run_probe(log), "")
log.write_text('127.0.0.1 "GET /login?loginPassword=present HTTP/1.1" 200 1\n')
self.assertIn("credential-bearing GET request present", self.run_probe(log))
def test_tail_byte_limit_and_file_guard(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
log = root / "access.log"
log.write_text(
'127.0.0.1 "GET /login?password=too-old HTTP/1.1" 200 1\n'
+ ("x" * 70000)
+ "\n",
encoding="utf-8",
)
self.assertEqual(self.run_probe(log), "")
alias = root / "alias.log"
alias.symlink_to(log)
self.assertEqual(self.run_probe(alias), "")
self.assertEqual(self.run_probe(root / "missing.log"), "")
def test_file_cap(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
paths = []
for i in range(9):
path = root / f"access-{i}.log"
path.write_text(
'127.0.0.1 "GET /login?password=TOP_SECRET_VALUE HTTP/1.1" 200 1\n',
encoding="utf-8",
)
paths.append(path)
output = self.run_probe(*paths)
self.assertEqual(output.count("credential-bearing GET request present"), 8)
self.assertIn("partial (file or time cap reached)", output)
self.assertNotIn("TOP_SECRET_VALUE", output)
def test_short_legacy_log_entry_is_still_selected(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
log = root / "ordinary.log"
log.write_text("short pwd=fixture-value\n", encoding="utf-8")
module = MODULE.read_text(encoding="utf-8")
legacy = module.split(' (find /var/log/', 1)[1].split(' lp_http_log_credential_cues', 1)[0]
legacy = ' (find /var/log/' + legacy
legacy = legacy.replace(
"find /var/log/ /var/logs/ /private/var/log",
f"find {root}",
)
result = subprocess.run(
["dash", "-c", "E=E; SED_RED=''; log_find_exec_end='+';\n" + legacy],
capture_output=True,
text=True,
timeout=8,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("short =fixture-value", result.stdout)
if __name__ == "__main__":
unittest.main()
+137
View File
@@ -0,0 +1,137 @@
import os
import shlex
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
class ActualGroupsTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.part = (
Path(__file__).resolve().parents[1]
/ "builder/linpeas_parts/6_users_information/19_Actual_groups.sh"
)
def run_probe(self, groups, *, timeout_name="timeout", clock=None):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
fixture = root / "group"
fixture.write_text(groups, encoding="utf-8")
bindir = root / "bin"
bindir.mkdir()
for command in ("sed", "date"):
(bindir / command).symlink_to(shutil.which(command))
(bindir / "id").write_text(
'#!/bin/sh\n'
'case "$1" in\n'
' -G) echo "100 200" ;;\n'
' -g) echo "$FAKE_EFFECTIVE_GID" ;;\n'
'esac\n',
encoding="utf-8",
)
(bindir / "newgrp").write_text(
'#!/bin/sh\n'
'printf "%s\\n" "$1" >> "$FAKE_PROBE_LOG"\n'
'read -r command\n'
'case "$1" in\n'
' shadow_only) FAKE_EFFECTIVE_GID=300 ;;\n'
' *) FAKE_EFFECTIVE_GID=100 ;;\n'
'esac\n'
'export FAKE_EFFECTIVE_GID\n'
'exec /bin/sh -c "$command"\n',
encoding="utf-8",
)
(bindir / "id").chmod(0o755)
(bindir / "newgrp").chmod(0o755)
if timeout_name:
(bindir / timeout_name).write_text(
'#!/bin/sh\n'
'printf "%s\\n" "$1 $2 $3" >> "$FAKE_TIMEOUT_LOG"\n'
'shift 3\n'
'exec "$@"\n',
encoding="utf-8",
)
(bindir / timeout_name).chmod(0o755)
if clock is not None:
(bindir / "date").unlink()
(bindir / "date").write_text(
'#!/bin/sh\n'
'value=$(cat "$FAKE_CLOCK_FILE")\n'
'printf "%s\\n" "$((value + 1))" > "$FAKE_CLOCK_FILE"\n'
'echo "$value"\n',
encoding="utf-8",
)
(bindir / "date").chmod(0o755)
(root / "clock").write_text(str(clock), encoding="utf-8")
(bindir / "cat").symlink_to("/bin/cat")
# Substitute only the group-file input; all commands remain fixtures.
part = self.part.read_text(encoding="utf-8").replace(
"done < /etc/group", f"done < {shlex.quote(str(fixture))}"
)
script = "\n".join(
[
'print_2title() { echo "TITLE: $1"; }',
"IAMROOT=0 E=E groupsVB=NEVERMATCH groupsB=NEVERMATCH",
"SED_RED_YELLOW='' SED_RED=''",
part,
'printf "ACTUAL=%s\\n" "$ActualGroup"',
]
)
env = os.environ.copy()
env.update(
PATH=str(bindir),
FAKE_PROBE_LOG=str(root / "probes"),
FAKE_TIMEOUT_LOG=str(root / "timeouts"),
FAKE_CLOCK_FILE=str(root / "clock"),
)
result = subprocess.run(
["/bin/sh", "-c", script],
env=env,
capture_output=True,
text=True,
timeout=5,
)
probes = (root / "probes").read_text().splitlines() if (root / "probes").exists() else []
timeouts = (root / "timeouts").read_text().splitlines() if (root / "timeouts").exists() else []
return result, probes, timeouts
def test_missing_token_group_is_detected_even_without_group_file_member(self):
groups = "in_token:x:100:\nshadow_only:x:300:\ndenied:x:400:\n"
result, probes, timeouts = self.run_probe(groups)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(probes, ["shadow_only", "denied"])
self.assertEqual(timeouts, ["-k 1 1", "-k 1 1"])
self.assertIn("Accessible group not shown in id: shadow_only (gid=300)", result.stdout)
self.assertNotIn("Accessible group not shown in id: denied", result.stdout)
self.assertIn("ACTUAL=|shadow_only|", result.stdout)
def test_no_timeout_skips_newgrp(self):
result, probes, _ = self.run_probe("shadow_only:x:300:\n", timeout_name=None)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(probes, [])
self.assertIn("ACTUAL=|", result.stdout)
def test_global_deadline_stops_before_remaining_rows(self):
groups = "".join(f"candidate{i}:x:{300 + i}:\n" for i in range(30))
result, probes, _ = self.run_probe(groups, clock=100)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(len(probes), 14)
def test_gtimeout_fallback(self):
result, probes, timeouts = self.run_probe("shadow_only:x:300:\n", timeout_name="gtimeout")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(probes, ["shadow_only"])
self.assertEqual(timeouts, ["-k 1 1"])
def test_group_name_is_passed_literally(self):
result, probes, _ = self.run_probe('literal$(touch marker):x:300:\n')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(probes, ['literal$(touch marker)'])
if __name__ == "__main__":
unittest.main()
+180
View File
@@ -0,0 +1,180 @@
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
MODULE = ROOT / "linPEAS/builder/linpeas_parts/7_software_information/Apache_nginx.sh"
SIGNATURES = ROOT / "build_lists/sensitive_files.yaml"
class ApachePipedRemcoTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.timeout = shutil.which("timeout")
if cls.timeout is None:
raise unittest.SkipTest("timeout is needed for bounded fixture runs")
cls.module = MODULE.read_text(encoding="utf-8").replace("peass{Apache-Nginx}", "")
def run_check(self, function, *args):
script = self.module + "\n" + f'TIMEOUT="{self.timeout}"\n{function} "$@"\n'
result = subprocess.run(
["sh", "-c", script, "fixture", *map(str, args)],
text=True,
capture_output=True,
env={**os.environ, "SEARCH_IN_FOLDER": ""},
timeout=8,
)
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout
def test_piped_log_metadata_redacts_commands(self):
with tempfile.TemporaryDirectory() as tmp:
site = Path(tmp) / "site.conf"
site.write_text(
'CustomLog "|$sh -c secret_token=hidden" common\n'
'ErrorLog "|/usr/bin/rotatelogs /tmp/error 1M"\n'
'TransferLog /var/log/apache/access.log\n',
encoding="utf-8",
)
output = self.run_check("lp_apache_piped_log_check", tmp)
self.assertIn("CustomLog: shell pipeline", output)
self.assertIn("ErrorLog: direct pipeline", output)
self.assertNotIn("secret_token", output)
self.assertNotIn("/usr/bin/rotatelogs", output)
self.assertNotIn("TransferLog:", output)
self.assertNotIn("privilege escalation", output.lower())
site.write_text("customlog '|$sh -c second_secret=hidden' common\n", encoding="utf-8")
lowered = self.run_check("lp_apache_piped_log_check", tmp)
self.assertIn("customlog: shell pipeline", lowered)
self.assertNotIn("second_secret", lowered)
def test_ordinary_file_log_does_not_trigger(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / "site.conf").write_text(
"CustomLog /var/log/apache/access.log common\n", encoding="utf-8"
)
self.assertEqual(self.run_check("lp_apache_piped_log_check", tmp), "")
def test_remco_raw_value_correlates_without_backend_query(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
templates = root / "templates"
templates.mkdir()
template = templates / "site.conf.tmpl"
template.write_text(
'ServerName {{ getv(printf("/customers/%s/url", customer)) }}.example\n',
encoding="utf-8",
)
config = root / "config"
config.write_text(
"[[resource]]\n"
'name = "apache2"\n'
"[[resource.template]]\n"
f'src = "{template}"\n'
'dst = "/etc/apache2/sites-enabled/site.conf"\n'
'reload_cmd = "systemctl restart apache2.service"\n'
"[resource.backend]\n"
"[resource.backend.etcd]\n"
"version = 3\n"
'nodes = ["http://127.0.0.1:2379"]\n'
'keys = ["/customers"]\n'
"watch = true\n"
"interval = 5\n",
encoding="utf-8",
)
output = self.run_check("lp_remco_apache_template_check", config, templates, "root")
self.assertIn("Candidate: root remco process", output)
self.assertIn("backend write permission unknown", output)
self.assertNotIn("/customers", output)
self.assertNotIn(str(template), output)
self.assertIn("process privilege", self.run_check(
"lp_remco_apache_template_check", config, templates, "unknown"
))
template.write_text(
'ServerName {{ getv("/customers/x/url") | escape }}.example\n',
encoding="utf-8",
)
filtered = self.run_check(
"lp_remco_apache_template_check", config, templates, "root"
)
self.assertIn("template contents", filtered)
self.assertNotIn("Candidate:", filtered)
def test_remco_oversize_config_is_unknown(self):
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / "config"
config.write_text("a" * 65537, encoding="utf-8")
output = self.run_check(
"lp_remco_apache_template_check", config, Path(tmp) / "templates", "root"
)
self.assertIn("unknown", output)
self.assertNotIn("Candidate", output)
def test_existing_apache_selector_is_preserved(self):
section = next(
entry for entry in yaml.safe_load(SIGNATURES.read_text(encoding="utf-8"))["search"]
if entry["name"] == "Apache-Nginx"
)["value"]
commands = section["config"]["exec"]
self.assertIn("lp_apache_piped_log_check", commands)
self.assertIn("lp_remco_apache_template_check", commands)
sites = next(item for item in section["files"] if item["name"] == "sites-enabled")
pattern = sites["value"]["files"][0]["value"]["bad_regex"]
for directive in ("ServerName", "DocumentRoot", "ProxyPass"):
self.assertIn(directive, pattern)
def test_generated_file_inventory_withholds_pipe_command(self):
section = next(
entry for entry in yaml.safe_load(SIGNATURES.read_text(encoding="utf-8"))["search"]
if entry["name"] == "Apache-Nginx"
)["value"]
default = next(item for item in section["files"] if item["name"] == "000-default.conf")
sys.path.insert(0, str(ROOT / "linPEAS"))
from builder.src.fileRecord import FileRecord
from builder.src.linpeasBuilder import LinpeasBuilder
from builder.src.peassRecord import PEASRecord
record = FileRecord(regex="000-default.conf", **default["value"])
parent = PEASRecord("Apache-Nginx", True, [], [record])
line = LinpeasBuilder.__new__(LinpeasBuilder)._LinpeasBuilder__construct_file_line(
parent, record
)
with tempfile.TemporaryDirectory() as tmp:
file = Path(tmp) / "000-default.conf"
file.write_text(
"ServerName example.local\n"
"DocumentRoot /var/www/html\n"
"ProxyPass / http://localhost:1234/\n"
'CustomLog "|$sh -c secret_token=hidden" common\n'
"customlog '|$sh -c second_secret=hidden' common\n",
encoding="utf-8",
)
result = subprocess.run(
["sh", "-c", line],
text=True,
capture_output=True,
env={
**os.environ,
"PSTORAGE_APACHE_NGINX": str(file),
"E": "E",
"SED_RED": "&",
},
timeout=4,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("secret_token", result.stdout)
self.assertNotIn("second_secret", result.stdout)
for directive in ("ServerName", "DocumentRoot", "ProxyPass"):
self.assertIn(directive, result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,78 @@
"""Apache's cached vhost display highlights explicit process identities."""
import subprocess
import sys
import unittest
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
class ApacheRootVhostHighlightTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
catalog = yaml.safe_load((ROOT / "build_lists/sensitive_files.yaml").read_text())
record, = [item for item in catalog["search"] if item["name"] == "Apache-Nginx"]
files = record["value"]["files"]
nested = files[0]["value"]["files"][0]["value"]
direct = next(item["value"] for item in files
if item["name"] == "000-default.conf")
cls.records = ((nested["bad_regex"], nested["remove_regex"]),
(direct["bad_regex"], direct["remove_regex"]))
def colorized(self, pattern, remove, line):
# The generator places this YAML regex inside shell double quotes,
# which reduces its doubled backslash before grep receives it.
remove = remove.replace("\\\\", "\\")
filtered = subprocess.run(
["grep", "-Ev", remove], input=line + "\n", text=True,
capture_output=True, timeout=2,
)
self.assertIn(filtered.returncode, (0, 1), filtered.stderr)
result = subprocess.run(
["sed", "-E", "s," + pattern + ",[IDENTITY-CUE],g"],
input=filtered.stdout, text=True, capture_output=True, timeout=2,
)
self.assertEqual(0, result.returncode, result.stderr)
return result.stdout.strip()
def test_active_identity_directives_and_existing_highlight(self):
for pattern, remove in self.records:
for line in ("AssignUserId root root", "AssignUserID 0 0",
"assignuserid root 0", " ASSIGNUSERID 0 root ",
"AssignUserId root staff", "AssignUserId app root",
"AssignUserID app_user app-group"):
with self.subTest(line=line):
self.assertIn("[IDENTITY-CUE]", self.colorized(pattern, remove, line))
for line in ("# AssignUserId app staff", " # AssignUserID 0 0",
"NotAssignUserId root root", "AssignUserID app",
"AssignUserID app/group staff"):
with self.subTest(line=line):
self.assertNotIn("[IDENTITY-CUE]", self.colorized(pattern, remove, line))
self.assertEqual("", self.colorized(pattern, remove, "# AssignUserID app staff"))
self.assertIn("[IDENTITY-CUE]", self.colorized(pattern, remove, "ServerName local.example"))
def test_generated_section_preserves_cached_vhost_inventory(self):
sys.path.insert(0, str(ROOT / "linPEAS"))
from builder.src.linpeasBuilder import LinpeasBuilder
from builder.src.peasLoaded import PEASLoaded
builder = LinpeasBuilder.__new__(LinpeasBuilder)
builder.ploaded = PEASLoaded()
section = builder._LinpeasBuilder__generate_sections()["Apache-Nginx"]
self.assertIn("PSTORAGE_APACHE_NGINX", section)
self.assertIn('grep -E "sites-enabled$"', section)
self.assertIn('grep -E "000-default\\.conf$"', section)
for pattern, remove in self.records:
self.assertIn(pattern, section)
self.assertIn('grep -Ev "' + remove + '" | sed -${E} ', section)
syntax = subprocess.run(["sh", "-n"], input=section, text=True,
capture_output=True, timeout=2)
self.assertEqual(0, syntax.returncode, syntax.stderr)
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
"""Authentication logs are cached by exact name and listed without contents."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[2]
TITLE = "Interesting logs"
class AuthLogCatalogTests(unittest.TestCase):
def test_exact_cached_path_only_selector(self):
catalog = yaml.safe_load((ROOT / "build_lists/sensitive_files.yaml").read_text())
record, = [item for item in catalog["search"] if item["name"] == TITLE]
options = {item["name"]: item["value"] for item in record["value"]["files"]}
self.assertEqual({"access.log", "error.log", "auth.log"}, options.keys())
self.assertEqual(
{"just_list_file": True, "type": "f", "search_in": ["common"]},
options["auth.log"],
)
sys.path.insert(0, str(ROOT / "linPEAS"))
from builder.src.linpeasBuilder import LinpeasBuilder
from builder.src.peasLoaded import PEASLoaded
builder = LinpeasBuilder.__new__(LinpeasBuilder)
builder.ploaded = PEASLoaded()
builder.hidden_files = set()
builder.bash_find_f_vars = set()
builder.bash_find_d_vars = set()
builder.bash_storages = set()
builder._LinpeasBuilder__get_files_to_search()
finds, _ = builder._LinpeasBuilder__generate_finds()
self.assertTrue(any('-name \\"auth.log\\"' in line for line in finds))
builder._LinpeasBuilder__generate_storages()
section = builder._LinpeasBuilder__generate_sections()[TITLE]
self.assertNotIn('cat "$f"', section)
self.assertNotIn("ls -lRA", section)
self.assertEqual(0, subprocess.run(["sh", "-n"], input=section, text=True,
capture_output=True, timeout=2).returncode)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
exact = root / "auth.log"
exact.write_text("SECRET_DO_NOT_PRINT\n")
rotated = root / "auth.log.1"
rotated.write_text("OTHER_SECRET_DO_NOT_PRINT\n")
unrelated = root / "authentication.log"
unrelated.write_text("UNRELATED_SECRET_DO_NOT_PRINT\n")
result = subprocess.run(
["sh", "-c", "print_2title() { :; }; " + section],
env={**os.environ,
"PSTORAGE_INTERESTING_LOGS": "\n".join(map(str, (exact, rotated, unrelated))),
"E": "E", "SED_RED": "&"},
text=True, capture_output=True, timeout=2,
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn(str(exact), result.stdout)
self.assertNotIn(str(rotated), result.stdout)
self.assertNotIn(str(unrelated), result.stdout)
self.assertNotIn("SECRET_DO_NOT_PRINT", result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,96 @@
"""The autologin password lead must remain exact-path and metadata-only."""
import os
import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
TITLE = "Autologin"
STORAGE = "PSTORAGE_AUTOLOGIN"
class AutologinPasswordCatalogTests(unittest.TestCase):
def test_exact_path_preserves_older_names_and_hides_password(self):
catalog = yaml.safe_load((ROOT / "build_lists/sensitive_files.yaml").read_text())
record, = [item for item in catalog["search"] if item["name"] == TITLE]
self.assertEqual(record["value"]["disable"], ["winpeas"])
files = {item["name"]: item["value"] for item in record["value"]["files"]}
self.assertEqual(set(files), {"autologin", "autologin.conf", "passwd"})
entry = files["passwd"]
self.assertTrue(entry["just_list_file"])
self.assertEqual(entry["type"], "f")
self.assertEqual(entry["search_in"], ["${ROOT_FOLDER}etc", "${ROOT_FOLDER}mnt"])
pattern = entry["check_extra_path"]
for path in ("/etc/autologin/passwd",
"/mnt/stateful_partition/etc/autologin/passwd",
"/opt/autologin.conf", "/var/lib/app/autologin",
"/opt/autologin"):
self.assertIsNotNone(re.search(pattern, path), path)
for path in ("/etc/passwd", "/tmp/etc/autologin/passwd",
"/etc/autologin/passwd.bak", "/etc/other/passwd",
"/mnt/other/etc/autologin/passwd"):
self.assertIsNone(re.search(pattern, path), path)
sys.path.insert(0, str(ROOT / "linPEAS"))
from builder.src.linpeasBuilder import LinpeasBuilder
from builder.src.peasLoaded import PEASLoaded
builder = LinpeasBuilder.__new__(LinpeasBuilder)
builder.ploaded = PEASLoaded()
builder.hidden_files = set()
builder.bash_find_f_vars = set()
builder.bash_find_d_vars = set()
builder.bash_storages = set()
builder._LinpeasBuilder__get_files_to_search()
builder._LinpeasBuilder__generate_finds()
storage = next(line for line in builder._LinpeasBuilder__generate_storages()
if line.startswith(STORAGE + "="))
self.assertIn(pattern, storage)
self.assertIn("$FIND_ETC", storage)
self.assertIn("$FIND_MNT", storage)
self.assertIn("$FIND_VAR", storage)
selected = subprocess.run(
["sh", "-c", storage + f'\nprintf "%s\n" "${STORAGE}"'],
env={**os.environ, "ROOT_FOLDER": "/", "FIND_ETC": "\n".join((
"/etc/autologin/passwd", "/etc/passwd", "/etc/autologin/passwd.bak",
"/etc/autologin.conf", "/opt/autologin")),
"FIND_MNT": "\n".join((
"/mnt/stateful_partition/etc/autologin/passwd",
"/mnt/other/etc/autologin/passwd")),
"FIND_VAR": "/var/lib/app/autologin.conf"},
text=True, capture_output=True, timeout=2,
)
self.assertEqual(selected.returncode, 0, selected.stderr)
self.assertEqual(set(selected.stdout.splitlines()),
{"/etc/autologin/passwd", "/mnt/stateful_partition/etc/autologin/passwd",
"/etc/autologin.conf", "/opt/autologin",
"/var/lib/app/autologin.conf"})
section = builder._LinpeasBuilder__generate_sections()[TITLE]
passwd_line, = [line for line in section.splitlines()
if 'echo_not_found "passwd"' in line]
self.assertNotIn("cat ", passwd_line)
self.assertEqual(subprocess.run(["sh", "-n"], input=section, text=True,
capture_output=True, timeout=2).returncode, 0)
with tempfile.TemporaryDirectory() as tmp:
password = Path(tmp) / "passwd"
password.write_text("SECRET_MUST_STAY_HIDDEN")
result = subprocess.run(
["sh", "-c", "print_2title() { :; }; " + passwd_line],
env={**os.environ, STORAGE: str(password), "E": "E", "SED_RED": "&"},
text=True, capture_output=True, timeout=2,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(str(password), result.stdout)
self.assertNotIn("SECRET_MUST_STAY_HIDDEN", result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,72 @@
"""Axel user configuration is a path-only lead in the existing file cache."""
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
SECTION_NAME = "Axel download configuration candidates"
class AxelUserConfigCatalogTests(unittest.TestCase):
def test_exact_path_only_selector_and_generated_output(self):
catalog = yaml.safe_load((ROOT / "build_lists/sensitive_files.yaml").read_text())
records = [item for item in catalog["search"] if item["name"] == SECTION_NAME]
self.assertEqual(1, len(records))
options = records[0]["value"]
self.assertEqual(["winpeas"], options["disable"])
self.assertTrue(options["config"]["auto_check"])
self.assertEqual(1, len(options["files"]))
self.assertEqual(".axelrc", options["files"][0]["name"])
selector = options["files"][0]["value"]
self.assertEqual(["common"], selector["search_in"])
self.assertEqual("f", selector["type"])
self.assertTrue(selector["just_list_file"])
for key in ("bad_regex", "good_regex", "line_grep", "only_bad_lines"):
self.assertNotIn(key, selector)
sys.path.insert(0, str(ROOT / "linPEAS"))
from builder.src.linpeasBuilder import LinpeasBuilder
from builder.src.peasLoaded import PEASLoaded
builder = LinpeasBuilder.__new__(LinpeasBuilder)
builder.ploaded = PEASLoaded()
builder.hidden_files = set()
builder.bash_find_f_vars = set()
builder.bash_find_d_vars = set()
builder.bash_storages = set()
builder._LinpeasBuilder__get_files_to_search()
finds, _ = builder._LinpeasBuilder__generate_finds()
self.assertTrue(any('.axelrc' in line and ' -name ' in line for line in finds))
section = builder._LinpeasBuilder__generate_sections()[SECTION_NAME]
syntax = subprocess.run(["sh", "-n"], input=section, text=True,
capture_output=True, timeout=2)
self.assertEqual(0, syntax.returncode, syntax.stderr)
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / ".axelrc"
backup = Path(tmp) / ".axelrc.bak"
config.write_text("DO_NOT_PRINT_CONFIG_SECRET")
backup.write_text("DO_NOT_PRINT_CONFIG_SECRET")
result = subprocess.run(
["sh", "-c", "print_2title() { :; }; " + section],
env={**os.environ,
"PSTORAGE_AXEL_DOWNLOAD_CONFIGURATION_CANDIDATES":
"\n".join((str(config), str(backup))),
"E": "E", "SED_RED": "&"},
text=True, capture_output=True, timeout=2,
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn(str(config), result.stdout)
self.assertNotIn(str(backup), result.stdout)
self.assertNotIn("DO_NOT_PRINT_CONFIG_SECRET", result.stdout)
if __name__ == "__main__":
unittest.main()
+266
View File
@@ -0,0 +1,266 @@
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
import yaml
class BackupFoldersTests(unittest.TestCase):
def setUp(self):
self.repo_root = Path(__file__).resolve().parents[2]
self.module = self.repo_root / "linPEAS/builder/linpeas_parts/9_interesting_files/13_Backup_folders.sh"
def test_backup_names_keep_file_and_directory_searches(self):
config = yaml.safe_load((self.repo_root / "build_lists/sensitive_files.yaml").read_text())
backup = next(record for record in config["search"] if record["name"] == "Backups")
types = {}
for record in backup["value"]["files"]:
types.setdefault(record["name"], set()).add(record["value"]["type"])
self.assertEqual(types["backup"], {"d", "f"})
self.assertEqual(types["backups"], {"d", "f"})
self.assertEqual(types["npbackup.conf"], {"f"})
def test_builder_emits_both_find_paths_for_backup_names(self):
with tempfile.TemporaryDirectory() as temp:
output_path = Path(temp) / "linpeas-backup.sh"
subprocess.run(
["python3", "-m", "builder.linpeas_builder", "--include",
"IF_Backup_folders", "--output", str(output_path)],
cwd=self.repo_root / "linPEAS", capture_output=True, text=True,
check=True,
)
lines = output_path.read_text().splitlines()
directory_find = next(line for line in lines if "FIND_DIR_VAR=" in line)
file_find = next(line for line in lines if "FIND_VAR=" in line)
storage = next(line for line in lines if "PSTORAGE_BACKUPS=" in line)
for name in ("backup", "backups"):
self.assertIn(f'-name \\"{name}\\"', directory_find)
self.assertIn(f'-name \\"{name}\\"', file_find)
self.assertIn("$FIND_DIR_VAR", storage)
self.assertIn("$FIND_VAR", storage)
def run_module(self, paths):
script = 'print_2title() { :; }; . "$BACKUP_MODULE"'
env = dict(os.environ, BACKUP_MODULE=str(self.module),
PSTORAGE_BACKUPS="\n".join(map(str, paths)),
SEARCH_IN_FOLDER="", DEBUG="", E="E", SED_RED="")
result = subprocess.run(["sh", "-c", script], env=env,
capture_output=True, text=True, check=True)
return result.stdout
def test_lists_immediate_metadata_and_skips_exact_name_file(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
backup_dir = root / "backup"
backup_dir.mkdir()
archive = backup_dir / "opaque.zip.aes"
archive.write_text("private fixture contents")
nested = backup_dir / "nested"
nested.mkdir()
(nested / "deep.txt").write_text("nested fixture contents")
exact_file = root / "backups"
exact_file.write_text("exact-name file contents")
output = self.run_module([backup_dir, exact_file])
self.assertEqual(output.count(str(archive)), 1)
self.assertIn(str(nested), output)
self.assertNotIn("deep.txt", output)
self.assertNotIn(str(exact_file), output)
self.assertNotIn("fixture contents", output)
def test_caps_each_directory_at_thirty_entries(self):
with tempfile.TemporaryDirectory() as temp:
backups = Path(temp) / "backups"
backups.mkdir()
for index in range(40):
(backups / f"item{index:02d}").touch()
output = self.run_module([backups])
entries = [line for line in output.splitlines()
if f"{backups}/item" in line]
self.assertEqual(len(entries), 30)
def test_redacted_backup_shell_pass_assignments_are_bounded(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
backups = root / "backup"
backups.mkdir()
script = backups / "offsite-backup.sh"
script.write_text(
"OFFSITE_PASS=active-secret\n"
"# export DB_PASS='old-secret'\n"
"BYPASS=wrong-secret\n"
"OFFSITE_PASSWORD=wrong-secret\n"
"echo OFFSITE_PASS=wrong-secret\n"
"OFFSITE_PASS =wrong-secret\n"
"# note OTHER_PASS=wrong-secret\n"
"EMPTY_PASS=\n"
"LONG_PASS=" + "x" * 513 + "\n"
)
(backups / "late.sh").write_text("# filler\n" * 200 + "LATE_PASS=late-secret\n")
(backups / "oversized.sh").write_text("BIG_PASS=big-secret\n" + "x" * 65536)
outside = root / "outside.sh"
outside.write_text("LINK_PASS=link-secret\n")
(backups / "linked.sh").symlink_to(outside)
os.mkfifo(backups / "pipe.sh")
locked = backups / "locked.sh"
locked.write_text("LOCKED_PASS=locked-secret\n")
locked.chmod(0)
if os.access(locked, os.R_OK):
# Privileged test runners can still read mode-000 files.
locked.unlink()
output = self.run_module([backups])
findings = [line for line in output.splitlines() if "[REDACTED]" in line]
self.assertEqual(findings, [
f" {script}:1: OFFSITE_PASS=[REDACTED] (active)",
f" {script}:2: DB_PASS=[REDACTED] (commented; stale/unknown)",
])
for value in ("active-secret", "old-secret", "wrong-secret", "late-secret",
"big-secret", "link-secret", "locked-secret"):
self.assertNotIn(value, output)
self.assertLessEqual(len(findings), 50)
def test_backup_pass_scan_caps_directories_and_findings(self):
with tempfile.TemporaryDirectory() as temp:
paths = []
for index in range(13):
backups = Path(temp) / f"backup{index:02d}"
backups.mkdir()
(backups / "vars.sh").write_text(
"".join(f"KEY{key}_PASS=secret{key}\n" for key in range(6))
)
paths.append(backups)
output = self.run_module(paths)
findings = [line for line in output.splitlines() if "[REDACTED]" in line]
self.assertEqual(len(findings), 50)
self.assertFalse(any("KEY5_PASS" in line for line in findings))
self.assertFalse(any(str(paths[-1] / "vars.sh") in line for line in findings))
self.assertNotIn("secret0", output)
def test_readable_root_archives_report_only_actual_group_or_world_access(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
backups = root / "backups"
backups.mkdir()
for name in ("group copy.tar.gz", "locked.tar.gz", "other-group.zip",
"world-readable.txz", "ordinary.txt", "nested.tar"):
(backups / name).write_text("fixture contents")
(backups / "group copy.tar.gz").chmod(0o640)
(backups / "locked.tar.gz").chmod(0o600)
(backups / "other-group.zip").chmod(0o640)
nested = backups / "subdir"
nested.mkdir()
(nested / "deep.tar.gz").write_text("fixture contents")
(backups / "link.tar.gz").symlink_to(backups / "group copy.tar.gz")
bin_dir = root / "bin"
bin_dir.mkdir()
for name in ("ls", "find", "head", "awk", "cut", "sed"):
(bin_dir / name).symlink_to(shutil.which(name))
self.assertFalse((bin_dir / "stat").exists())
script = r'''
print_2title() { :; }
id() {
case "$1" in
-u) printf '%s\n' 1000 ;;
-G) printf '%s\n' '1000 4242' ;;
*) return 1 ;;
esac
}
ls() {
if [ "$1" = -ldn ]; then
case "$2" in
*'group copy.tar.gz') printf '%s\n' '-rw-r----- 1 0 4242 123 fixture' ;;
*'locked.tar.gz') printf '%s\n' '-rw------- 1 0 4242 123 fixture' ;;
*'other-group.zip') printf '%s\n' '-rw-r----- 1 0 8888 123 fixture' ;;
*'world-readable.txz') printf '%s\n' '-rw-r--r-- 1 0 8888 123 fixture' ;;
*'ordinary.txt') printf '%s\n' '-rw-r--r-- 1 0 8888 123 fixture' ;;
*) return 1 ;;
esac
else
command ls "$@"
fi
}
stat() { return 127; }
. "$BACKUP_MODULE"
'''
env = dict(os.environ, BACKUP_MODULE=str(self.module),
PSTORAGE_BACKUPS=str(backups), SEARCH_IN_FOLDER="", DEBUG="",
E="E", SED_RED="", PATH=str(bin_dir))
result = subprocess.run(["/bin/sh", "-c", script], env=env,
capture_output=True, text=True, check=True)
findings = [line for line in result.stdout.splitlines()
if "Possible exposure:" in line]
self.assertEqual(len(findings), 2, result.stdout)
self.assertTrue(any("group copy.tar.gz" in line and
"access=group-readable" in line for line in findings))
self.assertTrue(any("world-readable.txz" in line and
"access=world-readable" in line for line in findings))
for name in ("locked.tar.gz", "other-group.zip", "ordinary.txt",
"nested.tar", "deep.tar.gz", "link.tar.gz"):
self.assertFalse(any(name in line for line in findings), name)
self.assertNotIn("fixture contents", result.stdout)
def test_small_other_user_archives_are_candidates_without_reading_contents(self):
with tempfile.TemporaryDirectory() as temp:
backups = Path(temp) / "backups"
backups.mkdir()
names = ("other-user.tar.gz", "shared.zip", "too-large.tar.gz",
"current-user.tar.gz", "locked.tar.gz", "ordinary.txt")
for name in names:
(backups / name).write_text("private fixture contents")
(backups / "link.tar.gz").symlink_to(backups / "other-user.tar.gz")
script = r'''
print_2title() { :; }
id() {
case "$1" in
-u) printf '%s\n' 1000 ;;
-G) printf '%s\n' '1000 4242' ;;
*) return 1 ;;
esac
}
ls() {
if [ "$1" = -ldn ]; then
case "$2" in
*'other-user.tar.gz') printf '%s\n' '-rw-r--r-- 1 2000 2000 123 fixture' ;;
*'shared.zip') printf '%s\n' '-rw-r----- 1 2000 4242 10485760 fixture' ;;
*'too-large.tar.gz') printf '%s\n' '-rw-r--r-- 1 2000 2000 10485761 fixture' ;;
*'current-user.tar.gz') printf '%s\n' '-rw-r--r-- 1 1000 1000 123 fixture' ;;
*'locked.tar.gz') printf '%s\n' '-rw------- 1 2000 2000 123 fixture' ;;
*) return 1 ;;
esac
else
command ls "$@"
fi
}
. "$BACKUP_MODULE"
'''
env = dict(os.environ, BACKUP_MODULE=str(self.module),
PSTORAGE_BACKUPS=str(backups), SEARCH_IN_FOLDER="", DEBUG="",
E="E", SED_RED="")
result = subprocess.run(["/bin/sh", "-c", script], env=env,
capture_output=True, text=True, check=True)
findings = [line for line in result.stdout.splitlines()
if "Possible exposure:" in line]
self.assertEqual(len(findings), 2, result.stdout)
self.assertTrue(any("other-user.tar.gz" in line and
"access=world-readable" in line and
"contents unverified" in line for line in findings))
self.assertTrue(any("shared.zip" in line and
"access=group-readable" in line and
"size=10485760" in line for line in findings))
for name in ("too-large.tar.gz", "current-user.tar.gz", "locked.tar.gz",
"ordinary.txt", "link.tar.gz"):
self.assertFalse(any(name in line for line in findings), name)
self.assertNotIn("private fixture contents", result.stdout)
if __name__ == "__main__":
unittest.main()
+115
View File
@@ -0,0 +1,115 @@
import os
import shlex
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path
class BelowLogDirectoryTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.module = (
Path(__file__).resolve().parents[1]
/ "builder/linpeas_parts/7_software_information/Below_Log_Directory.sh"
)
def _fixture(self, base, directory_mode=0o777, installed=True):
old_umask = os.umask(0)
try:
directory = base / "below-logs"
directory.mkdir(mode=directory_mode)
binary = base / "below"
if installed:
fd = os.open(binary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o755)
os.close(fd)
finally:
os.umask(old_umask)
self.assertEqual(stat.S_IMODE(directory.stat().st_mode), directory_mode)
return binary, directory, directory / "error_root.log"
def _run(self, binary, directory, log, sudo_rule=True, deny_access=False, symlink_log=False, ls_mode=None):
sudo_text = f" (root) NOPASSWD: {binary} *" if sudo_rule is True else sudo_rule or ""
script = "\n".join(
[
"SEARCH_IN_FOLDER=1",
"MACPEAS=",
f"sudo_l_output={shlex.quote(sudo_text)}",
"sudo_l_cached_output=",
"sudo_l_password_output=",
"ps() { return 1; }",
'print_2title() { printf "TITLE: %s\\n" "$1"; }',
f". {shlex.quote(str(self.module))}",
(
f"ls() {{ printf '%s\\n' {shlex.quote(ls_mode)}; }}"
if ls_mode
else ":"
),
"lp_below_effective_access() { return 1; }" if deny_access else ":",
(
f"lp_below_is_symlink() {{ [ \"$1\" = {shlex.quote(str(log))} ]; }}"
if symlink_log
else ":"
),
f"lp_below_check_log_directory {shlex.quote(str(binary))} "
f"{shlex.quote(str(directory))} {shlex.quote(str(log))}",
]
)
return subprocess.run(["sh", "-c", script], capture_output=True, text=True, check=False)
def test_writable_nonsticky_directory_and_root_sudo_rule(self):
with tempfile.TemporaryDirectory() as temp:
binary, directory, log = self._fixture(Path(temp))
result = self._run(binary, directory, log)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("potential privileged log-file exposure", result.stdout)
self.assertIn("effective write/search access; nonsticky", result.stdout)
self.assertIn("Patch status: unknown", result.stdout)
self.assertNotIn("Vulnerable to CVE", result.stdout)
def test_sticky_directory_is_excluded(self):
with tempfile.TemporaryDirectory() as temp:
binary, directory, log = self._fixture(Path(temp))
result = self._run(binary, directory, log, ls_mode="drwxrwxrwt")
self.assertEqual(result.stdout, "")
def test_no_effective_access_is_excluded_even_if_mode_is_writable(self):
with tempfile.TemporaryDirectory() as temp:
binary, directory, log = self._fixture(Path(temp))
result = self._run(binary, directory, log, deny_access=True, ls_mode="drwxrwxrwx+")
self.assertEqual(result.stdout, "")
def test_owner_writable_0755_uses_effective_access_not_world_bits(self):
with tempfile.TemporaryDirectory() as temp:
binary, directory, log = self._fixture(Path(temp), 0o755)
result = self._run(binary, directory, log)
self.assertIn("effective write/search access", result.stdout)
def test_installed_binary_and_privileged_path_are_both_required(self):
with tempfile.TemporaryDirectory() as temp:
binary, directory, log = self._fixture(Path(temp), installed=False)
self.assertEqual(self._run(binary, directory, log).stdout, "")
old_umask = os.umask(0)
try:
fd = os.open(binary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o755)
os.close(fd)
finally:
os.umask(old_umask)
self.assertEqual(self._run(binary, directory, log, sudo_rule=False).stdout, "")
def test_negated_sudo_rule_is_not_privileged_evidence(self):
with tempfile.TemporaryDirectory() as temp:
binary, directory, log = self._fixture(Path(temp))
result = self._run(binary, directory, log, sudo_rule=f" (root) NOPASSWD: !{binary}")
self.assertEqual(result.stdout, "")
def test_existing_symlink_is_labeled_without_reading_target(self):
with tempfile.TemporaryDirectory() as temp:
binary, directory, log = self._fixture(Path(temp))
result = self._run(binary, directory, log, symlink_log=True)
self.assertIn("is a symlink (target not read)", result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,86 @@
"""Exercise the bounded, passive binfmt capability-helper clue."""
import os
import shlex
import subprocess
import tempfile
import unittest
from pathlib import Path
MODULE = Path(__file__).resolve().parents[1] / "builder/linpeas_parts/8_interesting_perms_files/4_Capabilities.sh"
REGISTER = b"/proc/sys/fs/binfmt_misc/register"
class BinfmtCapabilityHelperTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
def binary(self, name, payload=REGISTER, executable=True):
path = self.root / name
path.write_bytes(payload)
path.chmod(0o755 if executable else 0o644)
return path
def scan(self, records):
source = MODULE.read_text()
start = source.index(" binfmt_probe_count=0\n cap_inventory_count=0\n getcap -r / 2>/dev/null | head -n 51 | while read cb; do")
end = source.index("\n checkSnapConfineCVE20268933", start)
listing = source[start:end]
record_text = "\n".join(records)
script = (
"E=E; SED_RED='<R>'; SED_RED_YELLOW='<H>'; capsB='cap_dac_override'; "
"capsVB=''; IAMROOT=''; STRINGS=\"$(command -v strings)\";\n"
f"getcap() {{ printf '%s\\n' {shlex.quote(record_text)}; }}\n"
f"{listing}\n"
)
result = subprocess.run(
["sh", "-c", script],
env=os.environ,
capture_output=True,
text=True,
timeout=5,
)
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout
def test_effective_capability_and_exact_register_literal(self):
path = self.binary("helper", b"prefix\0" + REGISTER + b"\0suffix")
for rights in ("cap_dac_override=ep", "cap_dac_override+eip", "cap_dac_override,cap_net_raw=ep"):
with self.subTest(rights=rights):
output = self.scan([f"{path} {rights}"])
self.assertIn(f"binfmt_misc helper review candidate: {path}", output)
self.assertNotIn("prefix", output)
def test_missing_effective_capability_and_unrelated_literal(self):
path = self.binary("helper")
for rights in ("cap_dac_override=p", "cap_dac_override=ip", "cap_net_raw=ep"):
with self.subTest(rights=rights):
self.assertNotIn("binfmt_misc helper review candidate", self.scan([f"{path} {rights}"]))
unrelated = self.binary("unrelated", b"/proc/sys/fs/binfmt_misc/register-extra")
self.assertNotIn("binfmt_misc helper review candidate", self.scan([f"{unrelated} cap_dac_override=ep"]))
def test_oversized_symlink_and_non_executable_are_skipped(self):
oversized = self.binary("oversized", REGISTER + b"x" * 1048576)
target = self.binary("target")
link = self.root / "link"
link.symlink_to(target)
non_exec = self.binary("nonexec", executable=False)
for path in (oversized, link, non_exec):
with self.subTest(path=path):
self.assertNotIn("binfmt_misc helper review candidate", self.scan([f"{path} cap_dac_override=ep"]))
def test_at_most_eight_qualifying_binaries_are_inspected(self):
records = []
for i in range(9):
path = self.binary(f"helper{i}")
records.append(f"{path} cap_dac_override=ep")
output = self.scan(records)
self.assertEqual(output.count("binfmt_misc helper review candidate:"), 8)
self.assertNotIn("binfmt_misc helper review candidate: " + str(self.root / "helper8"), output)
if __name__ == "__main__":
unittest.main()
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff. Show more