wtmp, btmp and lastlog Forensics, wtmpdb and lastlog2
Linux login records in an investigation: struct utmp on x86_64 and aarch64, btmp failed logins, lastlog slots, wtmpdb and lastlog2 SQLite files, tampering.
TL;DR. wtmp is the history of interactive sessions and reboots, btmp the failed logins, utmp the sessions open right now, and lastlog the last login of each UID. They are fixed-size binary records written outside syslog, so they survive when text logs are edited and contradict them when they are not. Read them with a parser that knows the record size of the source architecture, and on newer distributions look for their SQLite replacements, wtmp.db and lastlog2.db.
The files
| File | Path | Reader | Content |
|---|---|---|---|
| wtmp | /var/log/wtmp, wtmp.1 | last | Logins, logouts, boots, shutdowns |
| btmp | /var/log/btmp, btmp.1 | lastb | Failed logins (root-only, sensitive) |
| utmp | /run/utmp | who, w | Current sessions, cleared at boot |
| lastlog | /var/log/lastlog | lastlog | One slot per UID |
| wtmpdb | /var/lib/wtmpdb/wtmp.db, /var/log/wtmp.db (Debian 13) | wtmpdb last | SQLite replacement for wtmp |
| lastlog2 | /var/lib/lastlog/lastlog2.db | lastlog2 | SQLite replacement for lastlog |
logrotate typically rotates wtmp and btmp monthly and keeps one old generation, so expect one to two months. lastlog keeps one record per UID indefinitely.
struct utmp: 384 bytes, or 400
wtmp, btmp and utmp share struct utmp:
| Field | Content |
|---|---|
ut_type | 1 RUN_LVL, 2 BOOT_TIME, 5 INIT_PROCESS, 6 LOGIN_PROCESS, 7 USER_PROCESS (login), 8 DEAD_PROCESS (logout) |
ut_pid | PID of the login process (sshd for SSH) |
ut_line | Terminal, pts/1, tty1, 32 bytes |
ut_user | User name, 32 bytes; empty on logout records |
ut_host | Remote host or IP; kernel version on reboot records; 256 bytes |
ut_tv | Seconds and microseconds, UTC |
ut_addr_v6 | Remote address |
On x86_64 glibc the record is 384 bytes: the time fields are 32-bit for compatibility with 32-bit programs. On aarch64 they are 64-bit and the record is 400 bytes. last reads records with the layout of the machine it runs on, so an aarch64 wtmp read with last on an x86_64 workstation produces garbage or nothing. Run last on a matching architecture, or use a parser that detects the layout from the file size and the plausibility of the fields.
A logout is a DEAD_PROCESS record on the same ut_line with an empty user; last pairs them. A login with no matching logout shows as gone - no logout, or crash when a boot record follows.
lastlog: one slot per UID
/var/log/lastlog is an array of struct lastlog, indexed by UID: 292 bytes per slot on x86_64 (32-bit time, 32-byte line, 256-byte host), 296 on aarch64. The offset of UID 1001 is 1001 × 292. The file is sparse: a login by UID 100000 makes it look enormous while only a few blocks are used. Collect it with tar --sparse.
The lastlog value is overwritten at each login, so it only tells you the latest one, but it is written independently of wtmp. A lastlog time for which wtmp has no session is a strong signal that wtmp lost a record.
wtmpdb and lastlog2
The 32-bit time fields overflow in 2038, so newer distributions switch to SQLite:
- wtmpdb: table
wtmp(ID, Type, User, Login, Logout, TTY, RemoteHost, Service).LoginandLogoutare microseconds since the epoch;Typeis 1BOOT_TIME, 2RUNLEVEL, 3USER_PROCESS. One row per session, with the logout filled in when the session ends, and the PAMServicename (sshd,login) that the classic format never had. - lastlog2: table
Lastlog2(Name, Time, TTY, RemoteHost, Service), withTimein seconds and one row per user name.
openSUSE Tumbleweed uses them by default. Debian 13 removed last, lastb and lastlog and puts the wtmpdb file at /var/log/wtmp.db; the packages must be installed separately, so an upgraded trixie host may have no login database at all. Collect the -wal file next to a database if one exists: recent rows may still be in it.
sqlite3 wtmp.db "SELECT User, TTY, RemoteHost, Service, datetime(Login/1000000,'unixepoch'), datetime(Logout/1000000,'unixepoch') FROM wtmp;"
sqlite3 lastlog2.db "SELECT Name, datetime(Time,'unixepoch'), TTY, RemoteHost, Service FROM Lastlog2;"
btmp: failed logins
btmp uses the same record layout. Each failed attempt is a record with the attempted user name, the source host and the time. It is a third, independent copy of the password guessing also found in auth.log and the journal, useful when those were cleaned. Treat it as sensitive: users sometimes type their password into the user name field.
What tampering looks like
- Blanked records. Log cleaners overwrite a login record with zeros rather than deleting it, so the file size stays a multiple of the record size.
lastsimply skips it. Inutmpdumpoutput it appears as a record with type 0, no user and a 1970 date. - Partial record at the end. A file size that is not a multiple of 384 (or 400) means a truncated write, or a file copied while being written.
- Time going backwards between consecutive records.
- Zero-byte wtmp or btmp with an old modification time on a server that is in use.
- An interactive SSH session in auth.log, the journal and audit.log, but not in wtmp, while wtmp covers that period.
Remember the benign case: ssh host command, scp and sftp do not open a terminal and often leave no wtmp record. Compare interactive sessions only (a pts terminal in the logs).
Commands
TZ=UTC last -F -i -x -f wtmp # full dates, IPs, reboots
TZ=UTC lastb -F -i -f btmp
utmpdump wtmp > wtmp.txt # every record, including blanked ones
wtmpdb last -F -f wtmp.db
lastlog2 -d lastlog2.db
last prints in the analysis host's zone unless TZ=UTC is set.
In the browser
Linux Log Parser reads wtmp, btmp and utmp in both the 384-byte and 400-byte layouts, lastlog, wtmp.db and lastlog2.db; it reports blanked, partial and backwards records, pairs logins with logouts, merges them with sshd, logind and audit sessions, and flags interactive logins with no wtmp record. See the tampering guide and the wtmp cheat sheet.