| --- |
| license: apache-2.0 |
| tags: |
| - security |
| - proof-of-concept |
| - path-traversal |
| --- |
| |
| # espnet `unpack()` zip-slip / path-traversal arbitrary file write (PoC model) |
|
|
| This repository contains a **benign** proof-of-concept model archive that |
| demonstrates a path-traversal / zip-slip arbitrary **file write** in |
| [espnet](https://github.com/espnet/espnet). |
|
|
| - Affected code: `espnet2/main_funcs/pack_funcs.py`, function `unpack()` |
| (commit `85ac06a1f773ab842169d73e8805801d61a4a738`). |
| - Reached by the public model-loading path |
| `espnet_model_zoo.downloader.ModelDownloader.download_and_unpack(...)` → |
| `unpack_local_file(...)` → `pack_funcs.unpack(archive, cachedir)`. |
|
|
| ## Root cause |
|
|
| When espnet unpacks a model archive, every member whose name is listed in the |
| archive's `meta.yaml` `yaml_files` is written with a **manually joined path**: |
|
|
| ```python |
| fname = archive.get_name_from_info(info) # attacker-controlled member name |
| outname = outpath / fname # no basename / no containment check |
| outname.parent.mkdir(parents=True, exist_ok=True) |
| with outname.open("w") as f: # writes OUTSIDE outpath if fname has ../ |
| yaml.safe_dump(d, f) |
| ``` |
|
|
| The member name is never sanitised, so a name such as |
| `../../ESPNET_ZIPSLIP_ESCAPED.yaml` escapes the intended extraction directory. |
| Because the write uses a manual join + `open("w")` (not `ZipFile.extract`), this |
| escape works even for plain `.zip` archives, bypassing zipfile's own `..` |
| protection. The non-yaml branch (`archive.extract(...)`) additionally enables |
| classic tar-slip for `.tar`/`.tgz` archives. An attacker who publishes a model |
| can therefore drop files at arbitrary filesystem locations (e.g. overwrite a |
| startup script or config), leading to potential code execution. |
|
|
| ## Contents |
|
|
| - `espnet_zipslip_poc.zip` — a minimal malicious espnet model archive. It |
| contains `meta.yaml` and one member literally named |
| `../../ESPNET_ZIPSLIP_ESCAPED.yaml`. |
|
|
| **benign PoC — writes a marker only:** the escaping member writes a harmless |
| YAML marker (`marker: ESPNET_ZIPSLIP_ESCAPED`); it performs no destructive |
| action. |
|
|
| ## Reproduce |
|
|
| ```bash |
| pip install espnet |
| python -c "import tempfile,pathlib; from espnet2.main_funcs.pack_funcs import unpack; \ |
| d=pathlib.Path(tempfile.mkdtemp()); o=d/'cachedir'/'modelhash'; o.mkdir(parents=True); \ |
| (o/'model.zip').write_bytes(pathlib.Path('espnet_zipslip_poc.zip').read_bytes()); \ |
| unpack(str(o/'model.zip'), str(o)); \ |
| print('ESCAPED FILE WRITTEN OUTSIDE EXTRACTION DIR:', (d/'ESPNET_ZIPSLIP_ESCAPED.yaml').exists())" |
| ``` |
|
|
| Expected output ends with: |
|
|
| ``` |
| ESCAPED FILE WRITTEN OUTSIDE EXTRACTION DIR: True |
| ``` |
|
|
| The file `ESPNET_ZIPSLIP_ESCAPED.yaml` appears **two directories above** the |
| extraction directory, proving the traversal. |
|
|