osquery for fleet visibility

Query the host as tables, schedule differentials.

Advanced14 min · lesson 8 of 15

osquery presents a running host as a set of database tables (processes, listening sockets, users, SUID files, systemd units and about 150 more) that you query with SQL. The value is consistency: the same question returns the same columns on every host, so you can compare machines, schedule the question and log only what changed. In this lesson you install osquery 5.23.1 from the vendor's repository, query the tables that matter to a defender, schedule a differential query that catches a planted SUID file, and learn where osquery's view stops. The lab host is Ubuntu 26.04; osquery publishes the same packages for RHEL.

Install from the vendor's repository

osquery is not in the Ubuntu or RHEL archives. The vendor publishes an apt repository at pkg.osquery.io signed with its own key, and the safe pattern is to store that key in a keyring file and name it with signed-by on the repository line, so it can vouch for that repository and nothing else (the pattern from "Packages and updates" in Linux essentials). First fetch the key and check its fingerprint.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo mkdir -p /etc/apt/keyrings curl -fsSL https://pkg.osquery.io/deb/pubkey.gpg | sudo tee /etc/apt/keyrings/osquery.asc >/dev/null gpg --show-keys /etc/apt/keyrings/osquery.asc
… pub rsa4096 2015-01-24 [SC] 1484120AC4E9F8A1A577AEEE97A80C63C9D8B80B uid osquery (osquery) <osquery@fb.com> sub rsa4096 2015-01-24 [E]

The fingerprint 1484120A C4E9F8A1 A577AEEE 97A80C63 C9D8B80B is the one osquery publishes on its download page. Compare it before you trust the file; a key fetched over HTTPS from the right host can still be the wrong key. The vendor's line hard-codes arch=amd64; the lab host is arm64, so the architecture comes from dpkg.

deploy@web01 · Ubuntu 26.04 LTS
$ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/osquery.asc] https://pkg.osquery.io/deb deb main" | sudo tee /etc/apt/sources.list.d/osquery.list
deb [arch=arm64 signed-by=/etc/apt/keyrings/osquery.asc] https://pkg.osquery.io/deb deb main
$ sudo apt-get update 2>&1 | grep osquery
Get:1 https://pkg.osquerypackages.com/deb deb InRelease [102 kB] Get:6 https://pkg.osquerypackages.com/deb deb/main arm64 Packages [13.3 kB]
$ sudo apt-get install -y osquery
… The following NEW packages will be installed: osquery 0 upgraded, 1 newly installed, 0 to remove and 6 not upgraded. … Get:1 https://pkg.osquerypackages.com/deb deb/main arm64 osquery arm64 5.23.1-1.linux [31.0 MB] … Unpacking osquery (5.23.1-1.linux) ... Setting up osquery (5.23.1-1.linux) ... …

apt followed the repository's redirect to pkg.osquerypackages.com, and the only new package is osquery 5.23.1 for arm64 (the full output also shows needrestart's usual report). It installs under /opt/osquery and puts osqueryi (the interactive shell) and osqueryd (the daemon) on the path.

deploy@web01 · Ubuntu 26.04 LTS
$ osqueryi --version; systemctl is-enabled osqueryd; systemctl is-active osqueryd
osqueryi version 5.23.1 disabled inactive

The daemon is installed but neither enabled nor running, so nothing happens until you configure it. osqueryi needs no daemon at all.

The host as tables

osquery tables are virtual: nothing is stored, and each query collects its answer at that moment from /proc, the kernel and system files. .tables lists them and .schema shows a table's columns. Run queries with sudo when you want the whole host; as an ordinary user some tables see only what that user may read.

deploy@web01 · Ubuntu 26.04 LTS
$ osqueryi ".tables" | wc -l
157
$ osqueryi ".schema listening_ports"
CREATE TABLE listening_ports(`pid` INTEGER, `port` INTEGER, `protocol` INTEGER, `family` INTEGER, `address` TEXT, `fd` BIGINT, `socket` BIGINT, `path` TEXT, `net_namespace` TEXT);

Joins are where SQL pays off. This query lists root processes running from /usr/local (outside the package manager's territory) with the name of their parent.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo osqueryi "SELECT p.pid, p.name, p.path, pp.name AS parent FROM processes p JOIN processes pp ON p.parent = pp.pid WHERE p.uid = 0 AND p.path LIKE '/usr/local/%';"
+-----+-----------------+--------------------------------+---------+ | pid | name | path | parent | +-----+-----------------+--------------------------------+---------+ | 899 | lima-guestagent | /usr/local/bin/lima-guestagent | systemd | +-----+-----------------+--------------------------------+---------+

The one hit is lima-guestagent, the lab VM tool's agent, started by systemd. On a production server the same query surfaces anything someone installed by hand and runs as root, which is where you want to look first. The next query joins listening sockets to processes and keeps only addresses reachable from outside the host.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo osqueryi "SELECT DISTINCT p.name, l.port, l.protocol, l.address FROM listening_ports l JOIN processes p USING (pid) WHERE l.protocol IN (6, 17) AND l.address NOT LIKE '127.%' AND l.address != '::1' ORDER BY l.port;"
+-----------------+------+----------+--------------+ | name | port | protocol | address | +-----------------+------+----------+--------------+ | sshd | 22 | 6 | 0.0.0.0 | | sshd | 22 | 6 | :: | | systemd-network | 68 | 17 | 192.168.5.15 | | systemd-resolve | 5353 | 17 | 0.0.0.0 | | systemd-resolve | 5353 | 17 | :: | +-----------------+------+----------+--------------+

Protocol 6 is TCP and 17 is UDP. sshd on port 22 (IPv4 0.0.0.0 and IPv6 ::) and the DHCP client on port 68 are what a default Ubuntu server exposes. The multicast DNS listener on 5353 comes from the VM tool's resolver setting, not from Ubuntu, which is exactly the kind of line this query exists to make you explain. Two more checks: accounts with UID 0, and the SUID and SGID programs.

deploy@web01 · Ubuntu 26.04 LTS
$ osqueryi "SELECT uid, gid, username, shell FROM users WHERE uid = 0;"
+-----+-----+----------+-----------+ | uid | gid | username | shell | +-----+-----+----------+-----------+ | 0 | 0 | root | /bin/bash | +-----+-----+----------+-----------+
$ sudo osqueryi "SELECT path, username, groupname, permissions FROM suid_bin WHERE path LIKE '/usr/%' ORDER BY path;"
+---------------------------------+----------+-----------+-------------+ | path | username | groupname | permissions | +---------------------------------+----------+-----------+-------------+ | /usr/bin/chage | root | shadow | G | | /usr/bin/chfn | root | root | S | | /usr/bin/chsh | root | root | S | | /usr/bin/crontab | root | crontab | G | | /usr/bin/dotlockfile | root | mail | G | | /usr/bin/expiry | root | shadow | G | … | /usr/bin/passwd | root | root | S | | /usr/bin/sg | root | root | S | | /usr/bin/ssh-agent | root | _ssh | G | | /usr/bin/su | root | root | S | | /usr/bin/su-rs | root | root | S | | /usr/bin/sudo | root | root | S | | /usr/bin/sudo-rs | root | root | S | | /usr/bin/sudo.ws | root | root | S | | /usr/bin/sudoedit | root | root | S | | /usr/bin/sudoedit-rs | root | root | S | | /usr/bin/umount | root | root | S | … | /usr/sbin/unix_chkpwd | root | shadow | G | +---------------------------------+----------+-----------+-------------+

Only root has UID 0; a second UID 0 account is a classic backdoor. suid_bin reports S for set-user-ID and G for set-group-ID programs, the owner and group whose identity they lend. The sudo, sudo-rs and su-rs names are symlinks into /usr/lib/cargo/bin (sudo-rs), and the table reports the bits of the file they point to. There is a trap in the row count.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo osqueryi "SELECT count(*) AS all_rows, sum(path LIKE '/usr/%') AS under_usr FROM suid_bin;"
+----------+-----------+ | all_rows | under_usr | +----------+-----------+ | 54 | 27 | +----------+-----------+

54 rows, but only 27 under /usr. Ubuntu 26.04 is usr-merged: /bin and /sbin are symlinks to /usr/bin and /usr/sbin, and the table walks both, so every file appears twice. Filter on /usr/ paths, or you will count and alert on everything twice. Last, local systemd units, a favourite persistence spot.

deploy@web01 · Ubuntu 26.04 LTS
$ osqueryi "SELECT id, active_state, fragment_path FROM systemd_units WHERE fragment_path LIKE '/etc/systemd/system/%';"
+-------------------------+--------------+---------------------------------------------+ | id | active_state | fragment_path | +-------------------------+--------------+---------------------------------------------+ | lima-guestagent.service | active | /etc/systemd/system/lima-guestagent.service | +-------------------------+--------------+---------------------------------------------+

Units under /etc/systemd/system were written locally rather than shipped by a package, so each one should have an owner you can name. Here it is the VM tool's agent again.

Three ways to ask the same question
osqueryi: now
one host, this moment
triage, hunting, audits
run as root
or tables see only your view
osqueryd: over time
scheduled queries
interval per query
differential results
only added and removed rows
a fleet manager: everywhere
agents poll a TLS server
config, results, live queries
one query, every host
answers in seconds
Same SQL in all three; what changes is where it runs and when.

Watching for change: a scheduled differential query

osqueryd runs queries on a schedule and writes results to a log. By default a scheduled query is differential: each run is compared with the previous one and only rows that appeared (added) or disappeared (removed) are logged. Setting "snapshot": true logs the whole result every time instead. This configuration watches the SUID set every 10 seconds; that interval and schedule_splay_percent: 0 (no random jitter) are for the lab, where an hour and the default jitter suit a fleet.

/etc/osquery/osquery.conf
{
"options": {
"schedule_splay_percent": 0
},
"schedule": {
"suid_watch": {
"query": "SELECT path, username, permissions FROM suid_bin;",
"interval": 10
}
}
}
deploy@web01 · Ubuntu 26.04 LTS
$ sudo osqueryctl config-check && echo "config OK"
I0927 08:43:57.559324 100176 database.cpp:655] Failed to obtain database version. Assume version '0' and migrate. config OK
$ sudo systemctl start osqueryd; systemctl is-active osqueryd
active

osqueryctl config-check validates the file before the daemon reads it; the database.cpp line is osquery creating its local state database on first use. Once the first run has finished, count what it logged.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo jq -r "[.name, .action] | @tsv" /var/log/osquery/osqueryd.results.log | sort | uniq -c
54 suid_watch added

All 54 rows arrived as added. The first run of a differential query has nothing to compare against, so the whole current state is logged once; expect that burst from every host when you roll a query out, and do not page on it. Now plant a harmless test file, a SUID-root copy of true, in a directory the table watches.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo install -m 4755 -o root /usr/bin/true /usr/local/bin/oq-probe
$ sudo grep oq-probe /var/log/osquery/osqueryd.results.log | jq -c "{name, action, columns}"
{"name":"suid_watch","action":"added","columns":{"path":"/usr/local/bin/oq-probe","permissions":"S","username":"root"}}

One line of JSON, written without anyone asking: the query name, "action":"added" and the columns of the new row. Remove the file and the next run reports that too.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo rm /usr/local/bin/oq-probe
$ sudo grep oq-probe /var/log/osquery/osqueryd.results.log | jq -r "[.action, .columns.path] | @tsv"
added /usr/local/bin/oq-probe removed /usr/local/bin/oq-probe

The results log, /var/log/osquery/osqueryd.results.log with the default filesystem logger, is the thing to ship off the host with the rest of your telemetry (linux-det/auditpipe). Across a fleet, osquery agents are usually pointed at a management server over TLS, which hands them their configuration, collects results and runs live queries on every host at once; the TLS remote API is documented by osquery, and several open-source and commercial servers implement it.

A scheduled query can also go quiet with nothing in the results log to say so. osqueryd runs as two processes, a watchdog and the worker that executes the schedule. Its own health lives in tables such as osquery_schedule, which only the daemon fills, so osqueryi --connect queries the running daemon through its extension socket.

deploy@web01 · Ubuntu 26.04 LTS
$ pgrep -a osqueryd
100229 /opt/osquery/bin/osqueryd --flagfile /etc/osquery/osquery.flags --config_path /etc/osquery/osquery.conf 100242 /opt/osquery/bin/osqueryd
$ sudo osqueryi --connect /var/osquery/osquery.em "SELECT name, interval, executions, denylisted FROM osquery_schedule;"
Connected to extension socket /var/osquery/osquery.em for debugging +------------+----------+------------+------------+ | name | interval | executions | denylisted | +------------+----------+------------+------------+ | suid_watch | 10 | 3 | 0 | +------------+----------+------------+------------+
$ sudo osqueryi --connect /var/osquery/osquery.em "SELECT name, value FROM osquery_flags WHERE name LIKE 'watchdog%' OR name = 'disable_watchdog';"
Connected to extension socket /var/osquery/osquery.em for debugging +--------------------------------+-------+ | name | value | +--------------------------------+-------+ | disable_watchdog | false | | watchdog_delay | 60 | | watchdog_forced_shutdown_delay | 4 | | watchdog_latency_limit | 0 | | watchdog_level | 0 | | watchdog_max_delay | 600 | | watchdog_memory_limit | 0 | | watchdog_utilization_limit | 0 | +--------------------------------+-------+

The first osqueryd (100229), started by systemd with the flag file, is the watchdog; the second is the worker it spawned. suid_watch has run 3 times and is not denylisted. watchdog_level 0 is the normal set of limits, with the memory and CPU overrides unset (0), enforced from 60 seconds after the worker starts (watchdog_delay). The watchdog kills a worker that exceeds its CPU or memory limits and starts a new one, and osquery's debugging guide says the query that was running is then denylisted for 24 hours: that detection simply stops, and denylisted turns 1. Differential state lives in osquery's local database (/var/osquery/osquery.db), so a reinstall or a lost database replays the full added burst. And an agent that root stops just stops reporting. So collect osquery_schedule from every host, and alert centrally when a host's results or check-ins stop arriving.

Where osquery's view stops

suid_bin does not scan the whole filesystem. Its source lists fixed directories: /bin, /sbin, /usr/bin, /usr/sbin, /usr/local/bin, /usr/local/sbin and /tmp. Put the same test file in /var/tmp and compare with the generic file table, which reads exactly the directory you name.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo install -m 4755 -o root /usr/bin/true /var/tmp/oq-probe sudo osqueryi "SELECT count(*) AS seen FROM suid_bin WHERE path LIKE '%oq-probe';" sudo osqueryi "SELECT path, mode FROM file WHERE directory = '/var/tmp' AND mode LIKE '4%';"
+------+ | seen | +------+ | 0 | +------+ +-------------------+------+ | path | mode | +-------------------+------+ | /var/tmp/oq-probe | 4755 | +-------------------+------+
$ find /usr/lib -xdev -perm -4000 -type f 2>/dev/null
/usr/lib/openssh/ssh-keysign /usr/lib/dbus-1.0/dbus-daemon-launch-helper /usr/lib/cargo/bin/su /usr/lib/cargo/bin/sudo

suid_bin does not see /var/tmp/oq-probe at all, while file finds it with mode 4755. find shows four SUID files under /usr/lib that suid_bin never scans directly: ssh-keysign and the D-Bus launch helper are simply absent from the table, and the two sudo-rs binaries appear only through their /usr/bin symlinks. Know each table's scope before you trust a clean result; pair suid_bin with a file query on writable directories, or with the baseline scan from linux-det/pedetect.

The other limits follow from how osquery works. Scheduled queries poll, so something that starts and exits between two runs never appears. osquery can subscribe to process events instead, through one of two publishers, both off by default (the configuration above uses neither). The audit-based one needs the kernel's audit connection, and osquery's documentation says auditd must not run alongside it. That matters most on RHEL, where auditd runs out of the box (linux-det/auditpipe), and on Ubuntu once you install it. The eBPF publisher (the bpf_process_events table) avoids that conflict. Some tables, such as hash or file with wide patterns, read a lot of disk; give them long intervals. And every answer comes through the kernel of the host being asked.

The agent trusts the host it runs on
Hiding on a compromised host happens at two levels. A user-space implant changes what shared libraries return to the programs that load them, so two tools can disagree about the same process or file, and comparing osquery with ps, ls and a direct read of /proc can expose it. A kernel implant (a module or an eBPF program, run with root) changes what the kernel itself returns, so every tool on the host, osquery included, agrees on the same false answer. A clean processes or kernel_modules result on a suspect host therefore proves little. Use osquery for wide, cheap visibility and change detection, and confirm serious findings from sources the host cannot edit: network sensors, logs already shipped off the host, or a memory image.
deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl stop osqueryd; systemctl is-active osqueryd
inactive

The lab stopped the daemon here and then purged the package, the repository line, the keyring and /var/log/osquery, so no agent keeps running.

Try this

With osquery installed on a lab host, run sudo osqueryi "SELECT count(*) FROM suid_bin WHERE path LIKE '/usr/%';" and note the number (27 on a default Ubuntu 26.04 lab). Create /etc/osquery/osquery.conf from this lesson, start osqueryd, and wait until /var/log/osquery/osqueryd.results.log exists; count its added rows with the jq pipeline above and confirm it is twice your number. Create sudo install -m 4755 -o root /usr/bin/true /usr/local/bin/probe, wait ten seconds and find one new added row for it; remove the file and find the removed row. Then repeat the experiment in /var/tmp and confirm nothing is logged. Stop the daemon when you are done.

Takeaway

Treat osquery results as a baseline plus a change log: expect the first differential run to report everything, filter out the usr-merge duplicates, and alert on later added rows. Check each table's scope before trusting a clean answer, and ship the results log off the host.

Quick check
01You roll the suid_watch query out to 400 hosts. Within an hour your SIEM holds roughly 54 added rows from every host. What does that mean?
Incorrect — Identical rows from every host at rollout time are the signature of the first run, not of an attack.
Incorrect — Scheduled queries are differential unless you set snapshot to true; snapshot rows are not tagged added.
Correct — The first run reports everything as added; from then on only real changes appear, so suppress the initial burst.
Incorrect — osquery has no acknowledgement step; after the first run it logs only rows that changed.
02An intruder leaves a SUID-root copy of a shell at /var/tmp/.x. Your suid_watch differential query never reports it. Why not?
Correct — The table's search paths are fixed in its source; cover other writable directories with a file query or a separate scan.
Incorrect — Each scheduled run queries the table afresh; the lab's planted file under /usr/local/bin appeared without a restart.
Incorrect — Differential results report both added and removed rows; the lab logged the planted file as added.
Incorrect — The hidden name is not the reason; the lab's visible /var/tmp/oq-probe was missed for the same cause, its directory.
03On a host you suspect is compromised, SELECT * FROM kernel_modules and a scan of processes both look normal. How much should that reassure you?
Incorrect — osquery is itself a user-space program reading kernel interfaces, and an attacker with root can change what those return.
Incorrect — A genuine agent still depends on what the kernel tells it; a module that hides itself hides from every honest reader.
Incorrect — Hiding does not require crashing anything; it only requires filtering what the kernel reports.
Correct — Network sensors, logs already shipped away and memory images are sources the compromised host cannot rewrite.

Related