Skip to content

Commit d594d80

Browse files
committed
refactor(scanrootkit): tighten ksym match, drop dead Suckit check
- kernel symbol matching now uses exact set membership instead of a substring search (fixes false positives on legitimate symbols that happen to contain a rootkit signature as a substring) - remove the in-depth Suckit check and the rootkit_extra perfdata field; the check was dead code and could never detect a real getdents-hooking rootkit - catch yaml.YAMLError broadly so a single broken signature file no longer crashes the whole check - remove rootkit_extra from the Grafana panel - document the signature scope in the README (IoC scanner, not defense-in-depth) and add a maintainer note with curated sources for new rootkit signatures
1 parent 14d7255 commit d594d80

File tree

5 files changed

+56
-96
lines changed

5 files changed

+56
-96
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Monitoring Plugins:
9595
* php-status: always assume http://localhost/monitoring.php and, if not found, be tolerant
9696
* redis-status, valkey-status: modernize code and unify both plugins again after [PR #954](https://github.com/Linuxfabrik/monitoring-plugins/pull/954)
9797
* rocketchat-stats: improve output
98+
* scanrootkit: kernel symbol matching is now exact per symbol instead of a substring search, so a signature like `is_invisible` no longer accidentally matches an unrelated legitimate symbol named `is_invisible_helper`. False positives on clean systems that previously had such symbol-name collisions will disappear.
9899
* statuspal: replace `flatdict` dependency with a recursive approach ([#1044](https://github.com/Linuxfabrik/monitoring-plugins/issues/1044))
99100
* systemd-units-failed: show failed unit names in the first output line for better dashboard and SMS alert readability ([#967](https://github.com/Linuxfabrik/monitoring-plugins/issues/967))
100101
* updates: adapt to updated powershell.py library
@@ -105,6 +106,7 @@ Monitoring Plugins:
105106
Monitoring Plugins:
106107

107108
* cpu-usage: remove `--top` parameter (the top N processes by CPU time are now reported by the procs check via `--top`)
109+
* scanrootkit: remove the in-depth Suckit rootkit check and the `rootkit_extra` perfdata field. The Suckit check was dead code (it created a harmless test file and then re-read it from the same Python process, which cannot detect file-hiding rootkits that hook `getdents`), and the `rootkit_extra` perfdata is no longer produced. Update Grafana panels and alerting that rely on `rootkit_extra`.
108110

109111

110112
Tools:
@@ -161,6 +163,7 @@ Monitoring Plugins:
161163
* redis-status, valkey-status: the `key_count` perfdata now reports the total key count across all databases instead of just the last database's key count ([#1070](https://github.com/Linuxfabrik/monitoring-plugins/issues/1070))
162164
* rocketchat-stats: fix crash (`AttributeError`) when reporting the user count
163165
* sap-open-concur-com: `--service` now validates the service name against the allowed list; previously any value was silently accepted ([#1070](https://github.com/Linuxfabrik/monitoring-plugins/issues/1070))
166+
* scanrootkit: a single signature file that fails any YAML parse stage (scanner, constructor, etc.) no longer crashes the whole check. Any `yaml.YAMLError` subclass is now caught and reported as a scan-file error, leaving the remaining signatures to scan normally.
164167
* starface-java-memory-usage: fix corrupted overall state in the heap and non-heap memory checks (could previously report CRIT when only WARN thresholds were exceeded, or produce an out-of-range state integer) ([#1070](https://github.com/Linuxfabrik/monitoring-plugins/issues/1070))
165168
* updates: fix crash on Python 3.9 when pending updates are reported
166169
* users: fix incorrect TTY count when SSH clients connect via IPv6 ([#989](https://github.com/Linuxfabrik/monitoring-plugins/issues/989))

check-plugins/scanrootkit/README.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33

44
## Overview
55

6-
Scans the system for approximately 100 known rootkits by checking for their characteristic files, directories, and kernel symbols. New rootkit definitions can be added by dropping YAML files into the `assets` folder. Additionally performs in-depth checks for the Suckit rootkit (link count of `/sbin/init` and hidden file detection).
6+
Scans the system for approximately 100 known rootkits by checking for their characteristic files, directories, and kernel symbols. New rootkit definitions can be added by dropping YAML files into the `assets` folder.
77

88
**Important Notes:**
99

10+
* This check is an indicator-of-compromise (IoC) scanner for *known* rootkits. It catches rootkits whose file paths, directory names or kernel symbol names appear in the signature list. It does not catch unknown rootkits, eBPF-based rootkits, in-memory-only implants, or rootkits that only replace existing binaries without creating new files. Treat it as one layer of defense-in-depth, not a complete anti-rootkit solution. Complement it with file integrity monitoring (AIDE, Tripwire), package verification (`rpm --verify`, `debsums`), kernel auditing (auditd), and runtime security (falco, tetragon).
1011
* Rootkit YAML file structure (example from `assets/scanrootkit-kbeast.yml`):
1112

1213
```yaml
@@ -24,15 +25,30 @@ Scans the system for approximately 100 known rootkits by checking for their char
2425
cl: 100
2526
```
2627
27-
* Feel free to add more rootkit definitions by submitting a pull request
28+
* `cl` is the confidence level in percent. Signatures with `cl < 100` are reported as "possible" rootkit items (state WARN) instead of confirmed findings. If `cl` is omitted, the signature is treated as 100% confident.
29+
* Kernel symbol matching is exact per symbol (using `/proc/kallsyms`), so a signature like `is_invisible` will not accidentally match an unrelated legitimate symbol named `is_invisible_helper`.
30+
* Feel free to add more rootkit definitions by submitting a pull request.
2831
* Inspired by the [Rootkit Hunter Project](https://rkhunter.sourceforge.net/), which has been inactive since 2018. All rkhunter rootkit definitions have been translated to YAML and made available with this check plugin.
2932

33+
**Note for maintainers - sources for new rootkit signatures:**
34+
35+
Because rkhunter is no longer updated, file-path and kernel-symbol signatures for rootkits released after ~2018 have to be collected from current threat research. When extending the signature set, prefer sources that publish concrete on-disk indicators (full file paths, directory names, kernel module names, exported symbol names). Good starting points, in alphabetical order:
36+
37+
* [CISA cybersecurity advisories](https://www.cisa.gov/news-events/cybersecurity-advisories) - joint IoC reports, often Linux-specific
38+
* [ESET welivesecurity](https://www.welivesecurity.com/) - Linux malware teardowns (Ebury, FontOnLake, Kobalos) typically include an IoC appendix
39+
* [Intezer blog](https://intezer.com/blog/) - Linux threat analyses
40+
* [Kaspersky Securelist](https://securelist.com/) - Symbiote, HiatusRAT and similar
41+
* [MITRE ATT&CK T1014 Rootkit](https://attack.mitre.org/techniques/T1014/) and linked software entries
42+
* [Sandfly Security blog](https://sandflysecurity.com/blog) - specializes in Linux forensics, regularly publishes file-path IoCs
43+
* [Sysdig threat research](https://sysdig.com/blog/topic/threat-research/), [CrowdStrike](https://www.crowdstrike.com/en-us/blog/category/threat-intel-research/), [Mandiant](https://cloud.google.com/security/resources/insights) - mixed IoC quality, worth checking
44+
45+
Signatures should be strong enough to avoid false positives on a clean system. Prefer uncommon, rootkit-specific file paths (e.g. `/usr/_h4x_/`) over generic ones (`/tmp/.X11-unix`) and exact kernel symbol names over substrings. If a signature is only partially reliable, set `cl` below 100 so the plugin reports it as "possible" instead of "confirmed".
46+
3047
**Data Collection:**
3148

3249
* Loads rootkit definitions from YAML files in the `assets` directory
3350
* Checks for rootkit-specific files and directories on the filesystem
3451
* Scans kernel symbols (`/proc/kallsyms` or `/proc/ksyms`) for rootkit indicators
35-
* Performs extra checks for Suckit rootkit (link count of `/sbin/init`, hidden file detection via `.xrk` and `.mem` suffixes)
3652

3753

3854
## Fact Sheet
@@ -76,7 +92,7 @@ options:
7692
Output:
7793

7894
```text
79-
Found 1 rootkit item and 0 extra items. 3 possible rootkit items found.
95+
Found 1 rootkit item. 3 possible rootkit items found. [CRITICAL]
8096
Rootkits:
8197
* ENYE LKM v1.1, v1.2: /etc/.enyelkmHIDE^IT.ko (File)
8298
Possible Rootkits:
@@ -89,7 +105,7 @@ Possible Rootkits:
89105
## States
90106

91107
* OK if no rootkit indicators are found.
92-
* WARN or CRIT (depending on `--severity`, default: CRIT) if confirmed rootkit items or in-depth scan items are found.
108+
* WARN or CRIT (depending on `--severity`, default: CRIT) if confirmed rootkit items are found.
93109
* WARN if only possible rootkit items are found (confidence level below 100%), regardless of the selected severity.
94110
* UNKNOWN if no rootkit definition files are found in the `assets` directory.
95111

@@ -98,7 +114,6 @@ Possible Rootkits:
98114

99115
| Name | Type | Description |
100116
|----|----|----|
101-
| rootkit_extra | Number | Number of rootkit items found by the in-depth scan. |
102117
| rootkit_items | Number | The number of confirmed rootkit items found on the system. |
103118
| rootkit_possible | Number | Number of possible rootkit items found on the system. |
104119

check-plugins/scanrootkit/grafana/scanrootkit.yml

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -109,33 +109,6 @@ spec:
109109
operator: '='
110110
value: rootkit_items
111111

112-
- alias: rootkit_extra
113-
refId: rootkit_extra
114-
groupBy:
115-
- params:
116-
- $interval
117-
type: time
118-
measurement: $command
119-
resultFormat: time_series
120-
select:
121-
- - params:
122-
- value
123-
type: field
124-
- params: []
125-
type: mean
126-
tags:
127-
- key: hostname
128-
operator: '='
129-
value: $hostname
130-
- condition: AND
131-
key: service
132-
operator: '='
133-
value: $service
134-
- condition: AND
135-
key: metric
136-
operator: '='
137-
value: rootkit_extra
138-
139112
- alias: rootkit_possible
140113
refId: rootkit_possible
141114
groupBy:

check-plugins/scanrootkit/scanrootkit

Lines changed: 30 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ except ImportError:
2828

2929

3030
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
31-
__version__ = '2026040801'
31+
__version__ = '2026041301'
3232

3333
DESCRIPTION = """Scans the system for approximately 100 known rootkits by checking for their
34-
characteristic files, directories, and kernel modules. New rootkit definitions can
34+
characteristic files, directories, and kernel symbols. New rootkit definitions can
3535
be added by dropping YAML files into the assets folder.
3636
Alerts when rootkit indicators are found."""
3737

@@ -63,40 +63,24 @@ def parse_args():
6363
return args
6464

6565

66-
def extra_rootkit_checks():
67-
"""This function carries out some extra checks for rootkits.
68-
Return '' if nothing is found, else a string indicating what has been found.
66+
def load_kernel_symbols():
67+
"""Read `/proc/kallsyms` (or legacy `/proc/ksyms`) and return the set of
68+
exact kernel symbol names. Using a set gives us whole-symbol matching
69+
instead of the substring matching a raw `str in file` would do, which
70+
previously triggered on partial matches like `is_invisible_helper`
71+
shadowing the `is_invisible` rootkit signature.
6972
"""
70-
result = []
71-
72-
# === Extra checks for Suckit rootkit
73-
74-
# Check the link count of the '/sbin/init' file.
75-
# 1 is okay
76-
# >1 means that suckit may be installed
77-
try:
78-
stat_info = os.stat('/sbin/init')
79-
except FileNotFoundError:
80-
return result
81-
except Exception:
82-
return result
83-
if stat_info.st_nlink > 1:
84-
result.append('* More than one link in `/sbin/init`. Check for Suckit Rootkit.')
85-
86-
# Check to see if certain files are being hidden. These files have the '.xrk' or '.mem' suffix.
87-
files = [
88-
lib.disk.get_tmpdir() + '/linuxfabrik-monitoring-plugins-scanrootdir.mem',
89-
lib.disk.get_tmpdir() + '/linuxfabrik-monitoring-plugins-scanrootdir.xrk',
90-
]
91-
for file in files:
92-
lib.base.coe(lib.disk.write_file(file, 'suckitexttest'))
93-
if not lib.disk.file_exists(file):
94-
result.append(
95-
f'* `{file}` created and is now hidden. Check for Suckit Rootkit.'
96-
)
97-
_, _ = lib.disk.rm_file(file)
98-
99-
return result
73+
content = ''
74+
if lib.disk.file_exists('/proc/kallsyms', allow_empty=True):
75+
content = lib.base.coe(lib.disk.read_file('/proc/kallsyms'))
76+
elif lib.disk.file_exists('/proc/ksyms', allow_empty=True):
77+
content = lib.base.coe(lib.disk.read_file('/proc/ksyms'))
78+
ksyms = set()
79+
for line in content.splitlines():
80+
parts = line.split()
81+
if len(parts) >= 3:
82+
ksyms.add(parts[2])
83+
return ksyms
10084

10185

10286
def main():
@@ -126,13 +110,8 @@ def main():
126110
relative=False,
127111
)
128112

129-
# get the kernel symbols file
130-
if lib.disk.file_exists('/proc/kallsyms', allow_empty=True):
131-
ksyms_file = lib.base.coe(lib.disk.read_file('/proc/kallsyms'))
132-
elif lib.disk.file_exists('/proc/ksyms', allow_empty=True):
133-
ksyms_file = lib.base.coe(lib.disk.read_file('/proc/ksyms'))
134-
else:
135-
ksyms_file = ''
113+
# get the set of exact kernel symbol names
114+
ksyms = load_kernel_symbols()
136115

137116
# analyze system
138117
for rootkit in rootkits:
@@ -159,48 +138,43 @@ def main():
159138
rkfound.append(f'* {rk["name"]}: {item} (Dir)')
160139

161140
# scan kernel symbols for signs of rootkits or other malicious software
162-
if not ksyms_file:
141+
if not ksyms:
163142
continue
164143
for item in rk['ksyms']:
165-
if item in ksyms_file:
144+
if item in ksyms:
166145
if 'cl' in rk and rk['cl'] < 100:
167146
rkpossible.append(f'* {rk["name"]}: {item} (Kernel Symbol)')
168147
else:
169148
rkfound.append(f'* {rk["name"]}: {item} (Kernel Symbol)')
170149

171150
except KeyError as e:
172-
# missing an yaml attribute like 'files' or 'dirs'
151+
# missing a yaml attribute like 'files' or 'dirs'
173152
errors.append(f'* {os.path.basename(rootkit)}: Key Error {e}')
174-
except yaml.parser.ParserError:
175-
# got yaml file that is syntactically wrong
176-
errors.append(f'* {os.path.basename(rootkit)}: YAML syntax error')
177-
178-
rkextra = extra_rootkit_checks()
153+
except yaml.YAMLError as e:
154+
# got a yaml file that is broken in any way
155+
errors.append(f'* {os.path.basename(rootkit)}: YAML error: {e}')
179156

180157
# build the message
181158
if rkscanned == 0:
182159
lib.base.cu(f'No rootkit definition files found in `{rkdef_path}`.')
183-
if not rkfound and not rkpossible and not rkextra:
160+
if not rkfound and not rkpossible:
184161
msg += (
185162
f'Everything is ok. Scanned for {rkscanned}'
186163
f' {lib.txt.pluralize("rootkit", rkscanned)}.'
187164
)
188165
else:
189166
if rkpossible:
190167
state = lib.base.get_worst(state, STATE_WARN)
191-
if rkfound or rkextra:
168+
if rkfound:
192169
state = lib.base.get_worst(state, lib.base.str2state(args.SEVERITY))
193170
msg += (
194171
f'Found {len(rkfound)} rootkit'
195-
f' {lib.txt.pluralize("item", len(rkfound))}'
196-
f' and {len(rkextra)} extra'
197-
f' {lib.txt.pluralize("item", len(rkextra))}.'
172+
f' {lib.txt.pluralize("item", len(rkfound))}.'
198173
f' {len(rkpossible)} possible rootkit'
199174
f' {lib.txt.pluralize("item", len(rkpossible))}'
200175
f' found. {lib.base.state2str(state)}'
201176
)
202177
msg += '\nRootkits:\n' + '\n'.join(rkfound) if rkfound else ''
203-
msg += '\nIn-depth scan:\n' + '\n'.join(rkextra) if rkextra else ''
204178
msg += '\nPossible Rootkits:\n' + '\n'.join(rkpossible) if rkpossible else ''
205179
msg += '\nScanfile Errors:\n' + '\n'.join(errors) if errors else ''
206180

@@ -209,11 +183,6 @@ def main():
209183
len(rkfound),
210184
_min=0,
211185
)
212-
perfdata += lib.base.get_perfdata(
213-
'rootkit_extra',
214-
len(rkextra),
215-
_min=0,
216-
)
217186
perfdata += lib.base.get_perfdata(
218187
'rootkit_possible',
219188
len(rkpossible),

check-plugins/scanrootkit/unit-test/run

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class TestCheck(unittest.TestCase):
3434
lib.disk.write_file('/tmp/.cinik', 'test02')
3535
stdout, stderr, retc = lib.base.coe(lib.shell.shell_exec(self.check))
3636
self.assertIn(
37-
'Found 2 rootkit items and 0 extra items. 0 possible rootkit items found. [CRITICAL]',
37+
'Found 2 rootkit items. 0 possible rootkit items found. [CRITICAL]',
3838
stdout,
3939
)
4040
self.assertIn('Rootkits:', stdout)
@@ -51,7 +51,7 @@ class TestCheck(unittest.TestCase):
5151
)
5252

5353
self.assertIn(
54-
'Found 2 rootkit items and 0 extra items. 0 possible rootkit items found. [WARNING]',
54+
'Found 2 rootkit items. 0 possible rootkit items found. [WARNING]',
5555
stdout,
5656
)
5757
self.assertIn('Rootkits:', stdout)

0 commit comments

Comments
 (0)