Skip to content

fix(libvirt): reject shell injection via guest init-script interface fields - #1085

Closed
doublewhy wants to merge 6 commits into
devfrom
fix-libvirt-shell-injection
Closed

fix(libvirt): reject shell injection via guest init-script interface fields#1085
doublewhy wants to merge 6 commits into
devfrom
fix-libvirt-shell-injection

Conversation

@doublewhy

@doublewhy doublewhy commented Aug 12, 2026

Copy link
Copy Markdown

Related issues

Closes #1116


What breaks

A hostile value in a scenario definition could run arbitrary commands as root inside the guest VM. The guest's startup script was assembled as shell text, and three fields from the scenario — a network interface's MAC address, IP address, and CIDR prefix — were pasted into it without quoting or validation.

Concretely

Give a node an interface whose mac is:

aa:bb:cc:dd:ee:ff) ; rm -rf /outside #

The generated script closes the shell case arm early and runs whatever follows. ip and cidr_prefix were injectable the same way with $(...) or backticks. The generated line was:

    aa:bb:cc:dd:ee:ff) ; rm -rf /outside #)
      ip addr add 192.0.2.10/24 dev "$iface"

The same flaw was present in two files: techvault_appliance.py and guest_appliance.py.

Why it happens

Both _init_script functions built the interface arms with f-strings:

f"    {interface.get('mac')})",
f'      ip addr add {interface.get("ip")}/{interface.get("cidr_prefix")} dev "$iface"',

Only the hostname went through the module's existing _shell_quote. This is also inconsistent with the rest of the backend, which uses argv lists rather than shell text (cloudinit.py, drivers/oci.py).

The fix

Both files now share one helper that validates each field to its declared shape and refuses malformed input, then quotes it as a second layer:

  • mac must match a MAC address pattern
  • ip must parse via ipaddress.ip_address
  • cidr_prefix must be an integer within range for that address family

A value that is not the shape it claims to be is a bug in the plan, not text to escape, so it raises ValueError — matching how techvault_matrix.py, which produces these values, already reports problems.

Two related items:

  • _cpio_newc piped newline-joined file paths into cpio with check=False, so a filename containing a newline silently corrupted the initramfs archive. Such paths are now refused.
  • str.isdigit accepts characters like ² that int then rejects, which leaked a raw interpreter message instead of the module's own. isdecimal matches what int actually accepts.

How I know

7 tests fail against the current techvault_appliance and pass after; 6 of the guest_appliance cases likewise.

guest_appliance.py had no coverage on this path on any machine without a static BusyBox and cpio installed, because its only test module needs them. Its script generator is now exercised directly, so the second injection point stays covered wherever the suite runs.

I also confirmed the quoting holds in a real shell. An IPv6 zone-ID payload that ipaddress happens to accept is printed literally rather than executed:

$ printf '%s\n' 'fe80::1%$(id)/64'
fe80::1%$(id)/64

Full nox -s verify green on Ubuntu 22.04 / Python 3.12, all six lanes, 91% total coverage.

🤖 Generated with Claude Code

Yernat Yestekov and others added 6 commits August 11, 2026 17:25
The generated guest init script (run as root inside the appliance) interpolated interface `mac`, `ip`, and `cidr_prefix` into shell text unquoted. A malformed or hostile value such as a mac of `aa:bb:cc:dd:ee:ff) ; rm -rf /outside #` or an ip containing `$(...)` injected arbitrary commands into a root-run script; only the hostname was quoted.

Validate each interpolated field to its structural shape (MAC address, IPv4/IPv6 address, integer CIDR prefix) and reject malformed input with a typed ValueError, then quote every value with the existing _shell_quote as defense in depth. Both the native and guest-certified init scripts now share one validated helper, closing the identical injection in guest_appliance.py.

Also harden _cpio_newc: a member path containing a newline silently corrupted the newline-delimited cpio archive (subprocess ran with check=False), so reject newline-bearing paths before invoking cpio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`guest_appliance._init_script` builds the same root-run script as the
TechVault appliance and was routed through the shared validated helper,
but its only coverage lives in `test_libvirt_backend_guest_certified.py`,
which needs a static BusyBox and `cpio` and so does not run on hosts
without them. The generator is now exercised directly, so the second
injection site is covered wherever the suite runs. Six of these cases
fail against the pre-fix module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`str.isdigit` also accepts characters such as "²" that `int` then
refuses, so a prefix like that escaped `_validated_cidr_prefix` as a bare
interpreter message instead of the module's own. `isdecimal` matches what
`int` will actually accept.

Also covers the remaining validation branches on this boundary: a
non-string ip, a bool and a float prefix, a decimal-string prefix, and a
malformed interface entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A macOS directory-metadata file was picked up by a broad `git add`. The
repository .gitignore does not list .DS_Store, so it entered the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two init-script test modules assert the same guarantees against two
entry points, and the second was added by copying the first, leaving
identical helper and test bodies in both. SonarCloud flagged one new issue
per file for the duplication.

The valid interface, the hostile-field case table, and the expected
quoted output now live in `libvirt_interface_fixtures.py`, matching the
existing `*_fixtures.py` helpers. Each module keeps only what is specific
to its own generator, and a new hostile case added in one place now covers
both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both parametrized rejection tests wrapped two calls in one
`pytest.raises` block -- the fixture builder and the generator under test --
so nothing stated which was expected to raise, and a builder that started
raising would satisfy the assertion. SonarCloud reported this as one new
issue per file (python:S5779).

The domain is now built before the block, leaving the generator as the only
call inside it. The 20 hostile-input cases still fail against the pre-fix
modules and pass after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Brad-Edwards

Copy link
Copy Markdown
Collaborator

Please note in the comments or body which issue this PR closes. If no issue exists, please create one and link it.

Thank you!

@doublewhy

Copy link
Copy Markdown
Author

Linked the focused guest-interface validation issue in the PR body: this PR closes #1116. Thank you.

@doublewhy

Copy link
Copy Markdown
Author

Superseded by #1128, which is rebuilt on the current dev branch and closes #1094, #1095, #1105, and #1116. The successor includes the init-script validation fix without the stray .DS_Store, and adds the related reproducible-artifact and fresh-evidence corrections with current verification evidence.

@doublewhy doublewhy closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants