Local privilege escalation in qemu-guest-agent (guest-ssh-add-authorized-keys, symlink-following chown)
Imported by the "Security Issuer Importer" bot, on behalf of the original reporter who disclosed via qemu-security list:
- From:
Valentino Paulon <valentino.paulon88@gmail.com> - Date:
07 Jun, 2026 - Message ID:
<CAF2ANE2v06kjux51ymzyvKT3ayq+8904LBZXcF6tV5dEhnUj7Q@mail.gmail.com>
NOTE: the original reporter can not be copied on this issue so cannot view this ticket while it remains marked confidential. If further information is needed about the disclosure, contact the reporter directly.
I am reporting a local privilege escalation in qemu-guest-agent that I have reproduced end to end on the shipped qga 6.2.0. I am happy to provide the exact commands, a standalone proof of concept, and the raw reproduction output on request.
Title:
Local privilege escalation in qemu-guest-agent via symlink-following chown (and directory-symlink bypass) in guest-ssh-add-authorized-keys
Component:
qemu-guest-agent (qga), qga/commands-posix-ssh.c. Confirmed against the distro-shipped qga 6.2.0 and against current master. The line numbers below reference current master; the identical code is present in 6.2.0 at line 68 (directory chown), line 127 (file chown), and line 187 (the g_file_test IS_DIR test).
Vulnerability type:
Elevation of Privilege (local). CWE-59 (link following) / CWE-61 (symlink following) with CWE-282 (improper ownership management) via chown.
Severity (suggested):
Important, conditional - local unprivileged user to root, gated by an operator/host-initiated guest-ssh-add-authorized-keys for the victim's existing account. I defer the final rating to you.
Summary:
When qmp_guest_ssh_add_authorized_keys adds an SSH key for an existing local user, the agent (running as root) decides whether to create the user's .ssh directory with a symlink-following directory test, and then writes and chowns the authorized_keys file using a plain chown (not lchown). A local unprivileged user who owns their home directory can pre-stage their .ssh directory (or the authorized_keys file) as a symbolic link so that, when the host or operator triggers a key add for that user, the root agent follows the link and transfers ownership of an arbitrary root-owned file or directory to the unprivileged user, who can then rewrite it to obtain root. I reproduced both variants end to end on the shipped qga 6.2.0.
Affected code and root cause:
In qga/commands-posix-ssh.c, qmp_guest_ssh_add_authorized_keys builds the per-user path and gates the .ssh creation on a symlink-following test (line 138 ssh_path, line 139 authkeys_path, line 145 the g_file_test IS_DIR check, line 162 write_authkeys). g_file_test with G_FILE_TEST_IS_DIR follows symbolic links (it is stat-based; per GLib, all tests except IS_SYMLINK follow links). So if the user's .ssh is a symlink to an existing root-owned directory, the IS_DIR test returns true, mkdir_for_user is skipped, and write_authkeys proceeds with the path resolving through the symlink into that root-owned directory.
write_authkeys writes via g_file_set_contents (line 94) and then changes ownership with a plain chown (line 99). g_file_set_contents with default flags writes to a temporary file and renames it into place, so the write itself replaces a pre-planted symlink rather than following it. However, the subsequent chown on line 99 is a plain chown - not lchown, not fchownat with AT_SYMLINK_NOFOLLOW, not fchown on the written file descriptor - so it follows a symlink present at the path at the moment it runs. mkdir_for_user has the same unguarded chown on the directory (line 69).
The agent performs no privilege drop: there is no seteuid, setuid, setresuid or setfsuid anywhere in commands-posix-ssh.c or commands-common-ssh.c, so every file operation runs with root effective uid. The username is resolved with getpwnam and the path is built from pw_dir; the local user fully controls the contents of their own .ssh directory.
This yields two independent primitives on the same code path: a deterministic directory-symlink bypass (no race) via the symlink-following IS_DIR test, and a TOCTOU between the atomic write (rename) and the chown on line 99 for an arbitrary file.
Reproduction (confirmed end to end on qga 6.2.0):
I ran the shipped qemu-ga as root on a local unix socket and sent the guest-ssh-add-authorized-keys command over the socket; this exercises the exact code path a host triggers over the QMP/virtio-serial channel. The command was not blocked by the default qemu-guest-agent configuration and returned a success response. "victim" is an ordinary unprivileged local user.
Variant A - deterministic directory-symlink (no race):
- As victim, point the .ssh directory at a root-owned directory via a symlink (a root-owned directory of mode 0700).
- The host invokes guest-ssh-add-authorized-keys for victim.
- Result: an authorized_keys file is created inside that root-owned directory by root and then chowned to victim (observed owner victim, mode 0600). An unprivileged user obtained a file it owns inside a root-only directory it cannot otherwise read.
Variant B - TOCTOU file-symlink (arbitrary file):
- As victim, with the .ssh directory being an ordinary directory, run a tight loop re-creating the authorized_keys entry as a symlink to a root-owned target.
- The host invokes guest-ssh-add-authorized-keys for victim (repeated as on retries or normal operation).
- Result: the rename-to-chown window is wide; in my test the race was won within two triggers, and the root-owned target (a canary file used to avoid breaking the test host) became owned by victim. With a sensitive system file as the target, the user obtains root.
The decisive primitive is the chown: it transfers ownership of whatever the path resolves to, independent of the written content. A range of root-owned files and directories are viable targets.
Impact and honest preconditions:
- Elevation of privilege from an unprivileged local user to root on a shipping qemu-guest-agent.
- Conditional on a host or operator-initiated guest-ssh-add-authorized-keys for the victim's existing account. The attacker is the local user, who stages the symlink; the local user cannot invoke the command themselves - it arrives from the host over the QMP/virtio-serial channel. This is a passive-trigger local EoP, not a self-triggered or remote escalation, and I am stating the precondition explicitly rather than rating it as unauthenticated.
- The exposure is realistic wherever a management layer pushes SSH keys into guests via qga, for example libvirt's virDomainAuthorizedSSHKeysSet, OpenStack Nova key handling, oVirt/RHV, and some Proxmox/libvirt key-injection flows. I have confirmed the command is reachable by default in the agent; I cannot enumerate every deployment that exercises it, and I am not claiming it is universally triggered.
Class reference (not claimed as a novel root cause):
This is the same class as Froxlor CVE-2026-41236 (privilege escalation via a symlinked authorized_keys path) and the same root-privileged-agent writes-authorized-keys-following-symlinks pattern recently addressed in other guest and cloud agents. CVE-2023-0664 in qemu-guest-agent is a separate Windows installer issue and does not cover this POSIX SSH path; I found no CVE, advisory, or public proof of concept for this specific guest-ssh-add-authorized-keys symlink and chown path.
Suggested remediation:
- Replace chown with lchown (or fchown on the file descriptor used for the write) at write_authkeys line 99 and mkdir_for_user line 69, so ownership changes never follow a symlink.
- Replace the g_file_test IS_DIR check with an lstat-based check that rejects a symlinked .ssh, or open the directory with O_NOFOLLOW and O_DIRECTORY and operate on the file descriptor.
- Create or open the key file with O_NOFOLLOW (and O_EXCL on creation), and operate via the resulting file descriptor (fchown, fchmod) to eliminate the rename-to-chown TOCTOU.
- Alternatively, drop the effective uid and gid to the target user before any file operation in the user's home, so root never touches an attacker-controlled path.
I can provide the exact commands, the standalone primitive, and the raw reproduction output on request. I am happy to coordinate disclosure and a CVE request on your timeline.