View-only instance grant (is_change=False) can still power off, reset root password, inject SSH key, mount ISO and detach disks
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 52/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Quiet
- Tech stack
- django, python
- Domain
- authorization, backend, security
Research direction
Start in instances/views.py with get_instance() and compare the listed power, password, SSH key, disk, and ISO views against resizevm_cpu and the other views that enforce grant flags. Verify that view-only requests cannot reach the relevant proxy actions, while ordinary instance viewing and existing privileged access remain available. Also check add_public_key's UserSSHKey lookup against the ownership concern described in the issue.
Written by the indexing model from the issue text.
Description
Summary
WebVirtCloud lets an administrator grant a user access to a specific instance with three independent flags on the UserInstance grant: is_vnc (may open the console), is_change (may modify the VM) and is_delete (may delete the VM). A grant with all flags False is meant to be read-only: the user can see the instance but must not be able to change or control it.
Most state-changing instance views do not check these flags. They only verify, via get_instance(), that the user holds some grant for the instance. As a result a user who was given a view-only grant (is_change=False, is_delete=False, is_vnc=False) can still power the VM on and off, reset the guest root password, install an SSH public key into the guest, mount and unmount ISO images, and detach the VM's disks. A small set of sibling views (resizevm_cpu, resize_memory, resize_disk, change_options, update_console) correctly enforce is_change/is_vnc, which shows the flags are intended to be enforced everywhere.
Details
The grant model (accounts/models.py):
class UserInstance(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
instance = models.ForeignKey(Instance, on_delete=models.CASCADE)
is_change = models.BooleanField(default=False)
is_delete = models.BooleanField(default=False)
is_vnc = models.BooleanField(default=False)
The shared gate (instances/views.py, lines 316-330):
def get_instance(user, pk):
"""
Check that instance is available for user, if not raise 404
"""
instance = get_object_or_404(Instance, pk=pk)
user_instances = user.userinstance_set.all().values_list("instance", flat=True)
if (
user.is_superuser
or user.has_perm("instances.view_instances")
or instance.id in user_instances
):
return instance
else:
raise Http404()
get_instance() only answers "does this user hold a grant for this instance?" It does not look at is_change, is_delete or is_vnc. Any view that authorizes with get_instance() alone treats every grant as full control.
Views that correctly enforce the flag (the intended pattern), e.g. resizevm_cpu (lines 538-573):
def resizevm_cpu(request, pk):
instance = get_instance(request.user, pk)
try:
userinstance = instance.userinstance_set.get(user=request.user)
except Exception:
userinstance = UserInstance(is_change=False)
...
if request.method == "POST":
if request.user.is_superuser or request.user.is_staff or userinstance.is_change:
...
instance.proxy.resize_cpu(cur_vcpu, vcpu)
The same is_change check appears in resize_memory (line 587), resize_disk (line 636) and change_options (line 1541), and update_console (line 1479) checks is_vnc.
Views that DO NOT enforce the flag (the bug). They call get_instance() and then act:
poweroff (lines 356-363), also poweron, powercycle, force_off:
def poweroff(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.shutdown()
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Power Off"))
return redirect(request.META.get("HTTP_REFERER"))
set_root_pass (lines 473-500) resets the guest root password:
def set_root_pass(request, pk):
instance = get_instance(request.user, pk)
if request.method == "POST":
passwd = request.POST.get("passwd", None)
if passwd:
passwd_hash = crypt_r.crypt(passwd, "$6$kgPoiREy")
data = {"action": "password", "passwd": passwd_hash, "vname": instance.name}
if instance.proxy.get_status() == 5:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((instance.compute.hostname, 16510))
s.send(bytes(json.dumps(data).encode()))
...
add_public_key (lines 503-535) installs an SSH public key into the guest. Note it also fetches the key with no owner filter:
def add_public_key(request, pk):
instance = get_instance(request.user, pk)
if request.method == "POST":
sshkeyid = request.POST.get("sshkeyid", "")
publickey = UserSSHKey.objects.get(id=sshkeyid) # any user's key id
data = {"action": "publickey", "key": publickey.keypublic, "vname": instance.name}
if instance.proxy.get_status() == 5:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((instance.compute.hostname, 16510))
...
detach_vol (lines 887-900) and mount_iso/unmount_iso (lines 955-967): gated only by a template check, not by is_change:
def detach_vol(request, pk):
instance = get_instance(request.user, pk)
allow_admin_or_not_template = (
request.user.is_superuser or request.user.is_staff or not instance.is_template
)
if allow_admin_or_not_template:
dev = request.POST.get("dev", "")
instance.proxy.detach_disk(dev)
So for a normal (non-staff) user with a view-only grant on a normal (non-template) instance, allow_admin_or_not_template is True and the disk is detached.
The instance ids are sequential integers exposed in the instance list (/instances/) and in every URL, so a user always knows the pk of the instances they were granted; the attack uses an instance the user WAS granted (view-only), so no guessing is needed.
PoC
Set up: as admin, create a user and grant them access to one instance with all flags unchecked (Instances -> instance -> Owners, add the user; in the admin the UserInstance row has is_change=False, is_delete=False, is_vnc=False). Log in as that user.
The following requests all succeed despite the view-only grant. Replace ID with the granted instance's pk and reuse the session cookie + CSRF token from a normal page load.
Power off the VM (no is_change needed):
POST /instances/ID/poweroff/ HTTP/1.1
Cookie: sessionid=...; csrftoken=...
Content-Type: application/x-www-form-urlencoded
csrfmiddlewaretoken=...
Reset the guest root password:
POST /instances/ID/rootpasswd/ HTTP/1.1
Cookie: sessionid=...; csrftoken=...
Content-Type: application/x-www-form-urlencoded
csrfmiddlewaretoken=...&passwd=AttackerPass123!
Detach a disk:
POST /instances/ID/detach_vol/ HTTP/1.1
Cookie: sessionid=...; csrftoken=...
Content-Type: application/x-www-form-urlencoded
csrfmiddlewaretoken=...&dev=vdb&path=/var/lib/libvirt/images/disk.qcow2
Observed result from the real view code (recording mock on Instance.proxy, libvirt stubbed only at the hypervisor boundary; the authorization layer is unmodified):
SEED: inst_a.pk=7 (vouser view-only grant) inst_b.pk=8 (NO grant)
LOGIN: vouser logged in
=== A) view-only user (is_change=False) acting on GRANTED instance A ===
[poweroff A]
http=302 hypervisor_calls=['shutdown']
[set_root_pass A]
http=EXC:ConnectionRefusedError:[Errno 111] Connection refused hypervisor_calls=[]
[add_public_key A (victim key)]
http=EXC:ConnectionRefusedError:[Errno 111] Connection refused hypervisor_calls=[]
[detach_vol A]
http=302 hypervisor_calls=['detach_disk']
[mount_iso A]
http=302 hypervisor_calls=['mount_iso']
=== B) SAME view-only user, SIBLING resize views (is_change enforced) ===
[resizevm_cpu A (is_change gate)]
http=302 hypervisor_calls=[]
[resize_memory A (is_change gate)]
http=302 hypervisor_calls=[]
=== C) cross-user IDOR control: same user on NON-GRANTED instance B ===
[poweroff B (no grant -> should 404)]
http=404 hypervisor_calls=[]
Reading the output:
- poweroff/detach_vol/mount_iso reached the real hypervisor calls (shutdown, detach_disk, mount_iso) for a view-only user.
- set_root_pass and add_public_key passed authorization and ran the code that opens a socket to host:16510; the ConnectionRefusedError is only because the test box has no guest-agent listener. On a real deployment these reset the root password and inject the SSH key.
- The gated siblings resizevm_cpu/resize_memory made no hypervisor call for the same user (is_change correctly enforced).
- poweroff on a non-granted instance returned 404 (cross-user access correctly blocked).
Impact
A user who was deliberately given read-only access to a VM can fully control it: power it off (denial of service), reset the guest root password and inject an SSH key (full guest takeover after the next boot), mount or unmount ISO media, and detach the VM's disks (data loss). The protection the administrator selected when choosing a view-only grant is silently ignored for these actions, while the resize/console actions correctly honor it. Additionally, add_public_key looks up the SSH key by id with no ownership filter, so any stored public key can be selected for injection.
Affected Versions: all current versions, confirmed on commit 1b2da68b2800f94674dd96f4a986cde30ac88280
- Dominant language
- JavaScript
- Stars
- 1.9k
- Forks
- 399
- Avg merge
- 1h 10m
- Merged PRs (30d)
- 4
Getting set up
- Ships a Dockerfile or Docker Compose file
- No pull request template
- No contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from retspen/webvirtcloud
-
Difficulty 1/5 Under an hour Newbie friendliness 68/100
retspen/webvirtcloud#393 · 1 comment · 1 reaction ·
-
Difficulty 4/5 3-5 days Newbie friendliness 50/100
retspen/webvirtcloud#686 · 1 comment ·
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
retspen/webvirtcloud#683 · 1 comment ·
-
安装完成后打开控制头灰屏Open
Difficulty 4/5 3-5 days Newbie friendliness 15/100
retspen/webvirtcloud#678 ·
-
Difficulty 3/5 1-2 days Newbie friendliness 35/100
retspen/webvirtcloud#674 · 1 reaction ·
All issues in retspen/webvirtcloud
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
[quality] useFocusTrap's Shift+Tab wrap and non-Tab/non-Escape key arms are never driven end to endPossibly taken @hivecommons-hive claimed this today. Openagent/quality hive/covered-by-pr hive/hosted-available-lke648397-260827-5n31 quality testing
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
agentic-workflows
Difficulty 1/5 Under an hour Newbie friendliness 85/100
githubnext/gh-aw-workshop#4220 ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 92/100
JuliusBrussee/caveman#1189 ·
Maintainers usually reply within 1 day
-
priority:low ready-for-dev
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
OpenHands/extensions#738 ·
Maintainers usually reply within 1 day