Workaround For Duplicate LVM Names

One situation that I run into all too frequently in Linux investigations is disk images the come from multiple systems but which were originally created from a common “master” image (or which use a generic LVM2 volume group name like “linux_lvm“). This creates a problem if you are trying to mount these images simultaneously on the same analysis workstation. Linux simply refuses to mount your image if the volume group name, volume group UUID, or the individual file system UUIDs are the same as an already mounted file system.

One workaround is to create a working copy of your image file and then use tools like vgrename, vgchange, pvchange, etc to destructively modify the necessary names and UUIDs. Or you could use overlayfs, which does an implicit copy of your image file into the merged directory (and adds overhead). But what I’ve been really looking for is a way to create an on-the-fly write cache that would absorb the necessary changes without impacting the original underlying image.

When I ran into this issue again on a recent case and my research ran down the same useless rabbit holes, I threw the question out to my social media contacts. James Dunn gave me the nudge I needed to finally solve the problem to my satisfaction– using xmount with the “--cache” option.

Before we begin, let me document the starting checksums for our sample disk images, so that we can verify at the end of this process that they have not changed:

# md5sum host*/*
e3b85cad126731e76955b2240b69f39d host1/disk1.raw
e3b85cad126731e76955b2240b69f39d host2/disk1.raw

For this test scenario, I am actually using two copies of the exact same image. This is your worst nightmare in real casework. Two different hosts that were spawned from an identical master image. Every single UUID down to the individual file system level will be duplicated.

Getting Set Up With xmount

Your Linux distro will likely have a pre-compiled version of the xmount package, but you may have to install it (“apt install xmount” or similar). We are also going to need to create directories where xmount is going to create its virtual image files:

# mkdir -p /mnt/host1/xmount/disk1
# mkdir -p /mnt/host2/xmount/disk1

If you have multiple disks, you can simply repeat the above commands for the other disk numbers.

Now for the xmount magic:

# xmount --cache /mnt/host1/xmount/cache1 --in raw host1/disk1.raw /mnt/host1/xmount/disk1
# xmount --cache /mnt/host2/xmount/cache1 --in raw host2/disk1.raw /mnt/host2/xmount/disk1
# ls -lh /mnt/host*/xmount/*
-rw-r--r-- 1 root root 720K Aug 2 16:38 /mnt/host1/xmount/cache1
-rw-r--r-- 1 root root 720K Aug 2 16:38 /mnt/host2/xmount/cache1

/mnt/host1/xmount/disk1:
total 0
-rw-rw-rw- 1 root root 60G Jan 1 1970 disk1.dd
-r--r--r-- 1 root root 266 Jan 1 1970 disk1.info

/mnt/host2/xmount/disk1:
total 0
-rw-rw-rw- 1 root root 60G Jan 1 1970 disk1.dd
-r--r--r-- 1 root root 266 Jan 1 1970 disk1.info

The disk1.dd objects are virtual files created by xmount. At the moment they have not changed compared to the original image files on disk. But as we change LVM and UUID data, those changes will be reflected in the .../cache* files without impacting the underlying disk images. Note that xmount can also handle initial disk images in E01 and AFF format, but we will want the resulting virtual file to be in the default raw format (*.dd).

So it’s xmount‘s cache-backed disk1.dd files that we want to work with going forward. The next step is to set up loopback devices pointing at these virtual files:

# losetup -fP --show /mnt/host1/xmount/disk1/disk1.dd
/dev/loop5
# losetup -fP --show /mnt/host2/xmount/disk1/disk1.dd
/dev/loop6

Typically we would use the “-r” (read-only) option when setting up a loopback device for forensic purposes. But in this case we are anticipating having to change the volume metadata in our virtual disk images, so read-only is not appropriate.

Shout out to the “-P” option which causes losetup to automatically create sub-devices for the partitions in each disk image:

# file -Ls /dev/loop[56]p*
/dev/loop5p1: Linux rev 1.0 ext4 filesystem data, UUID=13fe4d4f-9291-4c1b-b0df-14b58d2a3e87 (extents) (64bit) (large files) (huge files)
/dev/loop5p2: LVM2 PV (Linux Logical Volume Manager), UUID: v4T9wI-LDPP-1fJG-7Siu-LrYh-dFr3-pcAEVP, size: 62423826432
/dev/loop6p1: Linux rev 1.0 ext4 filesystem data, UUID=13fe4d4f-9291-4c1b-b0df-14b58d2a3e87 (extents) (64bit) (large files) (huge files)
/dev/loop6p2: LVM2 PV (Linux Logical Volume Manager), UUID: v4T9wI-LDPP-1fJG-7Siu-LrYh-dFr3-pcAEVP, size: 62423826432

In typical Linux fashion, each disk has a Linux file system partition up front, which will be the /boot file system, and an LVM2 volume for the other file systems and swap. But because we are using two copies of the same image you will note that the LVM2 volume UUIDs and even the Linux filesystem UUIDs are the same. And of course this pattern continues throughout the rest of the volume metadata.

Physical Volume Conflicts

The first indication that a problem exists is when we try a typical command to access the LVM2 volume:

# vgscan
WARNING: Not using device /dev/loop6p2 for PV v4T9wI-LDPP-1fJG-7Siu-LrYh-dFr3-pcAEVP.
WARNING: PV v4T9wI-LDPP-1fJG-7Siu-LrYh-dFr3-pcAEVP prefers device /dev/loop5p2 because device was seen first.
Found volume group "LabVM" using metadata type lvm2

vgscan is sensing the two LVM physical volumes with the same UUID. It sees the lower numbered loop device first, /dev/loop5p2, so /dev/loop6p2 throws an error. Until we resolve the conflict here, we will not be able to proceed.

pvchange allows us to change the LVM2 physical volume UUID, but the command fails with an error:

# pvchange -u /dev/loop6p2
WARNING: Not using device /dev/loop6p2 for PV v4T9wI-LDPP-1fJG-7Siu-LrYh-dFr3-pcAEVP.
WARNING: PV v4T9wI-LDPP-1fJG-7Siu-LrYh-dFr3-pcAEVP prefers device /dev/loop5p2 because device was seen first.
0 physical volumes changed / 0 physical volumes not changed

While both loopback devices exist, the duplicate UUIDs are going to cause our commands to fail. So we actually need to tear down the /dev/loop6 device in order to make changes to host1 disk image:

# losetup -d /dev/loop6
# pvdisplay
--- Physical volume ---
PV Name /dev/loop5p2
VG Name LabVM
[...]
PV UUID v4T9wI-LDPP-1fJG-7Siu-LrYh-dFr3-pcAEVP
# pvchange -u /dev/loop5p2
Physical volume "/dev/loop5p2" changed
1 physical volume changed / 0 physical volumes not changed
# pvdisplay
--- Physical volume ---
PV Name /dev/loop5p2
VG Name LabVM
[...]
PV UUID 0FNYGG-WUYv-wTDe-OBVt-G1Xq-KygF-4bSxDy

Logical Volume Conflicts

The physical volume UUID is changed, so we should be good to go with the second disk, right? Yeah, about that:

# losetup -fP --show /mnt/host2/xmount/disk1/disk1.dd
/dev/loop6
# vgscan
WARNING: ignoring metadata seqno 4 on /dev/loop6p2 for seqno 5 on /dev/loop5p2 for VG LabVM.
WARNING: Inconsistent metadata found for VG LabVM.
See vgck --updatemetadata to correct inconsistency.
WARNING: outdated PV /dev/loop6p2 seqno 4 has been removed in current VG LabVM seqno 5.
See vgck --updatemetadata to clear outdated metadata.
Found volume group "LabVM" using metadata type lvm2

The good news is that we’ve resolved the conflict with the physical volume UUID, but now the volume group name is a conflict. Again we have to tear down /dev/loop6 so we can make additional changes:

# losetup -d /dev/loop6
# vgrename LabVM vg1
Volume group "LabVM" successfully renamed to "vg1"
# vgchange -u vg1
Volume group "vg1" successfully changed.
# vgdisplay vg1
--- Volume group ---
VG Name vg1
[...]
VG UUID 3lprSv-oRnK-FxdG-t2ns-Bcog-j1H3-HlJWuV

vgrename changes the volume group name (pick any name that is meaningful to you), but in this case that is insufficient. We also need to use “vgchange -u” to change the volume group UUID of the volume.

File System UUIDs

Unfortunately, this isn’t the end of our troubles. File systems have UUIDs too, and duplication here will prevent Linux from mounting the file systems from the LVM group.

First we need to activate the file systems from our LVM volume:

# vgchange -a y vg1
3 logical volume(s) in volume group "vg1" now active
# file -Ls /dev/vg1/*
/dev/vg1/home: Linux rev 1.0 ext4 filesystem data, UUID=d19118ff-f5d5-40a0-970a-d4b310934f46 (extents) (64bit) (large files) (huge files)
/dev/vg1/root: Linux rev 1.0 ext4 filesystem data, UUID=ee7fb811-d8f1-4584-8657-69e1298fe122 (extents) (64bit) (large files) (huge files)
/dev/vg1/var: Linux rev 1.0 ext4 filesystem data, UUID=df99460c-b71c-435f-8410-ef0e1ecaac91 (extents) (64bit) (large files) (huge files)

The file command shows us the three file systems and their respective UUIDs. To change the UUID for an EXT4 file system, use “tune2fs -U random” (for XFS, “xfs_admin -U generate“). But we have another issue:

# tune2fs -U random /dev/vg1/root
tune2fs 1.47.2 (1-Jan-2025)
This operation requires a freshly checked filesystem.
Please run e2fsck -f on the filesystem.

The file system is hasn’t been consistency checked recently. So we continue to lean on our xmount cache and run e2fsck on the file system:

# e2fsck -f /dev/vg1/root
e2fsck 1.47.2 (1-Jan-2025)
Pass 1: Checking inodes, blocks, and sizes
Pass 2: Checking directory structure
Pass 3: Checking directory connectivity
Pass 4: Checking reference counts
Pass 5: Checking group summary information
/dev/vg1/root: 389899/1831424 files (0.1% non-contiguous), 4405452/7323648 blocks
# tune2fs -U random /dev/vg1/root
tune2fs 1.47.2 (1-Jan-2025)
Setting the UUID on this filesystem could take some time.
Proceed anyway (or wait 5 seconds to proceed) ? (y,N) y
# file -Ls /dev/vg1/root
/dev/vg1/root: Linux rev 1.0 ext4 filesystem data, UUID=ae23562b-2e78-42fc-a212-c34bfc6fdd0b (extents) (64bit) (large files) (huge files)

That’s one file system UUID changed, but we need to repeat this process for the other two file systems:

# e2fsck -f /dev/vg1/var
[...]
# tune2fs -U random /dev/vg1/var
[...]
# e2fsck -f /dev/vg1/home
[...]
# tune2fs -U random /dev/vg1/home
[...]

Mount First Modified Image

With all of the renaming out of the way, we can now go through the usual process for mounting this modified disk image:

# mkdir -p /mnt/host1/files
# mount -o ro,noexec /dev/vg1/root /mnt/host1/files
# mount -o ro,noexec /dev/vg1/var /mnt/host1/files/var
# mount -o ro,noexec /dev/vg1/home /mnt/host1/files/home
# ls /mnt/host1/files
bin etc initrd.img lib32 lost+found opt run sys var
boot home initrd.img.old lib64 media proc sbin tmp vmlinuz
dev images lib libx32 mnt root srv usr vmlinuz.old

For completeness, we really should mount the /boot file system from the first partition on the disk:

# mount -o ro,noexec /dev/loop5p1 /mnt/host1/files/boot
# ls /mnt/host1/files/boot
System.map-5.10.0-20-amd64 grub vmlinuz-5.10.0-20-amd64
System.map-5.10.0-21-amd64 initrd.img-5.10.0-20-amd64 vmlinuz-5.10.0-21-amd64
config-5.10.0-20-amd64 initrd.img-5.10.0-21-amd64
config-5.10.0-21-amd64 lost+found

We can see the file systems are mounted. Now let’s verify that mounting the file system and all of the various changes we’ve made have not changed the original image:

# md5sum host1/disk1.raw
e3b85cad126731e76955b2240b69f39d host1/disk1.raw
# ls -lh /mnt/host1/xmount/*
-rw-r--r-- 1 root root 328M Aug 2 17:17 /mnt/host1/xmount/cache1

/mnt/host1/xmount/disk1:
total 0
-rw-rw-rw- 1 root root 60G Jan 1 1970 disk1.dd
-r--r--r-- 1 root root 266 Jan 1 1970 disk1.info

The original image checksum is unchanged, but the cache1 file has grown from its initial 720K size to 328MB, reflecting the necessary changes that were required to get to this point.

The Second Image

With all of the changes we’ve made to our first image, we could simply bring the second image online with no further modifications. In fact we wouldn’t need to use xmount here at all. But what if we had a third system image that was also derived from the same base image with the same volume group name and various UUIDs? We’d have to fix our second image before we could mount the third image.

I am going to proactively repeat the above process for the second image. Not only to save myself future pain, but also to review all of the steps necessary.

First we have the xmount and loopback device setup:

# mkdir -p /mnt/host2/xmount/disk1
# xmount --cache /mnt/host2/xmount/cache1 --in raw host2/disk1.raw /mnt/host2/xmount/disk1
# losetup -fP --show /mnt/host2/xmount/disk1/disk1.dd
/dev/loop6
# file -Ls /dev/loop6p*
/dev/loop6p1: Linux rev 1.0 ext4 filesystem data, UUID=13fe4d4f-9291-4c1b-b0df-14b58d2a3e87 (extents) (64bit) (large files) (huge files)
/dev/loop6p2: LVM2 PV (Linux Logical Volume Manager), UUID: v4T9wI-LDPP-1fJG-7Siu-LrYh-dFr3-pcAEVP, size: 62423826432

Then we fix the physical volume UUID and the volume group name and UUID:

# pvchange -u /dev/loop6p2
Physical volume "/dev/loop6p2" changed
1 physical volume changed / 0 physical volumes not changed
# vgscan
Found volume group "LabVM" using metadata type lvm2
Found volume group "vg1" using metadata type lvm2
# vgrename LabVM vg2
Volume group "LabVM" successfully renamed to "vg2"
# vgchange -u vg2
Volume group "vg2" successfully changed.

Then we can bring the volumes online and change the file system UUIDs:

# vgchange -a y vg2
3 logical volume(s) in volume group "vg2" now active
# file -Ls /dev/vg2/*
/dev/vg2/home: Linux rev 1.0 ext4 filesystem data, UUID=d19118ff-f5d5-40a0-970a-d4b310934f46 (extents) (64bit) (large files) (huge files)
/dev/vg2/root: Linux rev 1.0 ext4 filesystem data, UUID=ee7fb811-d8f1-4584-8657-69e1298fe122 (extents) (64bit) (large files) (huge files)
/dev/vg2/var: Linux rev 1.0 ext4 filesystem data, UUID=df99460c-b71c-435f-8410-ef0e1ecaac91 (extents) (64bit) (large files) (huge files)
# for dev in /dev/vg2/*; do
e2fsck -f $dev
tune2fs -U random $dev
done

[...]

Finally we mount all of the file systems:

# mkdir -p /mnt/host2/files
# mount -o ro,noexec /dev/vg2/root /mnt/host2/files
# mount -o ro,noexec /dev/vg2/var /mnt/host2/files/var
# mount -o ro,noexec /dev/vg2/home /mnt/host2/files/home
# mount -o ro,noexec /dev/loop6p1 /mnt/host2/files/boot

The “Easy” Button

Does this need to be automated? Yes, of course it does!

I’ve added “-V” and “-A” options to my mtt.sh script. “-A newvg” will set up the xmount cache, change the LVM volume group name to “newvg” (choose any name you want), and change all UUIDs:

# mtt.sh -A vg1 -d /mnt/host1 host1/disk1.raw
# mtt.sh -A vg2 -d /mnt/host2 host2/disk1.raw
# ls /mnt/host*/files
/mnt/host1/files:
bin etc initrd.img lib32 lost+found opt run sys var
boot home initrd.img.old lib64 media proc sbin tmp vmlinuz
dev images lib libx32 mnt root srv usr vmlinuz.old

/mnt/host2/files:
bin etc initrd.img lib32 lost+found opt run sys var
boot home initrd.img.old lib64 media proc sbin tmp vmlinuz
dev images lib libx32 mnt root srv usr vmlinuz.old

As always, you can unmount everything with the “-U” option:

# mtt.sh -U /mnt/host1
# mtt.sh -U /mnt/host2
# ls /mnt/host*/files
/mnt/host1/files:


/mnt/host2/files:

If you only need to change the LVM volume group name without changing any UUIDs, use “-V newvg” instead of “-A“. If you want to set up a writable xmount cache for your mounted image but don’t need to make any LVM changes, just use “-W“.

Fun With volshell

When triaging a collection of memory images, I often find myself running multiple Volatility plugins on each image. Typically I do this by shell script, calling each plugin individually and saving the output in files. The problem with this approach is that Volatility has to re-parse the memory image each time my script calls a new plugin. This adds a lot of overhead and time. I started wondering if I could leverage volshell to run multiple plugins so that I wouldn’t have to pay the startup cost each time.

volshell Basics

volshell is an interactive shell environment for exploring a memory image. Explaining all the features of volshell would fill a book, so we’re just going to focus on the basics of starting up volshell and running plugins.

Starting volshell is straightforward. Specify a memory image with “-f” and the OS it comes from with “-w“, “-m“, or “-l” (Windows, MacOS, Linux, respectively). If we don’t want the progress meter as it’s ingesting the memory image, we can add “-q” (“quiet” mode).

$ volshell -f avml.lime -l -q
Volshell (Volatility 3 Framework) 2.27.1
Readline imported successfully

Call help() to see available functions

Volshell mode : Linux
Current Layer : layer_name
Current Symbol Table : symbol_table_name1
Current Kernel Name : kernel

(layer_name) >>>

As the startup text suggests, help is available at any time by running the help() function:

(layer_name) >>> help()

Methods:
...
* dpo, display_plugin_output
Displays the output for a particular plugin (with keyword arguments)
...

volshell methods generally have both long and abbreviated forms. For example, we’ll be using the display_plugin_output() method to run plugins. But rather than type that long string each time, we can just use dpo() instead.

For a simple example, let’s run the linux.ip.Addr plugin via volshell:

(layer_name) >>> from volatility3.plugins.linux import ip
(layer_name) >>> dpo(ip.Addr, kernel = self.config['kernel'])

NetNS Index Interface MAC Promiscuous IP Prefix Scope Type State

4026531840 1 lo 00:00:00:00:00:00 False 127.0.0.1 8 host UNKNOWN
4026531840 1 lo 00:00:00:00:00:00 False ::1 128 host UNKNOWN
4026531840 2 enp0s3 08:00:27:3a:05:32 False 192.168.4.22 22 global UP
4026531840 2 enp0s3 08:00:27:3a:05:32 False fdb0:fa27:86c5:1:19cf:7bad:b8a6:c5d7 64 global UP
4026531840 2 enp0s3 08:00:27:3a:05:32 False fdb0:fa27:86c5:1:a00:27ff:fe3a:532 64 global UP
4026531840 2 enp0s3 08:00:27:3a:05:32 False fe80::a00:27ff:fe3a:532 64 link UP
4026532287 1 lo 00:00:00:00:00:00 False 127.0.0.1 8 host UNKNOWN
4026532287 1 lo 00:00:00:00:00:00 False ::1 128 host UNKNOWN
4026532345 1 lo 00:00:00:00:00:00 False 127.0.0.1 8 host UNKNOWN
4026532345 1 lo 00:00:00:00:00:00 False ::1 128 host UNKNOWN
4026532403 1 lo 00:00:00:00:00:00 False 127.0.0.1 8 host UNKNOWN
4026532403 1 lo 00:00:00:00:00:00 False ::1 128 host UNKNOWN
(layer_name) >>>

First we need to import the Volatility class that contains the plugin we want to invoke. The basic syntax here is “from volatility3.plugins.<os> import <class>“. “<os>” will be “windows“, “mac“, or “linux“. Since we’re running a Linux plugin, “<class>” will be the word that appears after “linux.” in the plugin name. For example, if were trying to run “linux.elfs.Elfs“, then “<class>” is “elfs“.

We use the dpo() method to actually run the plugin and get the output. If we were invoking the plugin on the command line, we would specify “linux.ip.Addr” as the plugin name. But here in Linux volshell, we can leave off the “linux.“. After the plugin name always specify “kernel = self.config['kernel']” to satisfy the dpo() method’s syntax.

If you want to run another plugin, just repeat the pattern. Import the appropriate Volatility class and run dpo() as before:

(layer_name) >>> from volatility3.plugins.linux import pstree
(layer_name) >>> dpo(pstree.PsTree, kernel = self.config['kernel'])

OFFSET (V) PID TID PPID COMM

0x8c7cc0281980 1 1 0 systemd
* 0x8c7cc0d79980 310 310 1 systemd-journal
* 0x8c7cc8268000 357 357 1 systemd-timesyn
* 0x8c7cc81a6600 365 365 1 systemd-udevd
* 0x8c7cc67e0000 687 687 1 avahi-daemon
** 0x8c7cc9986600 718 718 687 avahi-daemon
...

What’s great about this approach is that the memory image was already parsed when volshell started up. So each plugin runs very quickly.

Changing Output Modes

In many cases, it’s better for my workflow to get the plugin output in JSON format rather than the standard text output. I’d be embarrassed to admit how long the following little recipe took me to figure out, so let’s just get to the code:

(layer_name) >>> from volatility3.cli import text_renderer
(layer_name) >>> from volatility3.plugins.linux import psaux
(layer_name) >>> treegrid = gt(psaux.PsAux, kernel = self.config['kernel'])
(layer_name) >>> treegrid.populate()
(layer_name) >>> rt(treegrid,text_renderer.JsonLinesRenderer())

{"ARGS": "/sbin/init", "COMM": "systemd", "PID": 1, "PPID": 0, "__children": []}
{"ARGS": "[kthreadd]", "COMM": "kthreadd", "PID": 2, "PPID": 0, "__children": []}
{"ARGS": "[pool_workqueue_]", "COMM": "pool_workqueue_", "PID": 3, "PPID": 2, "__children": []}
{"ARGS": "[kworker/R-kvfre]", "COMM": "kworker/R-kvfre", "PID": 4, "PPID": 2, "__children": []}
{"ARGS": "[kworker/R-rcu_g]", "COMM": "kworker/R-rcu_g", "PID": 5, "PPID": 2, "__children": []}
...

First we’re importing the text_renderer class from volatility3.cli. This class contains methods for outputting various text formats, like JsonLinesRenderer() for single-line JSON format. Other options include JsonRenderer() for “pretty-printed” JSON output, or CSVRenderer() for comma-separated values formatting.

Next we import the class for Volatility plugin we want to invoke, just as before. But rather than calling dpo(), we create a new treegrid object with generate_treegrid() (abbreviated “gt()“). The arguments to gt() are the same as those for dpo().

gt() merely creates the treegrid object. We still have to call the treegrid.populate() method to load data into the object. Once we have populated the treegrid with data, we can invoke render_treegrid() (“rt()“) to output the data with our chosen text renderer.

Stumbling Towards Automation

Clearly this approach requires a lot of redundant typing. Automating the task of running multiple plugins through volshell is clearly the next step. My ptt.sh script has an initial attempt at this. At some point, I’d like to turn this idea into a standalone script outside of ptt.sh.

jq For Forensics

jq is a tremendously useful tool for dealing with JSON data. But the documentation that exists seems to be targeted at developers parsing deeply nested JSON structures to transform them into other JSON structures. In my DFIR role, I typically deal with streams of fairly simple JSON records–usually some sort of log– that I need to transform into structured text, such as comma-separated (CSV) or tab-separated (TSV) output. I’ve spent a lot of time running through reference manuals and endless Stack Overflow postings to get to a reasonable level with jq. I wanted to share some of the things I’ve learned along the way.

Start With The Basics

At it’s simplest, jq is an excellent JSON pretty printer:

$ jq . journal.json
{
"_MACHINE_ID": "0f2f13b9dce0451591ae0dc418f6c96f",
"_RUNTIME_SCOPE": "system",
"_HOSTNAME": "vbox",
"_SOURCE_BOOTTIME_TIMESTAMP": "0",
"MESSAGE": "Linux version 6.12.74+deb13+1-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.74-2 (2026-03-08)",
"__MONOTONIC_TIMESTAMP": "6400064",
"_SOURCE_MONOTONIC_TIMESTAMP": "0",
"_BOOT_ID": "2a5a598d4f6142c7b7719eed38c1a2b9",
"SYSLOG_IDENTIFIER": "kernel",
"_TRANSPORT": "kernel",
"PRIORITY": "5",
"SYSLOG_FACILITY": "0",
"__CURSOR": "s=0a047604dca842218e0807bc796d4cb7;i=1;b=2a5a598d4f6142c7b7719eed
38c1a2b9;m=61a840;t=64dc728142e95;x=852824913ddff90e",
"__REALTIME_TIMESTAMP": "1774367626505877"
}
{
"_MACHINE_ID": "0f2f13b9dce0451591ae0dc418f6c96f",
"MESSAGE": "Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.74+deb13+1-amd64 root=UUID=d6cf7c18-1df5-4f29-a6f8-d5c4947c1df7 ro quiet",

...

The basic syntax here is “jq <script> <jsonfile> ...“, where <script> is some sort of translation script in jq‘s own particular scripting language. The script “.” is essentially a null transformation that simply tells jq to output whatever it sees in its input <jsonfile>. The default output style for jq is the pretty-printed style you see above.

Some of you will recognize the data above as Systemd journal entries. Normally we would work with the Systemd journal via the journalctl command. But exported journal data from one of my lab systems is a good example set for showing you some useful jq tips and tricks that you can apply to any sort of exported logging stream.

Other Output Modes

Suppose we just wanted to output the “MESSAGE” field from each record. Just specify the field you want to output with a leading “.“:

$ jq .MESSAGE journal.json
"Linux version 6.12.74+deb13+1-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.74-2 (2026-03-08)"
"Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.74+deb13+1-amd64 root=UUID=d6cf7c18-1df5-4f29-a6f8-d5c4947c1df7 ro quiet"
...

Because the value of the MESSAGE field is a string, jq outputs each message surrounded by double quotes. If you don’t want the quoting, use the “-r” option for raw mode output:

$ jq -r .MESSAGE journal.json
Linux version 6.12.74+deb13+1-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.74-2 (2026-03-08)
Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.74+deb13+1-amd64 root=UUID=d6cf7c18-1df5-4f29-a6f8-d5c4947c1df7 ro quiet
...

Suppose we wanted to output multiple fields as columns of structured text. jq includes support for both “@csv” and “@tsv” output modes:

$ jq -r '[.__REALTIME_TIMESTAMP, ._HOSTNAME, .MESSAGE] | @csv' journal.json
"1774367626505877","vbox","Linux version 6.12.74+deb13+1-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.74-2 (2026-03-08)"
"1774367626505925","vbox","Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.74+deb13+1-amd64 root=UUID=d6cf7c18-1df5-4f29-a6f8-d5c4947c1df7 ro quiet"
...

jq transformation scripts use a pipelining syntax. Here we’re sending the fields we want to output into the “@csv” formatting tool. “@csv” wants its inputs as a JSON array, so we create an array on the fly simply by enclosing the fields we want to output with square brackets (“[..., ..., ...]“). The “@csv” output method automatically quotes each field and handles escaping any double quotes that might be included.

If you want other delimiters besides the traditional commas or tabs, jq can also output arbitrary text:

$ jq -r '"\(.__REALTIME_TIMESTAMP)|\(._HOSTNAME)|\(.MESSAGE)"' journal.json
1774367626505877|vbox|Linux version 6.12.74+deb13+1-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.74-2 (2026-03-08)
1774367626505925|vbox|Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.74+deb13+1-amd64 root=UUID=d6cf7c18-1df5-4f29-a6f8-d5c4947c1df7 ro quiet
...

Use double quotes ("...") to enclose your output template. Use “\(.fieldname)” to output the value of specific fields. Anything else in your template is output as literal text. Here I’m outputting pipe-delimited text with the same three fields as in our CSV example above.

Note that our output template can use the typical escape sequences like “\t” for tabs. So another way to produce tab-delimited text would be:

$ jq -r '"\(.__REALTIME_TIMESTAMP)\t\(._HOSTNAME)\t\(.MESSAGE)"' journal.json
1774367626505877 vbox Linux version 6.12.74+deb13+1-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.74-2 (2026-03-08)
1774367626505925 vbox Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.74+deb13+1-amd64 root=UUID=d6cf7c18-1df5-4f29-a6f8-d5c4947c1df7 ro quiet
...

However, it’s almost certainly easier to use '[..., ..., ...] | @tsv' for this.

Transforming Data With Builtin Operators

jq includes a wide variety of builtin operators for data transformation and math. For example, suppose we wanted to format those __REALTIME_TIMESTAMP fields in the Systemd journal into human-readable strings:

$ head -1 journal.json | jq -r '(.__REALTIME_TIMESTAMP | tonumber) / 1000000 | strftime("%F %T")'
2026-03-24 15:53:46

There’s a lot going on here, so let’s break it down a bit at a time. __REALTIME_TIMESTAMP is a string– if you look at the pretty-printed output above, the values are displayed in double quotes meaning they are string type values. Ultimately we want to feed the __REALTIME_TIMESTAMP value into strftime() to produce formatted text, but strftime() wants numeric input. The first thing to do then is to convert the string into a number with “tonumber“. The jq piping syntax is how we express this transformation.

Our next problem is that __REALTIME_TIMESTAMP is in microseconds, but strftime() wants good old Unix epoch seconds. So we do some math with the traditional “/” operator for division. This actually converts our value into a decimal number (“1774367626.505877“), but that’s good enough for strftime(). Finally we pipeline the number we calculated into the strftime() function. We give strftime() an appropriate format string to get the output we want.

This works great, but we’re throwing away the microseconds information. What if we wanted to display that as part of the timestamp? Time to introduce some more useful string operations:

$ head -1 journal.json | jq -r '((.__REALTIME_TIMESTAMP | tonumber) / 1000000 | strftime("%F %T.")) + 
(.__REALTIME_TIMESTAMP | .[-6:])'

2026-03-24 15:53:46.505877

Looking at the back part of our expression on the second line above, we are using jq‘s slicing operation “.[start:end]“. Since we are using a negative offset for the start value, we are counting backwards from the end of the string six characters. With no end value specified, it outputs the rest of the string from that point.

Like many other scripting languages, jq supports string concatenation with the addition operator (“+“). Here we are adding the formatted string output from strftime() and the microseconds value we sliced out of the string. Note the the strftime() format has been updated to output a literal “.” between the formatted text and the microseconds.

Suppose we wanted to include the human-readable timestamp we just created instead of the raw epoch microseconds for our “@csv” output. The trick is to take our jq code for producing human readable timestamps and drop it into our “[...] | @csv” pipeline in place of the __REALTIME_TIMESTAMP field:

$ jq -r '[((.__REALTIME_TIMESTAMP | tonumber) / 1000000 | strftime("%F %T.")) + (.__REALTIME_TIMESTAMP | .[-6:]), ._HOSTNAME, .MESSAGE] | @csv' journal.json
"2026-03-24 15:53:46.505877","vbox","Linux version 6.12.74+deb13+1-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.74-2 (2026-03-08)"
"2026-03-24 15:53:46.505925","vbox","Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.74+deb13+1-amd64 root=UUID=d6cf7c18-1df5-4f29-a6f8-d5c4947c1df7 ro quiet"
...

Scripting With jq

Obviously that jq expression is pretty horrible to type on the command line. You can always take any jq script and put it into a text file and then run that script on your data with the “-f” option:

$ jq -r -f csv-journal.jq journal.json
"2026-03-24 15:53:46.505877","vbox","Linux version 6.12.74+deb13+1-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.74-2 (2026-03-08)"
"2026-03-24 15:53:46.505925","vbox","Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.74+deb13+1-amd64 root=UUID=d6cf7c18-1df5-4f29-a6f8-d5c4947c1df7 ro quiet"
...

In this instance, our csv-journal.jq file is the jq recipe from our command line example, but without the single quotes. Since jq doesn’t care about whitespace in scripts, we can format our recipe with newlines and indentation to make it more readable:

$ cat csv-journal.jq 
[((.__REALTIME_TIMESTAMP | tonumber) / 1000000 | strftime("%F %T.")) +
(.__REALTIME_TIMESTAMP | .[-6:]),
._HOSTNAME, .MESSAGE] | @csv

On Linux systems you can even use jq in a “bang path” at the top of the script so it automatically gets invoked as the interpreter:

$ cat csv-journal.jq
#!/usr/bin/jq -rf

[((.__REALTIME_TIMESTAMP | tonumber) / 1000000 | strftime("%F %T.")) +
(.__REALTIME_TIMESTAMP | .[-6:]),
._HOSTNAME, .MESSAGE] | @csv

Note that the new interpreter path at the top of the script includes the “-rf” options for raw output (“-r“) and interpreting the rest of the file as a script (“-f“).

Once we have the interpreter path at the top of the script, we can just cat our JSON data into the script without invoking jq directly:

$ chmod +x csv-journal.jq 
$ cat journal.json | ./csv-journal.jq
"2026-03-24 15:53:46.505877","vbox","Linux version 6.12.74+deb13+1-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.74-2 (2026-03-08)"
"2026-03-24 15:53:46.505925","vbox","Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.74+deb13+1-amd64 root=UUID=d6cf7c18-1df5-4f29-a6f8-d5c4947c1df7 ro quiet"
...

This might make things easier for less-technical users.

Selecting Records

When working with streams of records, it’s typical to want to only operate on certain records. For example, suppose we only wanted to see log messages from the “sudo” command. In the Systemd journal, these messages have the “SYSLOG_IDENTIFIER” field set to “sudo“:

$ jq -r 'select(.SYSLOG_IDENTIFIER == "sudo") | .MESSAGE' journal.json
worker : user NOT in sudoers ; TTY=pts/0 ; PWD=/home/worker ; USER=root ; COMMAND=/bin/bash
worker : TTY=pts/2 ; PWD=/home/worker ; USER=root ; COMMAND=/bin/bash
pam_unix(sudo:session): session opened for user root(uid=0) by worker(uid=1000)
pam_unix(sudo:session): session closed for user root
worker : TTY=pts/0 ; PWD=/home/worker ; USER=root ; COMMAND=/bin/bash
pam_unix(sudo:session): session opened for user root(uid=0) by worker(uid=1000)
pam_unix(sudo:session): session closed for user root
worker : TTY=pts/1 ; PWD=/home/worker ; USER=root ; COMMAND=/bin/bash
pam_unix(sudo:session): session opened for user root(uid=0) by worker(uid=1000)
worker : TTY=pts/3 ; PWD=/home/worker ; USER=root ; COMMAND=/bin/bash
...

The new magic is jq‘s select() operator up at the front of that pipeline. If the conditional you give to select() evaluates to true, then the record you have matched gets passed down for processing by the rest of the pipeline. If not, then that record is skipped.

Logical operators (“and“, “or“, “not“) and parentheses are allowed. And you can do pattern matching with PCRE-like expressions. For example, the really interesting lines in Sudo logs are the ones that show the command being invoked (“COMMAND=“):

$ jq -r 'select(.SYSLOG_IDENTIFIER == "sudo" and (.MESSAGE | test("COMMAND="))) | .MESSAGE' journal.json
worker : user NOT in sudoers ; TTY=pts/0 ; PWD=/home/worker ; USER=root ; COMMAND=/bin/bash
worker : TTY=pts/2 ; PWD=/home/worker ; USER=root ; COMMAND=/bin/bash
worker : TTY=pts/0 ; PWD=/home/worker ; USER=root ; COMMAND=/bin/bash
worker : TTY=pts/1 ; PWD=/home/worker ; USER=root ; COMMAND=/bin/bash
worker : TTY=pts/3 ; PWD=/home/worker ; USER=root ; COMMAND=/bin/bash
...

For pattern matching, just pipeline the field you want to match against into the test() operator. Here I’m matching the literal string “COMMAND=” against the MESSAGE field. The pattern match is joined with our original selector for “sudo” in the SYSLOG_IDENTIFIER field using a logical “and“.

Here’s another example showing a useful regex when dealing with SSH logs, just to give you a flavor of things you can do with regular expression matching:

$ jq -r 'select(._COMM == "sshd" and 
(.MESSAGE | test("^((Accepted|Failed) .* for|Invalid user) "))) | .MESSAGE' journal.json

Invalid user mary from 192.168.10.31 port 55746
Failed password for invalid user mary from 192.168.10.31 port 55746 ssh2
Accepted password for hal from 192.168.4.22 port 42310 ssh2
...

Enough For Now

Hopefully this is enough to get you started writing your own basic jq scripts. As with many things, the rest you pick up as you practice and get frustrated. The jq reference manual is useful for checking the syntax of different built-in operators, but I often find the examples more frustrating than helpful. Searching Stack Overflow can often yield more useful results.

Feel free to drop your questions into the comments, or reach out to me via social media or email. Maybe your questions will turn this single blog article into a series!

Linux Notes: ls and Timestamps

There’s an old riddle in Unix circles: “Name a letter that is not an option for the ls command”. The advent of the GNU version of ls has only made this more difficult to answer. Even if you’re a Unix/Linux power user, you’ve probably only memorized a small handful of the available options.

For example, I have “ls -lArt” burned into my brain from my Sys Admin days. “-l” for detailed listing, “-A” to show hidden files and directories (but not the “.” and “..” links like “-a“), sort by last modified time with “-t“, and “-r” to reverse the sort so the newest files appear right above your next shell prompt.

$ ls -lArt
total 1288
-rw-r--r-- 1 root root 9 Aug 7 2006 host.conf
-rw-r--r-- 1 root root 433 Aug 23 2020 apg.conf
-rw-r--r-- 1 root root 26 Dec 20 2020 libao.conf
-rw-r--r-- 1 root root 12813 Mar 27 2021 services
-rw-r--r-- 1 root root 769 Apr 10 2021 profile
-rw-r--r-- 1 root root 449 Nov 29 2021 mailcap.order
-rw-r--r-- 1 root root 119 Jan 10 2022 catdocrc
...
-rw-r--r-- 1 root root 52536 Feb 23 11:44 mailcap
-rw-r--r-- 1 root root 108979 Mar 2 09:24 ld.so.cache
-rw-r--r-- 1 root root 75 Mar 3 18:08 resolv.conf
drwxr-xr-x 5 root lp 4096 Mar 5 19:52 cups

You’ll note that the timestamps are displayed in two different formats. The oldest files show “month day year”, while the newer files show “month day hh:mm”. The default for ls is that files more than six months old display year information.

Personally I prefer consistent ISO-style timestamps with “--time-style=long-iso“:

$ ls -lArt --time-style=long-iso
total 1288
-rw-r--r-- 1 root root 9 2006-08-07 13:14 host.conf
-rw-r--r-- 1 root root 433 2020-08-23 10:52 apg.conf
-rw-r--r-- 1 root root 26 2020-12-20 11:21 libao.conf
-rw-r--r-- 1 root root 12813 2021-03-27 18:32 services
-rw-r--r-- 1 root root 769 2021-04-10 16:00 profile
-rw-r--r-- 1 root root 449 2021-11-29 08:07 mailcap.order
-rw-r--r-- 1 root root 119 2022-01-10 19:08 catdocrc
...
-rw-r--r-- 1 root root 52536 2026-02-23 11:44 mailcap
-rw-r--r-- 1 root root 108979 2026-03-02 09:24 ld.so.cache
-rw-r--r-- 1 root root 75 2026-03-03 18:08 resolv.conf
drwxr-xr-x 5 root lp 4096 2026-03-05 19:52 cups

While “-t” sorts on last modified time by default, other options allow you to sort and display other timestamps. For example, “-u” sorts on and displays last access time. “-u” is hardly memorable as last access time, but remember “-a” is used for something else.

It’s a pain trying to remember the one letter options for the other timestamps– and note there isn’t even a short option for sorting/displaying on file creation time. So I just use “--time=” to pick the timestamp I want:

$ ls -lArt --time=birth --time-style=long-iso
total 1288
-rw-r--r-- 1 root root 1013 2025-04-10 10:27 fstab
drwxr-xr-x 2 root root 4096 2025-04-10 10:27 ImageMagick-6
drwxr-xr-x 2 root root 4096 2025-04-10 10:27 GNUstep
...
-rw-r--r-- 1 root root 142 2026-02-23 11:41 shells
-rw-r--r-- 1 root root 52536 2026-02-23 11:44 mailcap
-rw-r--r-- 1 root root 108979 2026-03-02 09:24 ld.so.cache
-rw-r--r-- 1 root root 75 2026-03-03 18:08 resolv.conf

Here we’re sorting on and displaying file creation times (“--time=birth“). You can use “--time=atime” or “--time=ctime” for the other timestamps.

If this command line seems long and unwieldy, remember that you can create aliases for commands in your .bashrc or other startup files:

alias ls='ls --color=auto --time-style=long-iso'
alias lb='ls -lArt --time=birth'

With normal ls commands, I’ll get colored output always, and “long-iso” dates whenever I use “-l“. I can use lb whenever I want file creation times. Note that alias definitions “stack”– the “lb” alias will get the color and time-style options from my basic “ls” alias, so I don’t need to include the “--time-style” option in the “lb” alias.