Skip to content

[Bug]Path traversal in draft embedding loading: weight_map shard names escape the target model directory and leak out-of-package tensors into exported drafts #1041

Open

Description

@3em0

Checklist

  • 1. I have searched related issues but cannot get the expected help.
  • 2. The bug has not been fixed in the latest version.
  • 3. Please note that if the bug-related issue you submitted lacks corresponding environment info and a minimal reproducible demo, it will be challenging for us to reproduce and resolve the issue, reducing the likelihood of receiving feedback.
  • 4. If the issue you raised is not a bug but a question, please raise a discussion at https://github.com/sgl-project/SpecForge/discussions/new/choose Otherwise, it will be closed.
  • 5. Please use English, otherwise it will be closed.

Describe the bug

Eagle3DraftModel.load_embedding (specforge/modeling/draft/base.py:134-186 at commit fcc0c36) reads the shard file name for the embedding key from the weight_map of a *.index.json inside the target-model directory and joins it with the model directory without any containment check:

ckpt_file = index_json["weight_map"][embedding_key]
with safe_open(os.path.join(model_path, ckpt_file), framework="pt") as f:
    emb_tokens = f.get_tensor(embedding_key)
self.embed_tokens.weight.copy_(emb_tokens)

weight_map values are never validated: path separators are not rejected, ".." segments are not rejected, and an absolute value replaces the directory prefix entirely. A model package whose index maps "model.embed_tokens.weight" to "../../../victim_side_private/licensed-target-model/licensed_weights.safetensors" (or to any absolute path) therefore makes SpecForge read a tensor file outside the package boundary, copy it into the draft model's frozen embedding, and ship it inside the exported draft artifact via model.save_pretrained.

I verified this end to end with the real export entry point (python -m specforge.export.to_hf --embedding-source ): a package that contains NO weight file at all still exports successfully, and the exported draft's embed_tokens.weight is byte-identical to a marker tensor placed outside the package. The same unchecked join exists in _load_embedding_tensor (specforge/export/to_hf.py:36-65), and on the current main branch the loader was refactored into load_checkpoint_tensors, which still joins the index value at specforge/modeling/target/checkpoint.py:205 with only a type check. Training reaches the same loader through build_eagle3_draft / build_peagle_draft when load_target_embedding is set (specforge/algorithms/model_providers.py:106-110, 122-126).

Suggested fix: reject weight_map values containing path separators or ".." segments (an index references shard file names, not paths), or require os.path.realpath(os.path.join(model_path, value)) to stay inside os.path.realpath(model_path); apply it in load_embedding, _load_embedding_tensor, and load_checkpoint_tensors.

Reproduction

Attachments: make_poc.py (generates the model packages, the out-of-package private shard, and a minimal draft checkpoint), verify_export.py (fingerprints the embedding inside an exported draft directory).

python make_poc.py
# creates: lab/x/y/z/pkg-rel (index maps the embedding key to ../../../victim_side_private/licensed-target-model/licensed_weights.safetensors),
# lab/x/y/z/pkg-abs (index maps it to an absolute path), lab/x/y/z/pkg-benign (negative control,
# index maps it to the in-package shard model-00001-of-00001.safetensors), and a private marker
# tensor outside every package at lab/x/victim_side_private/licensed-target-model/licensed_weights.safetensors

export PYTHONPATH=<SpecForge checkout at fcc0c367>
python -m specforge.export.to_hf --checkpoint lab/ckpt --draft-config lab/ckpt/draft_config.json --embedding-source lab/x/y/z/pkg-rel --output-dir out-rel
python verify_export.py out-rel 7.3125
# -> comparison against marker 7.3125: MATCH  (every exported embedding value came from the file outside the package)

python -m specforge.export.to_hf --checkpoint lab/ckpt --draft-config lab/ckpt/draft_config.json --embedding-source lab/x/y/z/pkg-abs --output-dir out-abs
python verify_export.py out-abs 7.3125
# -> MATCH as well (absolute-path variant)

python -m specforge.export.to_hf --checkpoint lab/ckpt --draft-config lab/ckpt/draft_config.json --embedding-source lab/x/y/z/pkg-benign --output-dir out-benign
python verify_export.py out-benign 1.5078125
# -> negative control loads the in-package benign marker; the three packages are identical except for the single weight_map value

pkg-rel and pkg-abs contain only config.json and model.safetensors.index.json — no weight file — so a successful export can only be explained by the loader following the index value outside the package boundary.

poc.zip

Environment

  • SpecForge: source checkout pinned at commit fcc0c36 (2026-09-09), version.txt says 0.2.0; the unchecked join is still present on main as of 2026-10-22 (specforge/modeling/target/checkpoint.py:205)
  • OS: Ubuntu 24.04 (WSL2) — also reproduced on Windows 11; CPU only, no GPU required
  • Python: 3.12.3; torch 2.14.1+cpu; transformers 5.20.2; safetensors (current release); huggingface-hub (current release)
  • The draft is a 256x64-embedding EAGLE3 config, so the whole run fits in CPU memory

link:
https://github.com/3em0/cve_repo/blob/main/2026/Path_Traversal_in_SpecForge_Draft_Embedding_Loader_Enables_Weight_Exfiltration.md

Activity

  1. jiapingW commented on Nov 2, 2026

    @jiapingW
    Collaborator

    Hi, while this check effectively blocks the directory traversal issue identified in #1041, it also inadvertently rejects standard Hugging Face snapshots. In these snapshots, the index contains valid relative shard names, but the actual files are symlinks pointing to locations like ../../blobs/.... Since this project’s resolve_checkpoint_dir() returns such paths, this change introduces a regression that breaks the loading of otherwise valid checkpoints. It is necessary to distinguish between index path validation and the resolution of supported cache files, while ensuring that Hugging Face snapshot scenarios remain functional. Simply removing the real-path check is not an option, nor should access to the entire cache directory be permitted unconditionally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions