Skip to content

Latest commit

 

History

History
140 lines (105 loc) · 8.74 KB

File metadata and controls

140 lines (105 loc) · 8.74 KB

JumpServer — Authenticated Arbitrary File Read/Write/Delete via Ops Playbook File Browser (Path Traversal → RCE)

Summary

Field Value
Product JumpServer (Privileged Access Management / bastion host)
Vendor repository https://github.com/jumpserver/jumpserver
Vulnerability type CWE-22 (Improper Limitation of a Pathname to a Restricted Directory / Path Traversal)
Component apps/ops/api/playbook.pyPlaybookFileBrowserAPIView
Affected versions v3.0.0 through v3.6.6 (audited on v3.6.0, commit cb11cb177)
Fixed in v3.7.0 (fix commit f3ca45aa7, introduces django.utils._os.safe_join) — silently, not disclosed as a security fix
CVE status No CVE assigned — this is a distinct issue from CVE-2024-40628/40629 (see "Relationship to CVE-2024-40628/40629" below)
Privileges required Any authenticated low-privilege user (built-in User role)
PoC file poc_vuln001_playbook_traversal_v2.py (also earlier variant poc_path_traversal_001.py)

Vulnerability Description

The Ops Playbook "file browser" API lets a user read, create, edit, rename and delete files inside the working directory of a playbook they own. The user-controlled key parameter is concatenated onto the playbook working directory with os.path.join() and passed straight to open(), os.rename(), os.remove() and shutil.rmtree() without any path normalization or boundary check. Because os.path.join(base, "../../../etc/passwd") resolves outside base, an attacker can traverse out of the working directory and reach arbitrary paths on the server filesystem.

The four HTTP verbs of the endpoint expose four distinct primitives:

  • GET → arbitrary file read
  • POST → arbitrary file/directory create
  • PATCH → arbitrary file write / rename
  • DELETE → arbitrary file/directory delete

Impact

  • Confidentiality: read any file the service account can read, including JumpServer's own config.yml (contains SECRET_KEY, database credentials, Redis credentials), /etc/passwd, etc.
  • Integrity → RCE: overwrite the main.yml of any existing playbook; when that playbook is next executed (by any privileged user or a scheduled job) the attacker-controlled Ansible content runs as the ansible-runner process. Writable Python module files can likewise be poisoned. This yields remote code execution from a single low-privilege account.
  • Availability: delete arbitrary files/directories, causing service disruption or data loss.

A secondary IDOR exists: get_object_or_404(Playbook, id=pk) is not scoped to creator/organization, so a user can also target other users' playbook file browsers. The path traversal alone is already sufficient for full impact.

CVSS 3.1

Base score: 9.9 (Critical)

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Rationale: network-reachable authenticated API (AV:N, PR:L), no user interaction, and the write→RCE primitive breaks out of the application's file sandbox to affect the underlying OS/other tenants (Scope changed), with full C/I/A loss.

Exploitation Conditions

  • Attacker holds a valid session for any account with the built-in User role. That role is granted ("ops", "playbook", "*", "*") by default (apps/rbac/builtin.py:22), which includes ops.add_playbook and ops.change_playbook.
  • The JumpServer web API is reachable.

Exploitation Steps

  1. Authenticate as a normal user and obtain a session + CSRF token.
  2. Create an owned playbook: POST /api/v1/ops/playbooks/ with {"name":"poc","create_method":"blank"} → returns the playbook UUID.
  3. Arbitrary read (leak secrets): GET /api/v1/ops/playbook/<uuid>/file/?key=../../../../config.yml
  4. Arbitrary write → RCE: PATCH /api/v1/ops/playbook/<uuid>/file/ with body {"key":"../../<path-to-another-playbook>/main.yml","content":"<malicious ansible/jinja>"}. The payload executes when that playbook is next run.
  5. Arbitrary delete: DELETE /api/v1/ops/playbook/<uuid>/file/?key=../../../../<target>

POC

PYTHON CODE

SINK (v3.6.0 — apps/ops/api/playbook.py)

# GET — line 82-91 (arbitrary read)
def get(self, request, **kwargs):
    playbook = get_object_or_404(Playbook, id=kwargs.get('pk'))
    work_path = playbook.work_dir
    file_key = request.query_params.get('key', '')
    if file_key:
        file_path = os.path.join(work_path, file_key)     # <-- no normalization / boundary check
        with open(file_path, 'r') as f:
            content = f.read()
            return Response({'content': content})

# PATCH — line 169 & 183-186 (arbitrary write / rename)
file_path = os.path.join(work_path, file_key)             # <-- traversal
...
if new_name:
    new_file_path = os.path.join(os.path.dirname(file_path), new_name)
    os.rename(file_path, new_file_path)
else:
    with open(file_path, 'w') as f:                       # <-- arbitrary write
        f.write(content)

# DELETE — line 195-199 (arbitrary delete)
file_path = os.path.join(work_path, file_key)
if os.path.isdir(file_path):
    shutil.rmtree(file_path)                              # <-- arbitrary rmtree
else:
    os.remove(file_path)

Note: the startswith('.') checks elsewhere in the class only build front-end tree node display IDs — they are not a security control.

SOURCE

  • User-controlled keyrequest.query_params.get('key') (GET/DELETE) and request.data.get('key') (POST/PATCH).
  • work_dir base — apps/ops/models/playbook.py:77-79:
    @property
    def work_dir(self):
        work_dir = os.path.join(settings.DATA_DIR, "ops", "playbook", self.id.__str__())
        return work_dir
  • Default privilege grant — apps/rbac/builtin.py:22:
    user_perms = (
        ...
        ("ops", "playbook", "*", "*"),   # built-in "User" role → add/change playbook
    )

Call Stack

HTTP GET/POST/PATCH/DELETE /api/v1/ops/playbook/<uuid:pk>/file/
  └─ PlaybookFileBrowserAPIView.get()/post()/patch()/delete()   (apps/ops/api/playbook.py)
       └─ work_path = Playbook.work_dir                          (apps/ops/models/playbook.py:77)
       └─ file_path = os.path.join(work_path, file_key)          # user-controlled key, no sanitization
            └─ open(file_path) / open(file_path,'w') / os.rename / os.remove / shutil.rmtree

Relationship to CVE-2024-40628 / CVE-2024-40629

This vulnerability is not covered by the existing JumpServer Ansible-playbook CVEs (CVE-2024-40628 arbitrary file read, GHSA-rpf7-g4xh-84v9; CVE-2024-40629 arbitrary file write → RCE, GHSA-3wgp-q8m7-v33v). They are distinct issues in different code paths, proven by patch analysis:

  1. Different component / attack surface. CVE-2024-40628/40629 are triggered through the Job Center "Job > Template / Job list" flow, abusing Ansible execution-time behavior (the lookup plugin / writing into Ansible package directories inside the Celery container). This report's vulnerability is a direct path traversal in the REST file-browser endpoint PlaybookFileBrowserAPIView (/api/v1/ops/playbook/<uuid>/file/), independent of any playbook execution.

  2. Different fix, different release. The CVE-2024-40628/40629 fix shipped in v3.10.12 (July 2024) and did not modify apps/ops/api/playbook.py at all — verified: no commit in the v3.10.11..v3.10.12 range touches that file; the actual fix reworked the Ansible runner ("isolated mode", commit 165d030c8, apps/ops/ansible/callback.py / runner.py). The file-browser path traversal was instead fixed much earlier, in v3.7.0, by commit f3ca45aa7 dated 2023-09-19, whose message is perf: 优化 Playbook 文件创建逻辑 — a refactor with no security wording and no associated CVE/advisory (a silent fix).

  3. Version ranges are mutually exclusive. CVE-2024-40629 lists v3.0.0–v3.10.11 as affected. Yet by v3.10.11 the file browser already used safe_join in all handlers (introduced in v3.7.0), i.e. it was no longer vulnerable throughout most of that CVE's stated range. A single bug cannot be "still vulnerable at v3.10.11" (the CVE) and "already fixed since v3.7.0" (this endpoint) simultaneously — confirming they are separate vulnerabilities.

Conclusion: the PlaybookFileBrowserAPIView path traversal is an independent, previously undisclosed vulnerability affecting v3.0.0–v3.6.6, silently fixed in v3.7.0, with no CVE currently assigned — eligible for a new CVE request.

Fix

Fixed in v3.7.0 by replacing all os.path.join(work_path, file_key) calls with django.utils._os.safe_join, which raises SuspiciousFileOperation when the resolved path escapes the base directory. The IDOR should additionally be closed by scoping get_object_or_404 to creator/organization.