Package Integrity Verification: Catching Tampered Binaries
- Explain why package verification catches a trojaned binary: the package database records each file's expected size, mode, owner, and hash
- Run rpm -Va / rpm -V and read the SM5DLUGTP flag string to tell a tampered binary (5 or S) from a benign mtime-only change (T)
- Run dpkg --verify / debsums on Debian systems and read the ??5?????? output, knowing dpkg's built-in check only covers stored md5sums
- Tie a changed file back to the package that owns it with rpm -qf / dpkg -S, and cross-check against an external known-good source when the package DB itself may be tampered
- Build and use an AIDE baseline: aide --init, move aide.db.new into place, aide --check and its exit status, aide --update after a verified change, and read the report's Changed entries
Why package verification catches tampering
Frame integrity verification: the package DB is a known-good manifest the attacker has to defeat.
An attacker who gains root often replaces a system binary to hide. A trojaned `ls` omits their files from listings; a trojaned `ps` hides their process; a backdoored `sshd` logs every password or grants a magic login. Editing a config — say slipping a rule into
/etc/sudoersor/etc/pam.d/— is the same move on a text file.Here is the leverage you have as the responder: when the package manager installed that binary, it recorded exactly what the file should look like — its size, its permissions (mode), its owner and group, and a cryptographic hash of its contents. The attacker's replacement almost never matches all of those at once. The file on disk no longer agrees with the record the package manager kept.
Verification is simply re-checking the file on disk against that stored record, attribute by attribute, and reporting every difference.
Check yourself
Why can package verification detect a trojaned /usr/bin/ls even when the file looks normal in a directory listing?
RPM world: rpm -Va and the flag string
Run a full-system RPM verify and learn to read the SM5DLUGTP output.
On RPM-based systems (RHEL, Fedora, Rocky, AlmaLinux, SUSE) the verifier is built into
rpmitself.rpm -V <package>verifies the files of one package.rpm -Vaverifies all installed packages — the whole system in one sweep.
The critical skill is reading the output. Each changed file prints a nine-character flag string followed by an optional attribute marker and the path. A
.(dot) in a position means that attribute is unchanged; a letter means that attribute differs from what was installed.Example — not runnable on your lab box
sudo rpm -VaS.5....T. /usr/bin/ls .M....... g /var/log/journal S.5....T. c /etc/ssh/sshd_config .......T. /usr/share/man/man1/tar.1.gz missing /usr/bin/lsof
- -V
- Verify: compare installed files against the package database record. Long form: --verify.
- -a
- All packages — verify every installed package on the system.
Read each nine-character string left to right. S.5....T.on `/usr/bin/ls` is the alarm: S = size differs, 5 = MD5/SHA hash digest differs, T = mtime differs. A binary whose size and content hash both changed is a tampered binary — exactly the trojaned-lssignature. Thecnext to/etc/ssh/sshd_configmarks it a config file; thegnext to the journal marks a ghost file. The last line, `missing`, means a file the package installed is gone from disk.Memorise the nine positions in order — `SM5DLUGTP`:
- S — Size differs
- M — Mode differs (permission bits / file type)
- 5 — the MD5/hash digest differs (the file's contents changed)
- D — Device major/minor number differs
- L — symbolic Link target differs
- U — User (owner) differs
- G — Group differs
- T — mTime (last-modified time) differs
- P — caPabilities differ
Plus a trailing marker: `c` = config file, `d` = documentation, `g` = ghost, `l` = license, `r` = readme. A leading
missingreplaces the whole string when the file is absent.Check yourself
In an rpm -V flag string, which character position means the file's hash (its contents) no longer matches what was installed?
RPM world: verify one file and find its owner
Narrow from a whole-system sweep to a single suspect file and the package behind it.
A full
rpm -Vacan be noisy. Two narrower moves let you zero in on a suspect.rpm -Vf <file>verifies just the package that owns one file, andrpm -qf <file>answers which package owns it — so you can name the suspect precisely in your notes and pull a known-good copy from the same package version later.Example — not runnable on your lab box
rpm -Vf /usr/bin/lsS.5....T. /usr/bin/ls
- -V
- Verify mode.
- -f
- ...the package that owns this File — locate the owning package and verify it.
Instead of naming a package, you point -Vfat a path and it figures out the owning package and verifies it. TheS.5....T.line confirms the same finding in isolation: size and hash changed on/usr/bin/ls. If the binary were untouched, this command would print nothing at all — silence means 'matches the database'.Example — not runnable on your lab box
rpm -qf /usr/bin/psprocps-ng-4.0.4-2.el9.x86_64
- -q
- Query mode — every rpm read operation begins with -q.
- -f
- ...by File: given a path, name the package that owns it.
rpm -qfreturns the full package NVRA (name-version-release-arch). Now you know/usr/bin/psbelongs to `procps-ng-4.0.4-2.el9`. With the exact version in hand you can fetch that same RPM from a trusted mirror and compare itsls/psagainst the one on disk — your known-good reference.Debian world: dpkg --verify and debsums
Run the equivalent checks on a Debian/Ubuntu host and understand dpkg's narrower coverage.
Debian-family systems (Debian, Ubuntu, Mint) verify differently and with an important limitation.
dpkg --verify(also writtendpkg -V) checks installed files againstdpkg's records, using the same nine-position flag layout idea as RPM — but with a catch: by defaultdpkgonly knows the md5sums the maintainer shipped, and only checks the digest (5) for files that have a stored md5sum. Mode/owner verification is far thinner than RPM's, so many genuine changes simply won't surface.sudo dpkg --verify??5?????? /bin/ls ??5?????? /usr/sbin/sshd missing c /etc/cron.d/popularity-contest
- --verify
- Verify installed package files against dpkg's stored checksums. Short form: -V.
dpkg uses the same positional layout, but a ?means 'not checked / no data for this attribute' rather than 'unchanged'. The line `??5?????? /bin/ls` reads: dpkg couldn't check most attributes, but the `5` (digest) position is set — the content hash does not match the shipped md5sum. That is the trojaned-binary tell on Debian. The same5on `/usr/sbin/sshd` points at a backdoored SSH daemon.missingagain means an installed file is gone.Because
dpkg's built-in check only covers the md5sums it happens to have stored, it can miss files altogether.debsumsis the fuller tool: it computes and compares hashes for installed package files and flags each asOK,FAILED, orREPLACED.Example — not runnable on your lab box
sudo debsums -c/bin/ls /usr/sbin/sshd
- -c
- Report only files that FAILED their checksum (changed). Without it, debsums prints every file and its OK/FAILED status.
debsums -cis the 'show me only the problems' switch: it lists just the files whose content hash no longer matches. Here it independently confirms/bin/lsand/usr/sbin/sshdwere altered. Usedpkg -S /bin/lsto name the owning package (the Debian counterpart torpm -qf), exactly as you did on RPM.Check yourself
On Debian, dpkg --verify prints '??5??????' for /usr/sbin/sshd. What does the '5' mean, and what do the '?' characters mean?
The responder workflow and the package-DB trap
Sequence verify → identify → confirm, and account for an attacker who tampered the package DB itself.
Put the pieces into a repeatable workflow you can run on any host you're authorized to investigate:
1. Verify the whole system —
rpm -Vaordpkg --verify/debsums -c.2. Triage the output — focus on `5` (contents) and `S` (size) on executables and on changes to security configs (
cfiles likesshd_config,sudoers,pam.d). Set aside lone `T` (mtime-only) noise.3. Identify the owning package and version —
rpm -qf/dpkg -S.4. Confirm against known-good — re-download that exact package version from a trusted mirror and diff the file, or compare its hash to a vendor-published value.
That last step exists because of one nasty assumption baked into steps 1-3.
Check yourself
Why is 'rpm -Va comes back clean' not sufficient proof that no system binary was tampered with?
AIDE: a baseline you build yourself
Initialise an AIDE database, check against it, read the report, and update it after a legitimate change.
Package verification only knows about files a package shipped, and only the attributes the package manager recorded. Your edited
sshd_config, a new file in/etc/cron.d, a script in/usr/local/bin: none of them have a package record to compare against. AIDE (Advanced Intrusion Detection Environment) fills that gap with a baseline you build. It walks the paths its config names, stores each file's hashes, size, owner, mode and timestamps in a database, and later reports every difference: entries added, removed and changed.The workflow has four moves:
1. `aide --init` builds a new database,
aide.db.new, from the current state. Do it on a host you trust, ideally straight after install.2. Move it into place as
aide.db, the file--checkreads. Init never overwrites the live baseline by itself.3. `aide --check` compares the filesystem with
aide.dband prints a report. Run it on a schedule (Ubuntu's package installs a daily cron job).4. `aide --update` after a change you have verified is legitimate: it checks and writes a fresh
aide.db.new, which you move into place again.AIDE isn't installed on the lab box, so the next three blocks show a real Ubuntu 22.04 host (AIDE 0.17.4,
apt install aide). On RHEL the package is alsoaide, and the database is/var/lib/aide/aide.db.gz.Example — not runnable on your lab box
aide --config /etc/aide/aide.conf --init | head -n 2 mv /var/lib/aide/aide.db.new /var/lib/aide/aide.dbStart timestamp: 2026-10-02 18:48:36 +0000 (AIDE 0.17.4) AIDE initialized database at /var/lib/aide/aide.db.new
- --config FILE
- Use this configuration (Ubuntu's is /etc/aide/aide.conf; RHEL's is /etc/aide.conf, the default).
- --init
- Build a new database from the current filesystem, written to database_out (aide.db.new).
Init hashes every path the config selects (about 5,500 entries on a minimal Ubuntu system, done in under a minute) and writes aide.db.new. Themvpromotes it to the baseline. Ubuntu'saideinitwrapper does both steps. The full init output ends with the database's own hashes: record them somewhere off the box, because they let you prove later that nobody rebuilt the baseline.Example — not runnable on your lab box
echo '203.0.113.7 updates.example.com' >> /etc/hosts aide --config /etc/aide/aide.conf --check --limit /etc > /root/aide-report.txt; echo "aide exit status: $?" sed -n '/^Summary:/,/^Detailed/p' /root/aide-report.txtaide exit status: 4 Summary: Total number of entries: 5563 Added entries: 0 Removed entries: 0 Changed entries: 1 --------------------------------------------------- Changed entries: --------------------------------------------------- f >b... mc..H.. . : /etc/hosts --------------------------------------------------- Detailed information about changes:
- --check
- Compare the filesystem with database_in (aide.db) and report differences.
- --limit REGEX
- Only check paths matching REGEX (here everything under /etc).
A planted hosts entry, caught. The exit status is a bitmask you can alert on: 1 = entries added, 2 = removed, 4 = changed, so 4 means changes only, 5 means added plus changed, and 0 means clean. Each report line starts with a summary string: the first character is the type ( ffile,ddirectory), then one position per attribute, a.where nothing changed. Here>the size grew,bthe block count changed,mandcthe modify and change times moved, and `H` the content hash differs (AIDE before 0.17 printedCfor the same thing). AnHon a file nobody was supposed to edit is the line to chase. TheDetailed informationsection below the summary gives the old and new value of each attribute.Example — not runnable on your lab box
aide --config /etc/aide/aide.conf --update --limit /etc > /dev/null; echo "aide exit status: $?" mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db aide --config /etc/aide/aide.conf --check --limit /etc | sed -n 2paide exit status: 4 AIDE found NO differences between database and filesystem. Looks okay!!
- --update
- Check, then write a new database reflecting the current filesystem to aide.db.new.
Suppose the hosts entry was an approved change. --updateruns the same comparison (so it still exits 4) and writes the current state toaide.db.new. Promote it, and the next check is clean. Only update after you have explained every difference: running--updateover an unexplained change writes the attacker's edit into your baseline.cat > /root/aide-sample.txt <<'EOF' AIDE found differences between database and filesystem!! Summary: Total number of entries: 5562 Added entries: 1 Removed entries: 0 Changed entries: 2 --------------------------------------------------- Added entries: --------------------------------------------------- f+++++++++++++++++: /etc/cron.d/zz-new --------------------------------------------------- Changed entries: --------------------------------------------------- d =.... mc.. .. . : /etc/cron.d f >b... mc..H.. . : /etc/hosts --------------------------------------------------- Detailed information about changes: --------------------------------------------------- EOF sed -n '/^Changed entries:/,/^Detailed/p' /root/aide-sample.txt | grep ' : /'d =.... mc.. .. . : /etc/cron.d f >b... mc..H.. . : /etc/hosts
- sed -n '/A/,/B/p'
- Print only the lines from the first one matching A through the next one matching B.
You will often get the report, not the host: a mailed cron result or a saved file. This block saves a trimmed AIDE 0.17.4 report, then pulls out just the Changed entries section: sed -n '/START/,/END/p'prints from theChanged entries:header to theDetailedheader, andgrep ' : /'keeps the entry lines. Read them as a pair. The directory/etc/cron.dchanged only its times (mc), which is what adding a file to it does, and the Added section shows that file./etc/hostschanged content (H). Two leads: a new cron job and an edited hosts file.Check yourself
aide --check exits with status 2. What kind of difference did it find: added, removed or changed entries?
Try it in your live terminal
Run the verification workflow yourself on the real sandbox, distro-agnostic and read-only.
You have a real root shell. These commands only read state — they change nothing — and they work whether the sandbox is RPM-based or Debian-based. There may be nothing tampered here; the goal is to make the moves reflex. Remember: on RPM, no output from a verify means the file matches the database.
command -v rpm >/dev/null && echo RPM || (command -v dpkg >/dev/null && echo DPKG)Detect which package world you're in. Prints RPMon RHEL/Fedora/SUSE-family hosts, orDPKGon Debian/Ubuntu — so you know which verify command applies before you run anything.rpm -Vf $(command -v ls) 2>/dev/null || dpkg -V coreutils 2>/dev/null | head || echo "no pkg verify"Verify the package that owns your real ls. On RPM it runsrpm -Vfagainst the resolved path; on Debian it verifiescoreutils(which shipsls) and shows the first lines; if neither tool exists it prints a fallback note. Clean output (or no output on RPM) meanslsmatches what was installed.rpm -qf $(command -v ps) 2>/dev/null || dpkg -S $(command -v ps) 2>/dev/nullIdentify which package owns the psbinary — the ownership half of the loop. RPM answers with the package NVRA; Debian answers withpackage: /path. This is the version you'd re-pull from a trusted mirror to confirm a suspected tamper against known-good.rpm -Va 2>/dev/null | grep -E '^..5|^S' | head || sudo debsums -c 2>/dev/null | head || echo "install debsums for full Debian hash checks"The high-signal sweep: on RPM, filter a full verify down to lines where the hash (5) or size (S) changed — the tampered-binary candidates — hiding the mtime-only noise. On Debian it falls back to debsums -c(changed files only), or prompts you to install debsums if it's missing.
- The package database is a known-good manifest: it records each file's expected size, mode, owner, group, and content hash, so a tampered binary stops matching its own record.
- On RPM, read the
SM5DLUGTPflag string —5(hash) andS(size) on an executable mean tampering; a loneT(mtime) is usually benign;cmarks a config file you'd expect admins to edit. - On Debian,
dpkg --verifyonly checks stored md5sums (mostly the5position,?= not checked), so usedebsumsfor fuller coverage and never read a clean run as proof of integrity. - Run the loop verify -> triage -> identify owner (
rpm -qf/dpkg -S) -> confirm against an EXTERNAL known-good source, because an attacker with root can also rewrite the local package DB to hide the swap. - AIDE covers what packages don't (configs, unpackaged files):
aide --init, moveaide.db.newtoaide.db,aide --check(exit bits 1 added, 2 removed, 4 changed;H= content hash changed), andaide --updateonly after every difference is explained. Keep a copy of the baseline off the host.
| Flag | Position / marker | Means | Weight as an IOC |
|---|---|---|---|
S | 1 — Size | File size differs from installed | High on a binary |
M | 2 — Mode | Permission bits or file type differ | Medium (e.g. world-writable) |
5 | 3 — Digest | Content hash differs — the bytes changed | Highest on a binary |
D | 4 — Device | Device major/minor differs | Low / rare |
L | 5 — Link | Symlink target differs | Medium |
U | 6 — User | Owner differs | Medium |
G | 7 — Group | Group differs | Medium |
T | 8 — mTime | Modified-time differs only | Low — often benign noise |
P | 9 — caPs | File capabilities differ | Medium (privilege escalation) |
c | marker | File is a config file | Expected to change — review, don't panic |
missing | whole string | Installed file is absent from disk | High — investigate |
