-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The function on the exec call is named import_string\n\nIn `mindsdb/integrations/handlers/byom_handler/proc_wrapper.py`, the function that turns the uploaded bytes into a running Python module is six lines:\n\n```python\ndef import_string(code, module_name='model'):\n # import string as python module\n import types\n module = types.ModuleType(module_name)\n exec(code, module.__dict__)\n return module\n```\n\nThe name says import. The body says `exec`. The execution context is the fresh module's `__dict__`, which means every top-level statement in `code` runs as part of the call: imports, top-level `subprocess.run`, top-level file writes, top-level network calls. The function returns the module object only after every top-level statement has completed.\n\nThe PoC's payload is exactly the shape this guarantees:\n\n```python\nimport subprocess\nresult = subprocess.run(['whoami'], capture_output=True, text=True)\nopen('/tmp/pwned.txt', 'w').write('PWNED: ' + result.stdout)\n\nclass MyModel:\n def train(self, df, target, args=None): return self\n def predict(self, df, args=None): return df\n```\n\nThe `subprocess.run` runs during `exec`. The class definition is decoration. The class is there to satisfy a check that has not run yet.\n\n## The check happens after the exec\n\nIn the same file:\n\n```python\ndef check_module(module, mode):\n # checks module and returns info\n methods = {}\n if mode == 'custom_function':\n methods = get_methods_info(module)\n else:\n # is BYOM, check it.\n model_class = find_model_class(module)\n if model_class is None:\n raise RuntimeError('Unable to find model class (it has to have `train` and `predict` methods)')\n model_class()\n return {'methods': methods}\n```\n\nThe wrapper that calls it is `ModelWrapperUnsafe`, in `byom_handler.py`:\n\n```python\nclass ModelWrapperUnsafe:\n \"\"\"Model wrapper that executes learn/predict in current process\"\"\"\n\n def __init__(self, code, modules_str, engine_id, engine_version: int):\n self.module = import_string(code)\n model_instance = None\n model_class = find_model_class(self.module)\n if model_class is not None:\n model_instance = model_class()\n self.model_instance = model_instance\n```\n\n`import_string` is the first statement. By the time `find_model_class` walks the module's namespace looking for a class with `train` and `predict` methods, the attacker's `subprocess.run(['whoami'])` has already run. The gate's only failure mode is `RuntimeError('Unable to find model class')`, raised from inside a process that has already executed the attacker's code. Adding `train` and `predict` methods to the class is the price of an exception not getting raised. The price is two lines.\n\n## BYOM_TYPE has two values. The form picks one.\n\nAt the top of `byom_handler.py`:\n\n```python\nBYOM_TYPE = Enum(\"BYOM_TYPE\", [\"INHOUSE\", \"VENV\"])\n```\n\n`INHOUSE` selects `ModelWrapperUnsafe`. `VENV` selects `ModelWrapperSafe`. The two wrappers exist side by side. One runs the upload in the MindsDB worker process. The other runs it in a subprocess inside a virtualenv. Both are real implementations; both ship in every release.\n\nThe selection lives in the HTTP handler. From `mindsdb/api/http/namespaces/handlers.py`:\n\n```python\n@ns_conf.route(\"/byom/\")\n@ns_conf.param(\"name\", \"Name of the model\")\nclass BYOMUpload(Resource):\n @ns_conf.doc(\"put_file\")\n @api_endpoint_metrics(\"PUT\", \"/handlers/byom/handler\")\n def put(self, name):\n params = prepare_formdata()\n code_file_path = params[\"code\"]\n try:\n module_file_path = params[\"modules\"]\n except KeyError:\n module_file_path = Path(code_file_path).parent / \"requirements.txt\"\n module_file_path.touch()\n module_file_path = str(module_file_path)\n connection_args = {\n \"code\": code_file_path,\n \"modules\": module_file_path,\n \"mode\": params.get(\"mode\"),\n \"type\": params.get(\"type\"),\n }\n ast_query = CreateMLEngine(name=Identifier(name), handler=\"byom\", params=connection_args)\n sql_session = SessionController()\n command_executor = ExecuteCommands(sql_session)\n command_executor.execute_command(ast_query)\n return \"\", 200\n```\n\n`params.get(\"type\")` reads the form's `type` field. The PoC sends `-F 'type=inhouse'`. The string is uppercased downstream, looked up in `BYOM_TYPE`, used to select the wrapper. There is no allowlist step. There is no operator approval. The form is the policy.\n\n## /api/handlers is in protected_namespaces. Protection is opt-in.\n\nThe BYOM upload route is mounted under `/api/handlers`. The list of HTTP namespaces the auth middleware will check on every request lives in `mindsdb/api/http/initialize.py`:\n\n```python\nprotected_namespaces = [\n tab_ns,\n utils_ns,\n conf_ns,\n file_ns,\n sql_ns,\n analysis_ns,\n handlers_ns,\n tree_ns,\n projects_ns,\n databases_ns,\n views_ns,\n models_ns,\n chatbots_ns,\n skills_ns,\n agents_ns,\n jobs_ns,\n knowledge_bases_ns,\n]\n```\n\n`handlers_ns` is on the list. A defender reading the source and grepping for `/api/handlers` will find it there. The list reads like enforcement.\n\nThe enforcement is one branch lower:\n\n```python\n@app.before_request\ndef before_request():\n ctx.set_default()\n h = request.headers.get(\"Authorization\")\n if not h or not h.startswith(\"Bearer \"):\n bearer = None\n else:\n bearer = h.split(\" \", 1)[1].strip() or None\n\n http_auth_type = config[\"auth\"][\"http_auth_type\"]\n if (\n config[\"auth\"][\"http_auth_enabled\"] is True\n and any(request.path.startswith(f\"/api{ns.path}\") for ns in protected_namespaces)\n and (\n (http_auth_type == HTTP_AUTH_TYPE.SESSION and check_session_auth() is False)\n or (http_auth_type == HTTP_AUTH_TYPE.TOKEN and verify_pat(bearer) is False)\n or (\n http_auth_type == HTTP_AUTH_TYPE.SESSION_OR_TOKEN\n and check_session_auth() is False\n and verify_pat(bearer) is False\n )\n )\n ):\n return http_error(HTTPStatus.UNAUTHORIZED, \"Unauthorized\", \"Authorization is required to complete the request\")\n```\n\nThe outermost condition is `config[\"auth\"][\"http_auth_enabled\"] is True`. If that flag is false, the entire boolean expression short-circuits and the request proceeds. The default value of the flag is in `mindsdb/utilities/config.py`:\n\n```python\n\"auth\": {\n \"http_auth_type\": HTTP_AUTH_TYPE.SESSION_OR_TOKEN,\n \"http_auth_enabled\": False,\n \"http_permanent_session_lifetime\": datetime.timedelta(days=31),\n \"username\": \"mindsdb\",\n \"password\": \"\",\n},\n```\n\n`http_auth_enabled` defaults to `False`. The default username is `mindsdb`. The default password is the empty string. A freshly cloned and `pip install -e`'d MindsDB, started with `python -m mindsdb --api=http`, listens on `0.0.0.0:47334` and accepts `PUT /api/handlers/byom/` from anyone who can route a packet to that port.\n\nThe protection list contains the route. The flag that activates the protection list defaults off.\n\n## The cloud build disables BYOM. The self-hosted build does not.\n\n`byom_handler.py` reads the deployment mode at construction time:\n\n```python\nis_cloud = Config().get(\"cloud\", False)\nif is_cloud is True:\n byom_enabled = os.environ.get(\"MINDSDB_BYOM_ENABLED\", \"false\")\n```\n\nThe cloud build defaults `MINDSDB_BYOM_ENABLED` to `\"false\"`. The self-hosted build does not run this branch, because `Config().get(\"cloud\", False)` returns `False` on a self-hosted instance. The vendor's own deployment of MindsDB does not expose the BYOM upload route under this configuration. The exact same release tarball, run on a researcher's laptop or a tenant's bare-metal box, does.\n\nThe toggle that turns BYOM off in cloud is in the codebase. The vendor reached for it for their own deployment. The default configuration that ships in `pip install mindsdb` does not reach for it.\n\n## \"Unrestricted upload\" is the wrong frame\n\nThe CWE assigned to CVE-2026-7711 is CWE-434, unrestricted upload of file with dangerous type. The PoC writeup leads with \"RCE via file upload.\" Both framings miss what is happening.\n\n`prepare_formdata` rejects field names other than `code` and `modules`, rejects source filenames containing path separators, and writes to a fresh `tempfile.mkdtemp(prefix=\"mindsdb_file_\")` directory. The file lands exactly where the developer intended it to land. The destination is not webroot. It is not the user upload directory of a CMS. It is a temp file the server reads back into memory four function calls later and passes to `exec`.\n\nThe path on disk is not the path that matters. The path that matters runs from the form field `code`, into a `tempfile`, into `params[\"code\"]`, into `connection_args[\"code\"]`, into the handler's model proxy, into `ModelWrapperUnsafe(code=, ...)`, into `import_string(code)`, into `exec(code, module.__dict__)`. Calling this a file-upload bug concedes the framing the code is in. The handler is not an upload pipeline that forgot to validate. It is an exec pipeline that is documented to call `exec`.\n\nThis is the architectural shape [Unauth Write To Execution Path](/patterns/unauth-write-to-execution-path) names, with a variant that has come up before in [Nginx-UI's restore route](/posts/nginx-ui-backup-signature-key-on-the-request): the execution path is not a webroot. It is a path the server reads back into a dangerous interpreter on its own, without a second request, without a CGI dispatch, without an `include` from a page handler. The pattern's three architectural decisions still compose: an unauthenticated trigger, a write into a path the server later treats as code, and a contract that does not validate what is in the path.\n\n## What the class names mean\n\n`ModelWrapperUnsafe` is the class name as it appears in `byom_handler.py`. `ModelWrapperSafe` is the sibling. The developer wrote both. The developer wrote the word \"Unsafe\" into one of them. The developer wrote the word \"Safe\" into the other.\n\nThe two classes are evidence of a threat model. Whoever named them understood that running uploaded Python in the worker process was unsafe and that running it in a virtualenv subprocess was the safer alternative. They built the safer alternative. They put the choice between the two on the wire, in a form field, on a route whose authentication defaults off, in a build whose deployment-mode check disables BYOM in the cloud and does not disable it self-hosted.\n\nThe CVE description ends with \"the vendor was contacted early about this disclosure but did not respond in any way.\" That sentence is in the NVD record. The class is still named Unsafe in the current `main` branch. The form still picks it.\n\nPoC: [nn0nkey/JD-Security-SHENYI-Team](https://github.com/nn0nkey/JD-Security-SHENYI-Team/blob/main/MindsDB_BYOM_RCE.md)","closing_line":"The patch will close this CVE. It will not delete ModelWrapperUnsafe.","hook_md":"MindsDB ships two ways to run user-supplied BYOM code. One is named `ModelWrapperSafe` and runs the upload in a subprocess inside a virtualenv. The other is named `ModelWrapperUnsafe` and runs it in the MindsDB worker process. CVE-2026-7711 is `PUT /api/handlers/byom/`, an HTTP route where the form's `type` field picks which wrapper runs the Python the caller just uploaded. The route lives in MindsDB's `protected_namespaces` list. The flag that activates the list, `config[\"auth\"][\"http_auth_enabled\"]`, defaults to false.","post_id":281,"slug":"mindsdb-byom-form-picks-modelwrapperunsafe","title":"CVE-2026-7711: MindsDB Built ModelWrapperSafe and ModelWrapperUnsafe. The HTTP Form Picks.","type":"initial","unreadable_sentence":"The class is named Unsafe. The form picks it."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCatOxuAAKCRDeZjl4jgkQ JucMAQDIwdjoYLfunQqk408MlQK0s5ozWyYtAFl/5zgWeu8ULAEAmlbpRDXJPQFe oEpNatoxUxXUUu7omJip+lEX0jIaOgo= =PBRd -----END PGP SIGNATURE-----