{"repro_id":"REPRO-2026-00302","version":6,"title":"datamodel-code-generator code injection via customBasePath before 0.70.0","repro_type":"security","status":"published","severity":"high","description":"Primary-source brief (manual researched): NVD states datamodel-code-generator prior to 0.70.0 has a code injection vulnerability when attackers control input schemas. A malicious customBasePath value containing embedded newlines and a dot-free Python expression is emitted verbatim into a generated 'from ... import ...' statement without identifier validation, causing arbitrary Python code execution when the generated module is imported. Upstream references include the fixing commit 545a96c56d1b6a8dd3f4a16c9090d8a4648d1e43 and release 0.70.0. Reproduction goal for the pipeline: exercise the generator's real input-to-output path and compare vulnerable vs fixed behavior from the advisory only; do not invent a broader PoC or claim beyond code injection on import of generated output.","root_cause":"# RCA Report — CVE-2026-63720\n\n## Summary\n\ndatamodel-code-generator (koxudaxi) before 0.70.0 contains a code-injection\nvulnerability in its JSON Schema `customBasePath` extension handling. The\ngenerator emits the schema-controlled `customBasePath` value verbatim into the\ngenerated module's `from <module> import <name>` statement (and the model\nbase-class list) via `Import.from_full_path`, which splits the value on `.` and\nperforms no identifier validation. A malicious schema whose `customBasePath`\nembeds a newline plus a dot-free Python expression causes attacker-controlled\nPython statements to execute when a victim imports the generated module. The\nvulnerability is reachable through the user-facing `datamodel-codegen` CLI\ngenerator input-to-output path.\n\n## Impact\n\n- **Package/component affected:** `datamodel_code_generator` parser\n  (`src/datamodel_code_generator/parser/jsonschema.py`,\n  `JsonSchemaObject.custom_base_path` /\n  `Import.from_full_path` /\n  `Parser._resolve_base_class`).\n- **Affected versions:** datamodel-code-generator before 0.70.0; vulnerable\n  checkout anchored at `af435d9893d1352924a2f011a2eb04a997d8b978` (parent of the\n  fix, reported as `0.69.0`).\n- **Risk level:** High. An attacker who supplies a JSON Schema to a victim\n  running the generator achieves arbitrary Python code execution in the\n  victim's import context when the generated module is imported. The\n  generator output is the natural consumption path of the CLI, so the\n  weaponized module is a direct product artifact.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Arbitrary code execution (code\n  injection on import of generated output).\n- **Reproduced impact from this run:** Arbitrary attacker-controlled Python\n  code execution. Two fresh vulnerable `datamodel-codegen` process instances\n  generated modules whose import executed attacker-controlled `print(...)`\n  writing distinct marker files (`DMCG_RCE_INST1`, `DMCG_RCE_INST2`). The\n  fixed build rejected the same schema with exit code 2 and produced no\n  output and no marker.\n- **Parity:** `full`.\n- **Not demonstrated:** None. The requested code-execution impact was\n  demonstrated directly through the CLI entrypoint and the generated-module\n  import consumption path.\n\n## Root Cause\n\n`JsonSchemaObject` models the JSON Schema `customBasePath` extension as\n`custom_base_path: str | list[str] | None` (alias `customBasePath`) with no\nvalidation. The parser resolves a model's base class via\n`Parser._resolve_base_class(class_name, obj.custom_base_path)`, which returns\nthe raw `custom_base_path` string. That string is fed to\n`Import.from_full_path`, which splits the value on `.` to derive a module path\nand an import name, and the result is emitted verbatim into the generated\n`from <module> import <name>` statement and the model base-class list. Because\nthe value is never checked to be a dotted Python identifier path, a value\ncontaining a newline plus attacker-controlled Python text is written into the\ngenerated module. Importing that module executes the injected text at module\ntop level.\n\nA leading comma in the payload (`pydantic.BaseModel,\\n<expr>`) is used so that\nboth the generated `from pydantic import BaseModel,\\n<expr>` statement and the\n`class ExploitModel(BaseModel,\\n<expr>):` definition stay syntactically valid\n(the newline is a continuation inside the comma-separated lists), letting the\nmodule compile and the injected `<expr>` execute on import.\n\nThe fix commit `545a96c56d1b6a8dd3f4a16c9090d8a4648d1e43` (\"Merge commit from\nfork\") adds a `field_validator(\"custom_base_path\", mode=\"before\")` that calls\n`_validate_schema_python_import_path` (which delegates to\n`_validate_dotted_python_identifier_path`) on every scalar/list member. Values\nthat are not dotted Python identifier paths raise `Error: customBasePath must\nbe a dotted Python identifier path: ...`, the CLI exits with code 2, and no\noutput file is written.\n\n## Reproduction Steps\n\n1. Script: `bundle/repro/reproduction_steps.sh`.\n2. What the script does:\n   - Reads `bundle/project_cache_context.json` and reuses the prepared repo at\n     `/pruva/project-cache/repo` when `prepared=true`; otherwise clones the\n     repository.\n   - Resolves the vulnerable commit `af435d98...` (parent of fix) and the fixed\n     commit `545a96c5...`, and verifies the `validate_custom_base_path`\n     validator is absent in the vulnerable checkout and present in the fixed\n     checkout.\n   - Creates two isolated git worktrees (one per commit) and editable-installs\n     each into its own venv so the two builds cannot mutate each other's source\n     tree.\n   - Generates a malicious JSON Schema whose `customBasePath` is\n     `pydantic.BaseModel,\\nprint('<MARKER>', file=open('<path>','w'))` (the\n     injected expression is dot-free so it survives `Import.from_full_path`'s\n     split-on-`.`).\n   - Runs the real `datamodel-codegen` CLI (`--input ... --input-file-type\n     jsonschema --output ...`) on the schema for two fresh vulnerable process\n     instances, then imports each generated module; the injected `print()`\n     executes at module top level and writes a distinct marker file.\n   - Runs the same CLI against the fixed build; it rejects the schema with\n     exit code 2, writes no output, and produces no marker (negative control).\n   - Writes `bundle/repro/runtime_manifest.json` and\n     `bundle/repro/negative_control_observation.json`.\n3. Expected evidence of reproduction: marker files `repro/marker_inst1`\n   (`DMCG_RCE_INST1`) and `repro/marker_inst2` (`DMCG_RCE_INST2`) written by\n   the vulnerable imports; `repro/generated_vuln_inst1.py` showing the injected\n   `print(...)` at module level; fixed codegen log showing the rejection;\n   `negative_control_observation.json` showing no marker and exit code 2.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full run transcript (verdict\n  CONFIRMED).\n- `bundle/logs/vuln_codegen_inst1.log`,\n  `bundle/logs/vuln_codegen_inst2.log` — vulnerable CLI codegen output (exit\n  0).\n- `bundle/logs/vuln_import_inst1.log`,\n  `bundle/logs/vuln_import_inst2.log` — generated-module import output\n  (executes injected print, then TypeError from the None base class).\n- `bundle/logs/fixed_codegen.log` — fixed CLI rejection (exit 2,\n  `customBasePath must be a dotted Python identifier path`).\n- `bundle/repro/malicious_schema_inst1.json`,\n  `bundle/repro/malicious_schema_inst2.json`,\n  `bundle/repro/malicious_schema_fixed.json` — attacker-controlled schemas.\n- `bundle/repro/generated_vuln_inst1.py` — generated module containing the\n  injected `print('DMCG_RCE_INST1', file=open(...,'w'))` at line 9.\n- `bundle/repro/marker_inst1` = `DMCG_RCE_INST1`,\n  `bundle/repro/marker_inst2` = `DMCG_RCE_INST2` — proof of executed\n  attacker-controlled code.\n- `bundle/repro/negative_control_observation.json` — fixed build: exit 2, no\n  output, no marker.\n- `bundle/repro/runtime_manifest.json` — runtime evidence manifest.\n\nKey excerpt from `generated_vuln_inst1.py`:\n\n```python\nfrom __future__ import annotations\n\nfrom pydantic import BaseModel\n\nprint('DMCG_RCE_INST1', file=open('/workspace/bundle/repro/run/marker_inst1', 'w'))\n\n\nclass ExploitModel(\n    BaseModel,\n    print('DMCG_RCE_INST1', file=open('/workspace/bundle/repro/run/marker_inst1', 'w')),\n):\n    value: str | None = None\n```\n\nEnvironment: CPython 3.14 on Linux x86_64; datamodel-code-generator 0.69.0\n(vulnerable) and 0.69.1.dev1+g545a96c56 (fixed); pydantic v2.\n\n## Recommendations / Next Steps\n\n- **Upgrade guidance:** Upgrade datamodel-code-generator to 0.70.0 or later,\n  which contains the `customBasePath` field_validator.\n- **Suggested fix approach:** The applied fix is appropriate: validate every\n  scalar and list member of `customBasePath` (and the analogous\n  `customTypePath` / `x-python-import` paths) with\n  `_validate_dotted_python_identifier_path` before any code generation, and\n  reject non-identifier values with a clear error and no output. Consider also\n  hardening the generator's import/base-class emission to escape or reject\n  control characters (newlines) in schema-controlled identifier fields as\n  defense in depth.\n- **Testing recommendations:** Add regression tests that feed schemas with\n  newline-bearing and non-identifier `customBasePath`/`customTypePath` values\n  and assert the CLI exits non-zero with no output; the fix commit already\n  adds `unsafe_custom_base_path_scalar.json` /\n  `unsafe_custom_base_path_list_nested.json` fixtures for this.\n\n## Additional Notes\n\n- **Idempotency confirmation:** The script was run twice consecutively; both\n  runs exited 0 with `CONFIRMED=1`, both vulnerable instances wrote their\n  distinct markers, and the fixed build rejected the schema both times. The\n  script recreates worktrees/venvs each run, so it is self-cleaning.\n- **Why the comma is required:** Without the leading comma in the payload,\n  `class ExploitModel(BaseModel\\n<expr>):` becomes a `SyntaxError` (two\n  expressions with no separator inside the base-class list), which prevents\n  the whole module from compiling and blocks execution of the injected\n  statement. The comma keeps both the import and the class definition\n  syntactically valid so the module compiles and the injected statement\n  executes at import time; the subsequent `TypeError` (metaclass conflict\n  from the `None` returned by `print`) occurs after the marker is written.\n- **Why the expression must be dot-free:** `Import.from_full_path` splits the\n  raw `customBasePath` string on `.`, so any `.` in the injected text\n  (including dots inside string literals) breaks the payload across segments\n  and the final segment no longer contains the intended expression. The\n  `print(..., file=open(...))` form is dot-free.\n- **Surface match:** The claim surface is `cli_local` /\n  `cli_command`. The reproduction exercises the real `datamodel-codegen` CLI\n  entrypoint (`scripts.datamodel-codegen = \"datamodel_code_generator.__main__:main\"`)\n  end-to-end and demonstrates the claimed code-execution impact through the\n  generated-module import consumption path.\n","cve_id":"CVE-2026-63720","cwe_id":"CWE-94 Improper Control of Generation of Code ('Code Injection')","source_url":"https://github.com/koxudaxi/datamodel-code-generator","package":{"name":"datamodel-code-generator","ecosystem":"pip","affected_versions":">= 0.11.6, < 0.70.0","fixed_version":"0.70.0"},"reproduced_at":"2026-07-27T05:42:55.896934+00:00","duration_secs":824.0,"tool_calls":227,"handoffs":2,"total_cost_usd":2.624587,"agent_costs":{"claim_matcher":0.018902,"judge":0.059477,"learning_policy":0.011905,"repro":1.40034,"support":0.050902,"vuln_variant":1.083061},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.018902},"judge":{"gpt-5.4-mini":0.059477},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.011905},"repro":{"accounts/fireworks/routers/glm-5p2-fast":1.40034},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.050902},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.083061}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-27T05:42:56.534535+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":12637,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10074,"category":"analysis"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1351,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":6123,"category":"log"},{"path":"bundle/logs/vuln_codegen_inst2.log","filename":"vuln_codegen_inst2.log","size":429,"category":"log"},{"path":"bundle/logs/vuln_import_inst2.log","filename":"vuln_import_inst2.log","size":398,"category":"log"},{"path":"bundle/repro/malicious_schema_inst2.json","filename":"malicious_schema_inst2.json","size":299,"category":"other"},{"path":"bundle/repro/generated_vuln_inst2.py","filename":"generated_vuln_inst2.py","size":429,"category":"script"},{"path":"bundle/repro/malicious_schema_fixed.json","filename":"malicious_schema_fixed.json","size":314,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1057,"category":"other"}]}