[{"data":1,"prerenderedAt":45},["ShallowReactive",2],{"pub-no-keys-required-inside-the-attack-surface-of-xllm":3,"publications":18},{"title":4,"slug":5,"excerpt":6,"tags":7,"category":11,"date":12,"author":13,"readTime":14,"thumbnail":15,"body":16,"bodyHtml":17},"No Keys Required: Inside the Attack Surface of xLLM","no-keys-required-inside-the-attack-surface-of-xllm","xLLM is a cutting-edge language model with a unique attack surface. This post explores the potential vulnerabilities and security considerations when interacting with xLLM.",[8,9,10],"xllm","security","ai","AI Security","2026-07-17","@hypnguyen1209","10 min read","https:\u002F\u002Fraw.githubusercontent.com\u002FLosec-io\u002Fblogs\u002Fmain\u002Finside-the-attack-surface-of-xllm\u002Fimage.png","*A deep dive into the architecture and vulnerability classes hiding inside production LLM inference engines, through the lens of xLLM.*\n\n**Security Research - July 2026**\n\nLLM inference engines are a strange breed of software. They are built by ML engineers who think in tensors and throughput, deployed on GPU clusters worth millions, and then exposed to the internet through an HTTP API that trusts every byte it receives. The result is an attack surface that feels less like a web application and more like an industrial control system that someone accidentally port-forwarded.\n\nxLLM is a high-performance, open-source LLM inference engine written in C++ on top of Apache brpc. It supports the full OpenAI and Anthropic API surface, handles multimodal inputs (vision, audio, video), and runs distributed multi-GPU inference with disaggregated prefill-decode scheduling. It powers deployment of Qwen, DeepSeek, LLaMA, and dozens of other model families. It was recently donated to the OpenAtom Foundation.\n\nWe spent three rounds auditing its codebase. What we found says less about xLLM specifically and more about a systemic pattern across the entire LLM serving ecosystem.\n\n## The Architecture, Through an Attacker's Eyes\n\nBefore looking at individual bugs, it helps to understand *why* the attack surface of an inference engine looks the way it does. Three architectural properties shape everything that follows.\n\n### Property 1: Multiple network listeners, zero authentication\n\nxLLM is not a single-port web server. In a production distributed deployment, a single node can bind **seven or more brpc server ports**:\n\n```mermaid\ngraph TB\n  subgraph \"Single xLLM Node\"\n    API[\"Main API\\nport 8010\\nHTTP + brpc\"]\n    PD[\"DisaggPD\\nport 7777\\nprefill-decode RPC\"]\n    W1[\"Worker 0\\nauto-assigned\\nmodel execution\"]\n    W2[\"Worker 1\\nauto-assigned\\nmodel execution\"]\n    MC[\"MooncakeTransfer\\nport 26000\\nKV cache shipping\"]\n    XT[\"XTensorDist\\ndynamic\\npage pool ops\"]\n    CO[\"Collective\\ndynamic\\nNCCL sync\"]\n  end\n  EXT[\"External Client\"] --> API\n  NODE2[\"Other xLLM Nodes\"] --> PD\n  NODE2 --> MC\n  NODE2 --> XT\n```\n\nEvery one of these ports speaks brpc's HTTP and protobuf-over-TCP protocols. We grepped the entire `xllm\u002F` tree for `auth`, `token`, `api_key`, `bearer`, `Authorization`, `credential`, `password`, `allowlist`, `blocklist`. Zero results. We checked every `brpc::ServerOptions` instance for TLS or auth configuration. Nothing. We checked the protobuf message definitions for auth-related fields. None.\n\nThe sole gating mechanism we found was `enable_online_profile` (a feature flag for the CPU profiler, default off, not an auth check) and a `RateLimiter` that caps concurrent requests at 200 without distinguishing users.\n\nThis means the management endpoints - `fork_master` (load a model from any filesystem path), `sleep`\u002F`wakeup` (unload\u002Freload models), `pause` (abort all in-flight requests), `link_p2p` (establish connections to arbitrary addresses) are accessible to anyone who can reach the port.\n\n### Property 2: The multimodal surface is a proxy\n\nWhen serving vision-language models, xLLM's API accepts URLs in `image_url`, `video_url`, and `audio_url` fields. The server fetches these URLs server-side to download the media content before passing it to the model. This turns the inference engine into an HTTP proxy that makes outbound requests on behalf of the client a classic SSRF surface.\n\nWhat makes this surface particularly interesting in LLM engines is the **multimodal decode pipeline**. The fetched bytes go through format-specific decoders (OpenCV for images, FFmpeg for video\u002Faudio). The decoder output becomes a tensor that feeds the model. The model's text output is returned to the client. This creates an indirect data channel: if the attacker can control what the model sees, they can influence what the model says.\n\n### Property 3: brpc's builtin services are opt-out\n\nApache brpc is a high-performance RPC framework used widely in Chinese tech infrastructure (Baidu, JD, ByteDance). One of its features is a built-in debug console: `\u002Fflags` (view\u002Fmodify all gflags), `\u002Fvars` (internal metrics), `\u002Fconnections` (active connections), `\u002Fpprof\u002F*` (CPU\u002Fheap profiling), `\u002Fstatus`. These are enabled by default on every server port unless the developer explicitly sets `has_builtin_services = false`.\n\nThis is the brpc equivalent of leaving Django's `DEBUG=True` in production except it exposes runtime flags that can be *modified*, not just read.\n\n## Mapping the Attack Surface\n\nWith the architecture understood, here is how we decomposed the surface. Each category represents a different trust boundary the engine fails to enforce.\n\n```mermaid\ngraph LR\n  subgraph \"Attack Surface Categories\"\n    S1[\"1. Inference API\\nuser prompts, tokens,\\nsampling params\"]\n    S2[\"2. Multimodal Fetch\\nURLs, headers,\\nfile:\u002F\u002F paths\"]\n    S3[\"3. Management Plane\\nfork_master, sleep,\\npause, link_p2p\"]\n    S4[\"4. brpc Builtins\\n\u002Fflags, \u002Fpprof,\\n\u002Fconnections\"]\n    S5[\"5. Inter-node RPC\\nworker comms,\\nKV transfer, sync\"]\n    S6[\"6. Local IPC\\nshared memory,\\n0666 perms\"]\n  end\n  S1 --> SINK1[\"Scheduler +\\nModel Execution\"]\n  S2 --> SINK2[\"SSRF \u002F LFI\"]\n  S3 --> SINK3[\"Lifecycle Control\"]\n  S4 --> SINK4[\"Config Leak +\\nFlag Modification\"]\n  S5 --> SINK5[\"Data Injection\"]\n  S6 --> SINK6[\"OOB Read\u002FWrite\"]\n```\n\nCategories 1 and 5-6 turned out to be well-defended or require non-default configurations. Categories 2-4 produced four confirmed findings. The interesting story is in both: what broke and what held.\n\n## The Multimodal Proxy: Blind SSRF with Header Forwarding\n\nThe first thing we traced was the multimodal URL fetch path. In `mm_handler.cpp`, the `ImageHandler::load()` method dispatches on URL prefix:\n\n```cpp\n\u002F\u002F xllm\u002Fcore\u002Fframework\u002Fmultimodal\u002Fmm_handler.cpp:112-138\nMMErrCode ImageHandler::load(const MMContent& content,\n                             MMInputItem& input,\n                             MMPayload& payload) {\n  const auto& url = content.image_url.url;\n\n  if (url.compare(0, dataurl_prefix_.size(), dataurl_prefix_) == 0) {\n    \u002F\u002F \"data:image\u002F...\" - base64 encoded, handled locally\n    return this->load_from_dataurl(url, input.raw_data, payload);\n\n  } else if (url.compare(0, httpurl_prefix_.size(), httpurl_prefix_) == 0) {\n    \u002F\u002F \"http:\u002F\u002F...\" or \"https:\u002F\u002F...\" - server fetches the URL\n    return this->load_from_http(url, input.raw_data,\n                                content.image_url.headers);  \u002F\u002F \u003C---\n\n  } else {\n    \u002F\u002F everything else: treat as local file path\n    if (this->load_from_local(url, input.raw_data) == MMErrCode::SUCCESS) {\n      return MMErrCode::SUCCESS;\n    }\n    return MMErrCode::INVALID_URL_ERR;\n  }\n}\n```\n\nThe `httpurl_prefix_` is the 4-character string `\"http\"`. Any URL starting with `http` both `http:\u002F\u002F` and `https:\u002F\u002F` enters the server-side fetch path. The URL flows through `load_from_http()` into `BRpcDownloader::download()`:\n\n```cpp\n\u002F\u002F xllm\u002Fcore\u002Futil\u002Fhttp_downloader.cpp:109-144\nbool BRpcDownloader::download(\n    const std::string& host, const std::string& url,\n    std::string& data,\n    const std::unordered_map\u003Cstd::string, std::string>& headers) {\n\n  brpc::Controller cntl;\n  cntl.http_request().uri() = url;          \u002F\u002F attacker URL, verbatim\n\n  for (const auto& [k, v] : parse_global_headers()) {\n    cntl.http_request().SetHeader(k, v);    \u002F\u002F global defaults\n  }\n  for (const auto& [k, v] : headers) {\n    cntl.http_request().SetHeader(k, v);    \u002F\u002F per-request: attacker-controlled\n  }\n\n  cntl.set_timeout_ms(2000);\n  auto channel = get_channel(host);\n  channel->CallMethod(nullptr, &cntl, nullptr, nullptr, nullptr);\n  \u002F\u002F ...\n}\n```\n\nThe `parse_url()` function (line 60) that runs before this does one thing: it splits the URL at `:\u002F\u002F` and the next `\u002F` to extract the host. No validation of the host. No check against private IP ranges. No scheme restriction. No redirect following limit. The URL goes straight to `brpc::Channel::Init(host)` and then `CallMethod`.\n\nThe critical detail is the `headers` parameter. It comes from the protobuf definition at `multimodal.proto:28`:\n\n```protobuf\n\u002F\u002F xllm\u002Fproto\u002Fmultimodal.proto\nmessage ImageURL {\n  string url = 1;\n  map\u003Cstring, string> headers = 2;   \u002F\u002F attacker-controlled key-value pairs\n}\n```\n\nThese headers are extracted at `mm_service_utils.h:50-51` and forwarded verbatim to the outbound request. An attacker can include `Authorization`, `Cookie`, or any other header, turning the blind SSRF into an authenticated proxy for internal services.\n\n```mermaid\ngraph LR\n  REQ[\"POST \u002Fv1\u002Fchat\u002Fcompletions\\n{image_url: {url, headers}}\"] --> PARSE[\"api_service.cpp:421\\nJsonToProtoMessage\"]\n  PARSE --> EXTRACT[\"mm_service_utils.h:47\\nextract URL + headers map\"]\n  EXTRACT --> DISPATCH[\"mm_handler.cpp:125\\nprefix == http ?\"]\n  DISPATCH --> HTTP[\"load_from_http()\"]\n  HTTP --> DL[\"http_downloader.cpp:115\\ncntl.uri() = url\\nSetHeader(k, v) per header\"]\n  DL --> OUT[\"Outbound HTTP\\nto attacker-specified host\\nwith attacker-specified headers\"]\n```\n\nThe SSRF is **blind** for non-image responses. Cloud metadata endpoints return JSON, which fails OpenCV's `imdecode()`, and the client sees only \"Failed to decode multimodal input.\" But \"blind\" here still means the attacker can distinguish four states via timing and error differentiation: connection refused (~1ms), connection timeout (2000ms), HTTP 200 with non-image data (`DECODE_ERR`), and HTTP 200 with valid image (the VLM processes it). This enables reliable port scanning and service fingerprinting of the internal network.\n\nThis is the exact same bug class as [CVE-2025-6242 in vLLM](https:\u002F\u002Fnvd.nist.gov\u002Fvuln\u002Fdetail\u002FCVE-2025-6242). The pattern is systemic: every LLM engine that fetches multimodal URLs server-side without validation inherits this surface.\n\n## The Debug Console Nobody Turned Off\n\nEvery `brpc::ServerOptions` in xLLM is default-constructed. The field `has_builtin_services` is never touched:\n\n```cpp\n\u002F\u002F xllm\u002Fserver\u002Fxllm_server.cpp:141-150\nbrpc::ServerOptions options;\noptions.idle_timeout_sec =\n    ::xllm::ServiceConfig::get_instance().rpc_idle_timeout_s();\noptions.num_threads = ::xllm::ServiceConfig::get_instance().num_threads();\noptions.health_reporter = &HealthReporter::instance();\n\u002F\u002F has_builtin_services is never set - brpc defaults it to true\nif (server_->Start(port, &options) != 0) { ... }\n```\n\nThe same pattern at line 269 (the generic `create_server()` used for DisaggPD, Collective, Worker, and XTensorDist servers) and at `mooncake_transfer_engine.cpp:152`. We grep'd for `has_builtin_services` and `internal_port` across the entire codebase: zero hits.\n\nThe consequence is that every brpc port exposes the full builtin suite. `GET \u002Fflags` dumps all 189 xLLM gflags plus brpc's own, including model paths, network addresses, etcd endpoints, and config file locations. `GET \u002Fpprof\u002Fcmdline` reads `\u002Fproc\u002Fself\u002Fcmdline` and returns the complete startup command with all arguments. `GET \u002Fconnections` reveals the internal cluster topology.\n\nAnd because `FLAGS_immutable_flags` is also never set, flags can be *modified* at runtime via `GET \u002Fflags\u002F\u003Cname>?setvalue=\u003Cval>`.\n\n### The singleton firewall\n\nWe investigated whether flag modification could escalate to RCE. The answer is no, and the reason is architecturally interesting. xLLM copies all flag values into singleton config objects at startup:\n\n```cpp\n\u002F\u002F Config initialization pattern (simplified)\n\u002F\u002F At startup:\nModelConfig::get_instance().initialize();  \u002F\u002F reads FLAGS_model, FLAGS_backend, etc.\n\n\u002F\u002F At runtime, all application code reads from the singleton:\nauto path = ModelConfig::get_instance().model_path();  \u002F\u002F not FLAGS_model\n```\n\nAfter initialization, the singletons are immutable. Modifying `FLAGS_model`, `FLAGS_python_model_path`, `FLAGS_enable_xtensor`, or `FLAGS_enable_shm` via `\u002Fflags` has zero effect on running behavior. The flags that *are* read live are brpc's own: setting `FLAGS_max_body_size` to 0 makes brpc reject all subsequent requests (\"message too large\"), a persistent DoS that requires no restart. Setting `FLAGS_minloglevel` to 3 silences all logs.\n\n> **Accidental defense-in-depth.** The singleton config pattern was almost certainly designed for performance (avoid re-reading flags on every request), not security. But it creates an effective firewall between the `\u002Fflags` endpoint and the application's critical state. Many brpc-based services read `FLAGS_` directly, where this same exposure would be catastrophic.\n\n## The Other Branch: Arbitrary File Read\n\nBack to the three-way URL dispatch in `ImageHandler::load()`. The else-branch at line 130 is the interesting one: everything that does not start with `\"data:image\"` or `\"http\"` falls through to `load_from_local()`. Since `\"file\"[0] != \"http\"[0]`, any `file:\u002F\u002F` URL and any bare absolute path reaches this code:\n\n```cpp\n\u002F\u002F xllm\u002Fcore\u002Fframework\u002Fmultimodal\u002Fmm_handler.cpp:82-98\nMMErrCode MMHandlerBase::load_from_local(const std::string& url,\n                                         std::string& data) {\n  std::string path = url;\n  const std::string prefix = \"file:\u002F\u002F\";\n  if (path.compare(0, prefix.size(), prefix) == 0) {\n    path = path.substr(prefix.size());     \u002F\u002F strip \"file:\u002F\u002F\", that's it\n  }\n\n  std::ifstream in(path, std::ios::binary);\n  if (!in) {\n    LOG(ERROR) \u003C\u003C \"failed to open local file: \" \u003C\u003C path;\n    return MMErrCode::LOAD_LOCAL_ERR;\n  }\n\n  data.assign(std::istreambuf_iterator\u003Cchar>(in),\n              std::istreambuf_iterator\u003Cchar>());\n  return MMErrCode::SUCCESS;\n}\n```\n\nNo allowlist. No chroot. No symlink resolution. No `..\u002F` filtering. No file size limit. The `istreambuf_iterator` reads until EOF. The bytes go into `data`, then to `cv::imdecode()`.\n\nWe spent a full investigation round trying to upgrade this to full content exfiltration. The results were definitive: OpenCV's decode pipeline (`imdecode` + `convert_decoded_image_to_rgb`) strips all metadata. EXIF, PNG tEXt chunks, nothing survives. Non-image files produce an empty `cv::Mat` and a generic decode error. The raw bytes are in server memory but never returned to the client.\n\nWhat *does* work:\n\n- **File existence oracle.** Two distinct error messages: `DECODE_ERR` (\"Failed to decode multimodal input\") when the file exists but is not a valid image, `INVALID_URL_ERR` (\"url must be data URL \u002F http(s) URL \u002F local file URL\") when the file does not exist. This allows enumerating the filesystem, including `\u002Fproc\u002Fself\u002Fenviron`, `\u002Fproc\u002Fself\u002Fmaps`, and open file descriptors at `\u002Fproc\u002Fself\u002Ffd\u002F\u003CN>`.\n- **Image exfiltration via VLM.** If the file at the target path happens to be a valid PNG, JPEG, or BMP, it is decoded, passed to the vision-language model, and the model's description is returned to the client. Screenshots, charts, photos anything the server process can read and OpenCV can decode.\n- **OOM crash.** Pointing the URL at `\u002Fdev\u002Fzero` causes the `istreambuf_iterator` to read forever, allocating memory until the kernel OOM-kills the process.\n\n```mermaid\ngraph TD\n  INPUT[\"image_url: file:\u002F\u002F\u002Ftarget\u002Fpath\"] --> STRIP[\"load_from_local()\\nstrip file:\u002F\u002F prefix\"]\n  STRIP --> OPEN[\"std::ifstream(path)\\nno validation\"]\n  OPEN -->|\"file exists\"| READ[\"istreambuf_iterator\\nread entire file\\nno size limit\"]\n  OPEN -->|\"file missing\"| E1[\"LOAD_LOCAL_ERR\"]\n  READ --> DECODE[\"cv::imdecode()\"]\n  DECODE -->|\"valid image\"| VLM[\"VLM processes image\\nmodel describes content\\nreturned to attacker\"]\n  DECODE -->|\"not image\"| E2[\"DECODE_ERR\\ngeneric error\"]\n  OPEN -->|\"\u002Fdev\u002Fzero\"| OOM[\"istreambuf_iterator\\nreads forever\\nOOM kill\"]\n```\n\nNote that only `ImageHandler` has this local-file fallback. `VideoHandler::load()` and `AudioHandler::load()` return `INVALID_URL_ERR` for non-data\u002Fnon-http URLs.\n\n## One Header to Kill Them All\n\nThis one is the smallest finding and the most reliable. xLLM defines a custom HTTP header, `Infer-Content-Length`, used to split a request body into a JSON region and a binary multimodal payload region. The header value is parsed by `get_json_content_length()`:\n\n```cpp\n\u002F\u002F xllm\u002Fapi_service\u002Fapi_service.cpp:301-315\nsize_t get_json_content_length(const brpc::Controller* ctrl) {\n  const auto infer_content_len =\n      ctrl->http_request().GetHeader(kInferContentLength);\n  if (infer_content_len != nullptr) {\n    return std::stoul(*infer_content_len);   \u002F\u002F throws on non-numeric input\n  }\n\n  const auto content_len = ctrl->http_request().GetHeader(kContentLength);\n  if (content_len != nullptr) {\n    return std::stoul(*content_len);         \u002F\u002F same pattern\n  }\n\n  LOG(ERROR) \u003C\u003C \"Content-Length header is missing.\";\n  return (size_t)-1L;\n}\n```\n\n`std::stoul` throws `std::invalid_argument` for non-numeric input and `std::out_of_range` for overflow. There is no `try-catch` here. There is no `try-catch` in any caller. There is no `try-catch` in brpc's service dispatch - service methods run in bthreads, and an uncaught exception in a bthread invokes `std::terminate()`.\n\nThe same pattern repeats at `call.cpp:50-51` in `Call::init_request_payload()`, which runs for every endpoint that processes a request body.\n\nA single HTTP request with `Infer-Content-Length: AAAA` terminates the entire server process. Every in-flight request from every user dies with it. The header is non-standard, so no reverse proxy validates or strips it.\n\n```mermaid\ngraph LR\n  REQ[\"HTTP request with\\nInfer-Content-Length: AAAA\"] --> PARSE[\"get_json_content_length()\\napi_service.cpp:305\"]\n  PARSE --> STOUL[\"std::stoul('AAAA')\"]\n  STOUL --> THROW[\"throws std::invalid_argument\"]\n  THROW --> TERM[\"std::terminate()\\nabort()\"]\n  TERM --> DEAD[\"Server process killed\\nall connections dropped\"]\n```\n\n## What Held Up\n\nA security audit that only reports what broke is incomplete. The surfaces we investigated and found well-defended tell us as much about the system's security posture as the vulnerabilities do.\n\n**Safetensors model loading.** Model weights are parsed by a **Rust crate** (safetensors v0.6.0) compiled as a static library with C FFI bindings. The Rust parser validates JSON header sizes, tensor offset\u002Fsize boundaries, and dtype values before the C++ consumer at `state_dict.cpp` ever sees the data. Memory-mapped files use `MAP_PRIVATE | PROT_READ`. There is no `pickle.loads`, no `torch.load`, no GGUF parser. This is the most secure model weight loading we have seen in any inference engine.\n\n**Jinja chat templates.** The minja C++ Jinja library receives user messages as structured `nlohmann::ordered_json` data at `jinja_chat_template.cpp:76-113`. The template itself is loaded from the model's `tokenizer_config.json` at initialization and is never user-controlled. Jinja's rendering pipeline treats `{{ }}` and `{% %}` in data values as literal text, not template directives. No SSTI.\n\n**Binary multimodal payload parsing.** The `binary,\u003Clength>` data URL format uses `butil::StringToSizeT()` for length parsing (rejects non-numeric, handles overflow) and `MMPayload::get()` bounds-checks against available data. No integer overflow, no OOB read.\n\n**HTTP request smuggling.** xLLM's brpc fork is based on version 1.12.1, past the CVE-2024-23452 fix in 1.8.0. The custom `Infer-Content-Length` header only affects how xLLM reads from brpc's already-consumed body buffer (`request_attachment()`), not the TCP stream. No desync possible.\n\n**Sampling parameter validation.** Temperature is clamped to `[0.0, 2.0]` at `request_params.cpp:597`. Zero temperature is gracefully replaced with 1.0 at `logits_utils.cpp:64`. Top-k is clamped to vocab size. Token lengths are validated by the scheduler against the model's max context length. NaN values via raw protobuf (not HTTP\u002FJSON) can bypass range checks, but the impact is limited to single-request output corruption, not a crash.\n\n**Shared memory IPC.** The `ForwardSharedMemoryManager` creates segments with mode 0666 and predictable names, and its read functions (`read_string`, `read_vector`, `read_tensor`) lack bounds checking. This is technically exploitable by a local attacker. But `enable_shm` defaults to `false`, so the segments are never created in default deployment. The vulnerability is real but conditional on a non-default performance optimization flag.\n\n## The Systemic Pattern\n\nNone of the bugs we found required a novel exploitation technique. SSRF, path traversal, information disclosure, uncaught exception these are textbook classes that web application security has understood for two decades. What is interesting is not the individual bugs but the **architectural assumptions** that produced all four simultaneously.\n\nLLM inference engines occupy a strange middle ground in the software stack. They are built like internal infrastructure no auth, management APIs wide open, debug surfaces enabled, inter-node communication in plaintext but deployed at the trust boundary of a public-facing API. The implicit threat model is \"the network is trusted.\" When that assumption fails, every surface fails at once.\n\nThis is not unique to xLLM. The same pattern produced:\n\n- **CVE-2025-6242** - vLLM SSRF via `MediaConnector.load_from_url()`, the exact same bug class.\n- **CVE-2024-50050** - Meta Llama Stack pickle deserialization over an unauthenticated ZMQ socket.\n- **CVE-2025-30165** - vLLM pickle deserialization over ZMQ, same pattern.\n- **CVE-2025-23254** - NVIDIA TensorRT-LLM, same pattern.\n\nThe LLM serving ecosystem is replaying the early web framework era: ship for throughput, authenticate later. The difference is that the blast radius is larger an inference engine compromise exposes model weights, user prompts, internal network topology, and cloud credentials and the \"later\" keeps not arriving.\n\n> The assumption that \"the network is trusted\" is load-bearing in every design decision. When that assumption fails, everything fails at once. The fix is not to patch individual bugs. The fix is to stop building inference engines like they will only ever run inside a VPN.\n\nOne bright spot: xLLM's Rust-based safetensors parser and its accidental singleton config firewall show that defense-in-depth can emerge even without deliberate security engineering. The lesson is to make it deliberate.\n\n## References\n\n- xLLM source: `github.com\u002FxLLM-AI\u002Fxllm` (Apache 2.0, commit `37413cfe`)\n- vLLM SSRF: [CVE-2025-6242](https:\u002F\u002Fnvd.nist.gov\u002Fvuln\u002Fdetail\u002FCVE-2025-6242)\n- brpc builtin services RCE: [CVE-2025-60021](https:\u002F\u002Fnvd.nist.gov\u002Fvuln\u002Fdetail\u002FCVE-2025-60021)\n- brpc HTTP smuggling: [CVE-2024-23452](https:\u002F\u002Fnvd.nist.gov\u002Fvuln\u002Fdetail\u002FCVE-2024-23452)\n- Meta Llama pickle deser: [CVE-2024-50050](https:\u002F\u002Fnvd.nist.gov\u002Fvuln\u002Fdetail\u002FCVE-2024-50050)\n- Research conducted with Claude Code. Agent fleet parallelized surface enumeration and adversarial cross-verification; human judgment drove threat modeling and severity assessment.\n","\u003Cp>\u003Cem>A deep dive into the architecture and vulnerability classes hiding inside production LLM inference engines, through the lens of xLLM.\u003C\u002Fem>\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Security Research - July 2026\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Cp>LLM inference engines are a strange breed of software. They are built by ML engineers who think in tensors and throughput, deployed on GPU clusters worth millions, and then exposed to the internet through an HTTP API that trusts every byte it receives. The result is an attack surface that feels less like a web application and more like an industrial control system that someone accidentally port-forwarded.\u003C\u002Fp>\n\u003Cp>xLLM is a high-performance, open-source LLM inference engine written in C++ on top of Apache brpc. It supports the full OpenAI and Anthropic API surface, handles multimodal inputs (vision, audio, video), and runs distributed multi-GPU inference with disaggregated prefill-decode scheduling. It powers deployment of Qwen, DeepSeek, LLaMA, and dozens of other model families. It was recently donated to the OpenAtom Foundation.\u003C\u002Fp>\n\u003Cp>We spent three rounds auditing its codebase. What we found says less about xLLM specifically and more about a systemic pattern across the entire LLM serving ecosystem.\u003C\u002Fp>\n\u003Ch2>The Architecture, Through an Attacker&#39;s Eyes\u003C\u002Fh2>\n\u003Cp>Before looking at individual bugs, it helps to understand \u003Cem>why\u003C\u002Fem> the attack surface of an inference engine looks the way it does. Three architectural properties shape everything that follows.\u003C\u002Fp>\n\u003Ch3>Property 1: Multiple network listeners, zero authentication\u003C\u002Fh3>\n\u003Cp>xLLM is not a single-port web server. In a production distributed deployment, a single node can bind \u003Cstrong>seven or more brpc server ports\u003C\u002Fstrong>:\u003C\u002Fp>\n\u003Cfigure class=\"mermaid-figure\">\u003Cspan class=\"reticle-corner reticle-corner--tl\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--tr\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--bl\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--br\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cfigcaption class=\"mermaid-figure__bar\">\u003Cspan class=\"mermaid-figure__label\">\u003Csvg viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"3\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Crect x=\"3\" y=\"16\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Crect x=\"15\" y=\"16\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Cpath d=\"M12 8v4M6 12h12M6 12v4M18 12v4\"\u002F>\u003C\u002Fsvg>Diagram\u003C\u002Fspan>\u003Cspan class=\"mermaid-figure__meta\">losec:\u002F\u002Fschematic\u003C\u002Fspan>\u003C\u002Ffigcaption>\u003Cpre class=\"mermaid\" role=\"img\" aria-label=\"Diagram\">graph TB\n  subgraph &quot;Single xLLM Node&quot;\n    API[&quot;Main API\\nport 8010\\nHTTP + brpc&quot;]\n    PD[&quot;DisaggPD\\nport 7777\\nprefill-decode RPC&quot;]\n    W1[&quot;Worker 0\\nauto-assigned\\nmodel execution&quot;]\n    W2[&quot;Worker 1\\nauto-assigned\\nmodel execution&quot;]\n    MC[&quot;MooncakeTransfer\\nport 26000\\nKV cache shipping&quot;]\n    XT[&quot;XTensorDist\\ndynamic\\npage pool ops&quot;]\n    CO[&quot;Collective\\ndynamic\\nNCCL sync&quot;]\n  end\n  EXT[&quot;External Client&quot;] --&gt; API\n  NODE2[&quot;Other xLLM Nodes&quot;] --&gt; PD\n  NODE2 --&gt; MC\n  NODE2 --&gt; XT\n\u003C\u002Fpre>\u003C\u002Ffigure>\u003Cp>Every one of these ports speaks brpc&#39;s HTTP and protobuf-over-TCP protocols. We grepped the entire \u003Ccode>xllm\u002F\u003C\u002Fcode> tree for \u003Ccode>auth\u003C\u002Fcode>, \u003Ccode>token\u003C\u002Fcode>, \u003Ccode>api_key\u003C\u002Fcode>, \u003Ccode>bearer\u003C\u002Fcode>, \u003Ccode>Authorization\u003C\u002Fcode>, \u003Ccode>credential\u003C\u002Fcode>, \u003Ccode>password\u003C\u002Fcode>, \u003Ccode>allowlist\u003C\u002Fcode>, \u003Ccode>blocklist\u003C\u002Fcode>. Zero results. We checked every \u003Ccode>brpc::ServerOptions\u003C\u002Fcode> instance for TLS or auth configuration. Nothing. We checked the protobuf message definitions for auth-related fields. None.\u003C\u002Fp>\n\u003Cp>The sole gating mechanism we found was \u003Ccode>enable_online_profile\u003C\u002Fcode> (a feature flag for the CPU profiler, default off, not an auth check) and a \u003Ccode>RateLimiter\u003C\u002Fcode> that caps concurrent requests at 200 without distinguishing users.\u003C\u002Fp>\n\u003Cp>This means the management endpoints - \u003Ccode>fork_master\u003C\u002Fcode> (load a model from any filesystem path), \u003Ccode>sleep\u003C\u002Fcode>\u002F\u003Ccode>wakeup\u003C\u002Fcode> (unload\u002Freload models), \u003Ccode>pause\u003C\u002Fcode> (abort all in-flight requests), \u003Ccode>link_p2p\u003C\u002Fcode> (establish connections to arbitrary addresses) are accessible to anyone who can reach the port.\u003C\u002Fp>\n\u003Ch3>Property 2: The multimodal surface is a proxy\u003C\u002Fh3>\n\u003Cp>When serving vision-language models, xLLM&#39;s API accepts URLs in \u003Ccode>image_url\u003C\u002Fcode>, \u003Ccode>video_url\u003C\u002Fcode>, and \u003Ccode>audio_url\u003C\u002Fcode> fields. The server fetches these URLs server-side to download the media content before passing it to the model. This turns the inference engine into an HTTP proxy that makes outbound requests on behalf of the client a classic SSRF surface.\u003C\u002Fp>\n\u003Cp>What makes this surface particularly interesting in LLM engines is the \u003Cstrong>multimodal decode pipeline\u003C\u002Fstrong>. The fetched bytes go through format-specific decoders (OpenCV for images, FFmpeg for video\u002Faudio). The decoder output becomes a tensor that feeds the model. The model&#39;s text output is returned to the client. This creates an indirect data channel: if the attacker can control what the model sees, they can influence what the model says.\u003C\u002Fp>\n\u003Ch3>Property 3: brpc&#39;s builtin services are opt-out\u003C\u002Fh3>\n\u003Cp>Apache brpc is a high-performance RPC framework used widely in Chinese tech infrastructure (Baidu, JD, ByteDance). One of its features is a built-in debug console: \u003Ccode>\u002Fflags\u003C\u002Fcode> (view\u002Fmodify all gflags), \u003Ccode>\u002Fvars\u003C\u002Fcode> (internal metrics), \u003Ccode>\u002Fconnections\u003C\u002Fcode> (active connections), \u003Ccode>\u002Fpprof\u002F*\u003C\u002Fcode> (CPU\u002Fheap profiling), \u003Ccode>\u002Fstatus\u003C\u002Fcode>. These are enabled by default on every server port unless the developer explicitly sets \u003Ccode>has_builtin_services = false\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>This is the brpc equivalent of leaving Django&#39;s \u003Ccode>DEBUG=True\u003C\u002Fcode> in production except it exposes runtime flags that can be \u003Cem>modified\u003C\u002Fem>, not just read.\u003C\u002Fp>\n\u003Ch2>Mapping the Attack Surface\u003C\u002Fh2>\n\u003Cp>With the architecture understood, here is how we decomposed the surface. Each category represents a different trust boundary the engine fails to enforce.\u003C\u002Fp>\n\u003Cfigure class=\"mermaid-figure\">\u003Cspan class=\"reticle-corner reticle-corner--tl\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--tr\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--bl\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--br\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cfigcaption class=\"mermaid-figure__bar\">\u003Cspan class=\"mermaid-figure__label\">\u003Csvg viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"3\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Crect x=\"3\" y=\"16\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Crect x=\"15\" y=\"16\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Cpath d=\"M12 8v4M6 12h12M6 12v4M18 12v4\"\u002F>\u003C\u002Fsvg>Diagram\u003C\u002Fspan>\u003Cspan class=\"mermaid-figure__meta\">losec:\u002F\u002Fschematic\u003C\u002Fspan>\u003C\u002Ffigcaption>\u003Cpre class=\"mermaid\" role=\"img\" aria-label=\"Diagram\">graph LR\n  subgraph &quot;Attack Surface Categories&quot;\n    S1[&quot;1. Inference API\\nuser prompts, tokens,\\nsampling params&quot;]\n    S2[&quot;2. Multimodal Fetch\\nURLs, headers,\\nfile:\u002F\u002F paths&quot;]\n    S3[&quot;3. Management Plane\\nfork_master, sleep,\\npause, link_p2p&quot;]\n    S4[&quot;4. brpc Builtins\\n\u002Fflags, \u002Fpprof,\\n\u002Fconnections&quot;]\n    S5[&quot;5. Inter-node RPC\\nworker comms,\\nKV transfer, sync&quot;]\n    S6[&quot;6. Local IPC\\nshared memory,\\n0666 perms&quot;]\n  end\n  S1 --&gt; SINK1[&quot;Scheduler +\\nModel Execution&quot;]\n  S2 --&gt; SINK2[&quot;SSRF \u002F LFI&quot;]\n  S3 --&gt; SINK3[&quot;Lifecycle Control&quot;]\n  S4 --&gt; SINK4[&quot;Config Leak +\\nFlag Modification&quot;]\n  S5 --&gt; SINK5[&quot;Data Injection&quot;]\n  S6 --&gt; SINK6[&quot;OOB Read\u002FWrite&quot;]\n\u003C\u002Fpre>\u003C\u002Ffigure>\u003Cp>Categories 1 and 5-6 turned out to be well-defended or require non-default configurations. Categories 2-4 produced four confirmed findings. The interesting story is in both: what broke and what held.\u003C\u002Fp>\n\u003Ch2>The Multimodal Proxy: Blind SSRF with Header Forwarding\u003C\u002Fh2>\n\u003Cp>The first thing we traced was the multimodal URL fetch path. In \u003Ccode>mm_handler.cpp\u003C\u002Fcode>, the \u003Ccode>ImageHandler::load()\u003C\u002Fcode> method dispatches on URL prefix:\u003C\u002Fp>\n\u003Cdiv class=\"code-block\">\u003Cdiv class=\"code-block__bar\">\u003Cspan class=\"code-block__lang\">cpp\u003C\u002Fspan>\u003Cbutton class=\"code-block__copy\" type=\"button\" data-copy aria-label=\"Copy code\">\u003Csvg class=\"code-block__copy-icon\" viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"\u002F>\u003Cpath d=\"M5 15V5a2 2 0 0 1 2-2h10\"\u002F>\u003C\u002Fsvg>\u003Cspan class=\"code-block__copy-label\">Copy\u003C\u002Fspan>\u003C\u002Fbutton>\u003C\u002Fdiv>\u003Cdiv class=\"code-block__body\">\u003Cspan class=\"code-block__gutter\" aria-hidden=\"true\">1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\u003C\u002Fspan>\u003Cpre>\u003Ccode class=\"hljs language-cpp\">\u003Cspan class=\"hljs-comment\">\u002F\u002F xllm\u002Fcore\u002Fframework\u002Fmultimodal\u002Fmm_handler.cpp:112-138\u003C\u002Fspan>\n\u003Cspan class=\"hljs-function\">MMErrCode \u003Cspan class=\"hljs-title\">ImageHandler::load\u003C\u002Fspan>\u003Cspan class=\"hljs-params\">(\u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> MMContent&amp; content,\n                             MMInputItem&amp; input,\n                             MMPayload&amp; payload)\u003C\u002Fspan> \u003C\u002Fspan>{\n  \u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">auto\u003C\u002Fspan>&amp; url = content.image_url.url;\n\n  \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (url.\u003Cspan class=\"hljs-built_in\">compare\u003C\u002Fspan>(\u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>, dataurl_prefix_.\u003Cspan class=\"hljs-built_in\">size\u003C\u002Fspan>(), dataurl_prefix_) == \u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>) {\n    \u003Cspan class=\"hljs-comment\">\u002F\u002F &quot;data:image\u002F...&quot; - base64 encoded, handled locally\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">this\u003C\u002Fspan>-&gt;\u003Cspan class=\"hljs-built_in\">load_from_dataurl\u003C\u002Fspan>(url, input.raw_data, payload);\n\n  } \u003Cspan class=\"hljs-keyword\">else\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (url.\u003Cspan class=\"hljs-built_in\">compare\u003C\u002Fspan>(\u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>, httpurl_prefix_.\u003Cspan class=\"hljs-built_in\">size\u003C\u002Fspan>(), httpurl_prefix_) == \u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>) {\n    \u003Cspan class=\"hljs-comment\">\u002F\u002F &quot;http:\u002F\u002F...&quot; or &quot;https:\u002F\u002F...&quot; - server fetches the URL\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">this\u003C\u002Fspan>-&gt;\u003Cspan class=\"hljs-built_in\">load_from_http\u003C\u002Fspan>(url, input.raw_data,\n                                content.image_url.headers);  \u003Cspan class=\"hljs-comment\">\u002F\u002F &lt;---\u003C\u002Fspan>\n\n  } \u003Cspan class=\"hljs-keyword\">else\u003C\u002Fspan> {\n    \u003Cspan class=\"hljs-comment\">\u002F\u002F everything else: treat as local file path\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (\u003Cspan class=\"hljs-keyword\">this\u003C\u002Fspan>-&gt;\u003Cspan class=\"hljs-built_in\">load_from_local\u003C\u002Fspan>(url, input.raw_data) == MMErrCode::SUCCESS) {\n      \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> MMErrCode::SUCCESS;\n    }\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> MMErrCode::INVALID_URL_ERR;\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cp>The \u003Ccode>httpurl_prefix_\u003C\u002Fcode> is the 4-character string \u003Ccode>&quot;http&quot;\u003C\u002Fcode>. Any URL starting with \u003Ccode>http\u003C\u002Fcode> both \u003Ccode>http:\u002F\u002F\u003C\u002Fcode> and \u003Ccode>https:\u002F\u002F\u003C\u002Fcode> enters the server-side fetch path. The URL flows through \u003Ccode>load_from_http()\u003C\u002Fcode> into \u003Ccode>BRpcDownloader::download()\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cdiv class=\"code-block\">\u003Cdiv class=\"code-block__bar\">\u003Cspan class=\"code-block__lang\">cpp\u003C\u002Fspan>\u003Cbutton class=\"code-block__copy\" type=\"button\" data-copy aria-label=\"Copy code\">\u003Csvg class=\"code-block__copy-icon\" viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"\u002F>\u003Cpath d=\"M5 15V5a2 2 0 0 1 2-2h10\"\u002F>\u003C\u002Fsvg>\u003Cspan class=\"code-block__copy-label\">Copy\u003C\u002Fspan>\u003C\u002Fbutton>\u003C\u002Fdiv>\u003Cdiv class=\"code-block__body\">\u003Cspan class=\"code-block__gutter\" aria-hidden=\"true\">1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\u003C\u002Fspan>\u003Cpre>\u003Ccode class=\"hljs language-cpp\">\u003Cspan class=\"hljs-comment\">\u002F\u002F xllm\u002Fcore\u002Futil\u002Fhttp_downloader.cpp:109-144\u003C\u002Fspan>\n\u003Cspan class=\"hljs-function\">\u003Cspan class=\"hljs-type\">bool\u003C\u002Fspan> \u003Cspan class=\"hljs-title\">BRpcDownloader::download\u003C\u002Fspan>\u003Cspan class=\"hljs-params\">(\n    \u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> std::string&amp; host, \u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> std::string&amp; url,\n    std::string&amp; data,\n    \u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> std::unordered_map&lt;std::string, std::string&gt;&amp; headers)\u003C\u002Fspan> \u003C\u002Fspan>{\n\n  brpc::Controller cntl;\n  cntl.\u003Cspan class=\"hljs-built_in\">http_request\u003C\u002Fspan>().\u003Cspan class=\"hljs-built_in\">uri\u003C\u002Fspan>() = url;          \u003Cspan class=\"hljs-comment\">\u002F\u002F attacker URL, verbatim\u003C\u002Fspan>\n\n  \u003Cspan class=\"hljs-keyword\">for\u003C\u002Fspan> (\u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">auto\u003C\u002Fspan>&amp; [k, v] : \u003Cspan class=\"hljs-built_in\">parse_global_headers\u003C\u002Fspan>()) {\n    cntl.\u003Cspan class=\"hljs-built_in\">http_request\u003C\u002Fspan>().\u003Cspan class=\"hljs-built_in\">SetHeader\u003C\u002Fspan>(k, v);    \u003Cspan class=\"hljs-comment\">\u002F\u002F global defaults\u003C\u002Fspan>\n  }\n  \u003Cspan class=\"hljs-keyword\">for\u003C\u002Fspan> (\u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">auto\u003C\u002Fspan>&amp; [k, v] : headers) {\n    cntl.\u003Cspan class=\"hljs-built_in\">http_request\u003C\u002Fspan>().\u003Cspan class=\"hljs-built_in\">SetHeader\u003C\u002Fspan>(k, v);    \u003Cspan class=\"hljs-comment\">\u002F\u002F per-request: attacker-controlled\u003C\u002Fspan>\n  }\n\n  cntl.\u003Cspan class=\"hljs-built_in\">set_timeout_ms\u003C\u002Fspan>(\u003Cspan class=\"hljs-number\">2000\u003C\u002Fspan>);\n  \u003Cspan class=\"hljs-keyword\">auto\u003C\u002Fspan> channel = \u003Cspan class=\"hljs-built_in\">get_channel\u003C\u002Fspan>(host);\n  channel-&gt;\u003Cspan class=\"hljs-built_in\">CallMethod\u003C\u002Fspan>(\u003Cspan class=\"hljs-literal\">nullptr\u003C\u002Fspan>, &amp;cntl, \u003Cspan class=\"hljs-literal\">nullptr\u003C\u002Fspan>, \u003Cspan class=\"hljs-literal\">nullptr\u003C\u002Fspan>, \u003Cspan class=\"hljs-literal\">nullptr\u003C\u002Fspan>);\n  \u003Cspan class=\"hljs-comment\">\u002F\u002F ...\u003C\u002Fspan>\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cp>The \u003Ccode>parse_url()\u003C\u002Fcode> function (line 60) that runs before this does one thing: it splits the URL at \u003Ccode>:\u002F\u002F\u003C\u002Fcode> and the next \u003Ccode>\u002F\u003C\u002Fcode> to extract the host. No validation of the host. No check against private IP ranges. No scheme restriction. No redirect following limit. The URL goes straight to \u003Ccode>brpc::Channel::Init(host)\u003C\u002Fcode> and then \u003Ccode>CallMethod\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>The critical detail is the \u003Ccode>headers\u003C\u002Fcode> parameter. It comes from the protobuf definition at \u003Ccode>multimodal.proto:28\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cdiv class=\"code-block\">\u003Cdiv class=\"code-block__bar\">\u003Cspan class=\"code-block__lang\">protobuf\u003C\u002Fspan>\u003Cbutton class=\"code-block__copy\" type=\"button\" data-copy aria-label=\"Copy code\">\u003Csvg class=\"code-block__copy-icon\" viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"\u002F>\u003Cpath d=\"M5 15V5a2 2 0 0 1 2-2h10\"\u002F>\u003C\u002Fsvg>\u003Cspan class=\"code-block__copy-label\">Copy\u003C\u002Fspan>\u003C\u002Fbutton>\u003C\u002Fdiv>\u003Cdiv class=\"code-block__body\">\u003Cspan class=\"code-block__gutter\" aria-hidden=\"true\">1\n2\n3\n4\n5\u003C\u002Fspan>\u003Cpre>\u003Ccode class=\"hljs language-protobuf\">\u002F\u002F xllm\u002Fproto\u002Fmultimodal.proto\nmessage ImageURL {\n  string url = 1;\n  map&lt;string, string&gt; headers = 2;   \u002F\u002F attacker-controlled key-value pairs\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cp>These headers are extracted at \u003Ccode>mm_service_utils.h:50-51\u003C\u002Fcode> and forwarded verbatim to the outbound request. An attacker can include \u003Ccode>Authorization\u003C\u002Fcode>, \u003Ccode>Cookie\u003C\u002Fcode>, or any other header, turning the blind SSRF into an authenticated proxy for internal services.\u003C\u002Fp>\n\u003Cfigure class=\"mermaid-figure\">\u003Cspan class=\"reticle-corner reticle-corner--tl\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--tr\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--bl\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--br\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cfigcaption class=\"mermaid-figure__bar\">\u003Cspan class=\"mermaid-figure__label\">\u003Csvg viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"3\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Crect x=\"3\" y=\"16\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Crect x=\"15\" y=\"16\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Cpath d=\"M12 8v4M6 12h12M6 12v4M18 12v4\"\u002F>\u003C\u002Fsvg>Diagram\u003C\u002Fspan>\u003Cspan class=\"mermaid-figure__meta\">losec:\u002F\u002Fschematic\u003C\u002Fspan>\u003C\u002Ffigcaption>\u003Cpre class=\"mermaid\" role=\"img\" aria-label=\"Diagram\">graph LR\n  REQ[&quot;POST \u002Fv1\u002Fchat\u002Fcompletions\\n{image_url: {url, headers}}&quot;] --&gt; PARSE[&quot;api_service.cpp:421\\nJsonToProtoMessage&quot;]\n  PARSE --&gt; EXTRACT[&quot;mm_service_utils.h:47\\nextract URL + headers map&quot;]\n  EXTRACT --&gt; DISPATCH[&quot;mm_handler.cpp:125\\nprefix == http ?&quot;]\n  DISPATCH --&gt; HTTP[&quot;load_from_http()&quot;]\n  HTTP --&gt; DL[&quot;http_downloader.cpp:115\\ncntl.uri() = url\\nSetHeader(k, v) per header&quot;]\n  DL --&gt; OUT[&quot;Outbound HTTP\\nto attacker-specified host\\nwith attacker-specified headers&quot;]\n\u003C\u002Fpre>\u003C\u002Ffigure>\u003Cp>The SSRF is \u003Cstrong>blind\u003C\u002Fstrong> for non-image responses. Cloud metadata endpoints return JSON, which fails OpenCV&#39;s \u003Ccode>imdecode()\u003C\u002Fcode>, and the client sees only &quot;Failed to decode multimodal input.&quot; But &quot;blind&quot; here still means the attacker can distinguish four states via timing and error differentiation: connection refused (~1ms), connection timeout (2000ms), HTTP 200 with non-image data (\u003Ccode>DECODE_ERR\u003C\u002Fcode>), and HTTP 200 with valid image (the VLM processes it). This enables reliable port scanning and service fingerprinting of the internal network.\u003C\u002Fp>\n\u003Cp>This is the exact same bug class as \u003Ca href=\"https:\u002F\u002Fnvd.nist.gov\u002Fvuln\u002Fdetail\u002FCVE-2025-6242\">CVE-2025-6242 in vLLM\u003C\u002Fa>. The pattern is systemic: every LLM engine that fetches multimodal URLs server-side without validation inherits this surface.\u003C\u002Fp>\n\u003Ch2>The Debug Console Nobody Turned Off\u003C\u002Fh2>\n\u003Cp>Every \u003Ccode>brpc::ServerOptions\u003C\u002Fcode> in xLLM is default-constructed. The field \u003Ccode>has_builtin_services\u003C\u002Fcode> is never touched:\u003C\u002Fp>\n\u003Cdiv class=\"code-block\">\u003Cdiv class=\"code-block__bar\">\u003Cspan class=\"code-block__lang\">cpp\u003C\u002Fspan>\u003Cbutton class=\"code-block__copy\" type=\"button\" data-copy aria-label=\"Copy code\">\u003Csvg class=\"code-block__copy-icon\" viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"\u002F>\u003Cpath d=\"M5 15V5a2 2 0 0 1 2-2h10\"\u002F>\u003C\u002Fsvg>\u003Cspan class=\"code-block__copy-label\">Copy\u003C\u002Fspan>\u003C\u002Fbutton>\u003C\u002Fdiv>\u003Cdiv class=\"code-block__body\">\u003Cspan class=\"code-block__gutter\" aria-hidden=\"true\">1\n2\n3\n4\n5\n6\n7\n8\u003C\u002Fspan>\u003Cpre>\u003Ccode class=\"hljs language-cpp\">\u003Cspan class=\"hljs-comment\">\u002F\u002F xllm\u002Fserver\u002Fxllm_server.cpp:141-150\u003C\u002Fspan>\nbrpc::ServerOptions options;\noptions.idle_timeout_sec =\n    ::xllm::ServiceConfig::\u003Cspan class=\"hljs-built_in\">get_instance\u003C\u002Fspan>().\u003Cspan class=\"hljs-built_in\">rpc_idle_timeout_s\u003C\u002Fspan>();\noptions.num_threads = ::xllm::ServiceConfig::\u003Cspan class=\"hljs-built_in\">get_instance\u003C\u002Fspan>().\u003Cspan class=\"hljs-built_in\">num_threads\u003C\u002Fspan>();\noptions.health_reporter = &amp;HealthReporter::\u003Cspan class=\"hljs-built_in\">instance\u003C\u002Fspan>();\n\u003Cspan class=\"hljs-comment\">\u002F\u002F has_builtin_services is never set - brpc defaults it to true\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (server_-&gt;\u003Cspan class=\"hljs-built_in\">Start\u003C\u002Fspan>(port, &amp;options) != \u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>) { ... }\n\u003C\u002Fcode>\u003C\u002Fpre>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cp>The same pattern at line 269 (the generic \u003Ccode>create_server()\u003C\u002Fcode> used for DisaggPD, Collective, Worker, and XTensorDist servers) and at \u003Ccode>mooncake_transfer_engine.cpp:152\u003C\u002Fcode>. We grep&#39;d for \u003Ccode>has_builtin_services\u003C\u002Fcode> and \u003Ccode>internal_port\u003C\u002Fcode> across the entire codebase: zero hits.\u003C\u002Fp>\n\u003Cp>The consequence is that every brpc port exposes the full builtin suite. \u003Ccode>GET \u002Fflags\u003C\u002Fcode> dumps all 189 xLLM gflags plus brpc&#39;s own, including model paths, network addresses, etcd endpoints, and config file locations. \u003Ccode>GET \u002Fpprof\u002Fcmdline\u003C\u002Fcode> reads \u003Ccode>\u002Fproc\u002Fself\u002Fcmdline\u003C\u002Fcode> and returns the complete startup command with all arguments. \u003Ccode>GET \u002Fconnections\u003C\u002Fcode> reveals the internal cluster topology.\u003C\u002Fp>\n\u003Cp>And because \u003Ccode>FLAGS_immutable_flags\u003C\u002Fcode> is also never set, flags can be \u003Cem>modified\u003C\u002Fem> at runtime via \u003Ccode>GET \u002Fflags\u002F&lt;name&gt;?setvalue=&lt;val&gt;\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch3>The singleton firewall\u003C\u002Fh3>\n\u003Cp>We investigated whether flag modification could escalate to RCE. The answer is no, and the reason is architecturally interesting. xLLM copies all flag values into singleton config objects at startup:\u003C\u002Fp>\n\u003Cdiv class=\"code-block\">\u003Cdiv class=\"code-block__bar\">\u003Cspan class=\"code-block__lang\">cpp\u003C\u002Fspan>\u003Cbutton class=\"code-block__copy\" type=\"button\" data-copy aria-label=\"Copy code\">\u003Csvg class=\"code-block__copy-icon\" viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"\u002F>\u003Cpath d=\"M5 15V5a2 2 0 0 1 2-2h10\"\u002F>\u003C\u002Fsvg>\u003Cspan class=\"code-block__copy-label\">Copy\u003C\u002Fspan>\u003C\u002Fbutton>\u003C\u002Fdiv>\u003Cdiv class=\"code-block__body\">\u003Cspan class=\"code-block__gutter\" aria-hidden=\"true\">1\n2\n3\n4\n5\n6\u003C\u002Fspan>\u003Cpre>\u003Ccode class=\"hljs language-cpp\">\u003Cspan class=\"hljs-comment\">\u002F\u002F Config initialization pattern (simplified)\u003C\u002Fspan>\n\u003Cspan class=\"hljs-comment\">\u002F\u002F At startup:\u003C\u002Fspan>\nModelConfig::\u003Cspan class=\"hljs-built_in\">get_instance\u003C\u002Fspan>().\u003Cspan class=\"hljs-built_in\">initialize\u003C\u002Fspan>();  \u003Cspan class=\"hljs-comment\">\u002F\u002F reads FLAGS_model, FLAGS_backend, etc.\u003C\u002Fspan>\n\n\u003Cspan class=\"hljs-comment\">\u002F\u002F At runtime, all application code reads from the singleton:\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">auto\u003C\u002Fspan> path = ModelConfig::\u003Cspan class=\"hljs-built_in\">get_instance\u003C\u002Fspan>().\u003Cspan class=\"hljs-built_in\">model_path\u003C\u002Fspan>();  \u003Cspan class=\"hljs-comment\">\u002F\u002F not FLAGS_model\u003C\u002Fspan>\n\u003C\u002Fcode>\u003C\u002Fpre>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cp>After initialization, the singletons are immutable. Modifying \u003Ccode>FLAGS_model\u003C\u002Fcode>, \u003Ccode>FLAGS_python_model_path\u003C\u002Fcode>, \u003Ccode>FLAGS_enable_xtensor\u003C\u002Fcode>, or \u003Ccode>FLAGS_enable_shm\u003C\u002Fcode> via \u003Ccode>\u002Fflags\u003C\u002Fcode> has zero effect on running behavior. The flags that \u003Cem>are\u003C\u002Fem> read live are brpc&#39;s own: setting \u003Ccode>FLAGS_max_body_size\u003C\u002Fcode> to 0 makes brpc reject all subsequent requests (&quot;message too large&quot;), a persistent DoS that requires no restart. Setting \u003Ccode>FLAGS_minloglevel\u003C\u002Fcode> to 3 silences all logs.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Accidental defense-in-depth.\u003C\u002Fstrong> The singleton config pattern was almost certainly designed for performance (avoid re-reading flags on every request), not security. But it creates an effective firewall between the \u003Ccode>\u002Fflags\u003C\u002Fcode> endpoint and the application&#39;s critical state. Many brpc-based services read \u003Ccode>FLAGS_\u003C\u002Fcode> directly, where this same exposure would be catastrophic.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>The Other Branch: Arbitrary File Read\u003C\u002Fh2>\n\u003Cp>Back to the three-way URL dispatch in \u003Ccode>ImageHandler::load()\u003C\u002Fcode>. The else-branch at line 130 is the interesting one: everything that does not start with \u003Ccode>&quot;data:image&quot;\u003C\u002Fcode> or \u003Ccode>&quot;http&quot;\u003C\u002Fcode> falls through to \u003Ccode>load_from_local()\u003C\u002Fcode>. Since \u003Ccode>&quot;file&quot;[0] != &quot;http&quot;[0]\u003C\u002Fcode>, any \u003Ccode>file:\u002F\u002F\u003C\u002Fcode> URL and any bare absolute path reaches this code:\u003C\u002Fp>\n\u003Cdiv class=\"code-block\">\u003Cdiv class=\"code-block__bar\">\u003Cspan class=\"code-block__lang\">cpp\u003C\u002Fspan>\u003Cbutton class=\"code-block__copy\" type=\"button\" data-copy aria-label=\"Copy code\">\u003Csvg class=\"code-block__copy-icon\" viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"\u002F>\u003Cpath d=\"M5 15V5a2 2 0 0 1 2-2h10\"\u002F>\u003C\u002Fsvg>\u003Cspan class=\"code-block__copy-label\">Copy\u003C\u002Fspan>\u003C\u002Fbutton>\u003C\u002Fdiv>\u003Cdiv class=\"code-block__body\">\u003Cspan class=\"code-block__gutter\" aria-hidden=\"true\">1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\u003C\u002Fspan>\u003Cpre>\u003Ccode class=\"hljs language-cpp\">\u003Cspan class=\"hljs-comment\">\u002F\u002F xllm\u002Fcore\u002Fframework\u002Fmultimodal\u002Fmm_handler.cpp:82-98\u003C\u002Fspan>\n\u003Cspan class=\"hljs-function\">MMErrCode \u003Cspan class=\"hljs-title\">MMHandlerBase::load_from_local\u003C\u002Fspan>\u003Cspan class=\"hljs-params\">(\u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> std::string&amp; url,\n                                         std::string&amp; data)\u003C\u002Fspan> \u003C\u002Fspan>{\n  std::string path = url;\n  \u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> std::string prefix = \u003Cspan class=\"hljs-string\">&quot;file:\u002F\u002F&quot;\u003C\u002Fspan>;\n  \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (path.\u003Cspan class=\"hljs-built_in\">compare\u003C\u002Fspan>(\u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>, prefix.\u003Cspan class=\"hljs-built_in\">size\u003C\u002Fspan>(), prefix) == \u003Cspan class=\"hljs-number\">0\u003C\u002Fspan>) {\n    path = path.\u003Cspan class=\"hljs-built_in\">substr\u003C\u002Fspan>(prefix.\u003Cspan class=\"hljs-built_in\">size\u003C\u002Fspan>());     \u003Cspan class=\"hljs-comment\">\u002F\u002F strip &quot;file:\u002F\u002F&quot;, that&#x27;s it\u003C\u002Fspan>\n  }\n\n  \u003Cspan class=\"hljs-function\">std::ifstream \u003Cspan class=\"hljs-title\">in\u003C\u002Fspan>\u003Cspan class=\"hljs-params\">(path, std::ios::binary)\u003C\u002Fspan>\u003C\u002Fspan>;\n  \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (!in) {\n    \u003Cspan class=\"hljs-built_in\">LOG\u003C\u002Fspan>(ERROR) &lt;&lt; \u003Cspan class=\"hljs-string\">&quot;failed to open local file: &quot;\u003C\u002Fspan> &lt;&lt; path;\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> MMErrCode::LOAD_LOCAL_ERR;\n  }\n\n  data.\u003Cspan class=\"hljs-built_in\">assign\u003C\u002Fspan>(std::\u003Cspan class=\"hljs-built_in\">istreambuf_iterator\u003C\u002Fspan>&lt;\u003Cspan class=\"hljs-type\">char\u003C\u002Fspan>&gt;(in),\n              std::\u003Cspan class=\"hljs-built_in\">istreambuf_iterator\u003C\u002Fspan>&lt;\u003Cspan class=\"hljs-type\">char\u003C\u002Fspan>&gt;());\n  \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> MMErrCode::SUCCESS;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cp>No allowlist. No chroot. No symlink resolution. No \u003Ccode>..\u002F\u003C\u002Fcode> filtering. No file size limit. The \u003Ccode>istreambuf_iterator\u003C\u002Fcode> reads until EOF. The bytes go into \u003Ccode>data\u003C\u002Fcode>, then to \u003Ccode>cv::imdecode()\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>We spent a full investigation round trying to upgrade this to full content exfiltration. The results were definitive: OpenCV&#39;s decode pipeline (\u003Ccode>imdecode\u003C\u002Fcode> + \u003Ccode>convert_decoded_image_to_rgb\u003C\u002Fcode>) strips all metadata. EXIF, PNG tEXt chunks, nothing survives. Non-image files produce an empty \u003Ccode>cv::Mat\u003C\u002Fcode> and a generic decode error. The raw bytes are in server memory but never returned to the client.\u003C\u002Fp>\n\u003Cp>What \u003Cem>does\u003C\u002Fem> work:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>File existence oracle.\u003C\u002Fstrong> Two distinct error messages: \u003Ccode>DECODE_ERR\u003C\u002Fcode> (&quot;Failed to decode multimodal input&quot;) when the file exists but is not a valid image, \u003Ccode>INVALID_URL_ERR\u003C\u002Fcode> (&quot;url must be data URL \u002F http(s) URL \u002F local file URL&quot;) when the file does not exist. This allows enumerating the filesystem, including \u003Ccode>\u002Fproc\u002Fself\u002Fenviron\u003C\u002Fcode>, \u003Ccode>\u002Fproc\u002Fself\u002Fmaps\u003C\u002Fcode>, and open file descriptors at \u003Ccode>\u002Fproc\u002Fself\u002Ffd\u002F&lt;N&gt;\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Image exfiltration via VLM.\u003C\u002Fstrong> If the file at the target path happens to be a valid PNG, JPEG, or BMP, it is decoded, passed to the vision-language model, and the model&#39;s description is returned to the client. Screenshots, charts, photos anything the server process can read and OpenCV can decode.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>OOM crash.\u003C\u002Fstrong> Pointing the URL at \u003Ccode>\u002Fdev\u002Fzero\u003C\u002Fcode> causes the \u003Ccode>istreambuf_iterator\u003C\u002Fcode> to read forever, allocating memory until the kernel OOM-kills the process.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cfigure class=\"mermaid-figure\">\u003Cspan class=\"reticle-corner reticle-corner--tl\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--tr\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--bl\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--br\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cfigcaption class=\"mermaid-figure__bar\">\u003Cspan class=\"mermaid-figure__label\">\u003Csvg viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"3\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Crect x=\"3\" y=\"16\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Crect x=\"15\" y=\"16\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Cpath d=\"M12 8v4M6 12h12M6 12v4M18 12v4\"\u002F>\u003C\u002Fsvg>Diagram\u003C\u002Fspan>\u003Cspan class=\"mermaid-figure__meta\">losec:\u002F\u002Fschematic\u003C\u002Fspan>\u003C\u002Ffigcaption>\u003Cpre class=\"mermaid\" role=\"img\" aria-label=\"Diagram\">graph TD\n  INPUT[&quot;image_url: file:\u002F\u002F\u002Ftarget\u002Fpath&quot;] --&gt; STRIP[&quot;load_from_local()\\nstrip file:\u002F\u002F prefix&quot;]\n  STRIP --&gt; OPEN[&quot;std::ifstream(path)\\nno validation&quot;]\n  OPEN --&gt;|&quot;file exists&quot;| READ[&quot;istreambuf_iterator\\nread entire file\\nno size limit&quot;]\n  OPEN --&gt;|&quot;file missing&quot;| E1[&quot;LOAD_LOCAL_ERR&quot;]\n  READ --&gt; DECODE[&quot;cv::imdecode()&quot;]\n  DECODE --&gt;|&quot;valid image&quot;| VLM[&quot;VLM processes image\\nmodel describes content\\nreturned to attacker&quot;]\n  DECODE --&gt;|&quot;not image&quot;| E2[&quot;DECODE_ERR\\ngeneric error&quot;]\n  OPEN --&gt;|&quot;\u002Fdev\u002Fzero&quot;| OOM[&quot;istreambuf_iterator\\nreads forever\\nOOM kill&quot;]\n\u003C\u002Fpre>\u003C\u002Ffigure>\u003Cp>Note that only \u003Ccode>ImageHandler\u003C\u002Fcode> has this local-file fallback. \u003Ccode>VideoHandler::load()\u003C\u002Fcode> and \u003Ccode>AudioHandler::load()\u003C\u002Fcode> return \u003Ccode>INVALID_URL_ERR\u003C\u002Fcode> for non-data\u002Fnon-http URLs.\u003C\u002Fp>\n\u003Ch2>One Header to Kill Them All\u003C\u002Fh2>\n\u003Cp>This one is the smallest finding and the most reliable. xLLM defines a custom HTTP header, \u003Ccode>Infer-Content-Length\u003C\u002Fcode>, used to split a request body into a JSON region and a binary multimodal payload region. The header value is parsed by \u003Ccode>get_json_content_length()\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cdiv class=\"code-block\">\u003Cdiv class=\"code-block__bar\">\u003Cspan class=\"code-block__lang\">cpp\u003C\u002Fspan>\u003Cbutton class=\"code-block__copy\" type=\"button\" data-copy aria-label=\"Copy code\">\u003Csvg class=\"code-block__copy-icon\" viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"\u002F>\u003Cpath d=\"M5 15V5a2 2 0 0 1 2-2h10\"\u002F>\u003C\u002Fsvg>\u003Cspan class=\"code-block__copy-label\">Copy\u003C\u002Fspan>\u003C\u002Fbutton>\u003C\u002Fdiv>\u003Cdiv class=\"code-block__body\">\u003Cspan class=\"code-block__gutter\" aria-hidden=\"true\">1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\u003C\u002Fspan>\u003Cpre>\u003Ccode class=\"hljs language-cpp\">\u003Cspan class=\"hljs-comment\">\u002F\u002F xllm\u002Fapi_service\u002Fapi_service.cpp:301-315\u003C\u002Fspan>\n\u003Cspan class=\"hljs-function\">\u003Cspan class=\"hljs-type\">size_t\u003C\u002Fspan> \u003Cspan class=\"hljs-title\">get_json_content_length\u003C\u002Fspan>\u003Cspan class=\"hljs-params\">(\u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> brpc::Controller* ctrl)\u003C\u002Fspan> \u003C\u002Fspan>{\n  \u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">auto\u003C\u002Fspan> infer_content_len =\n      ctrl-&gt;\u003Cspan class=\"hljs-built_in\">http_request\u003C\u002Fspan>().\u003Cspan class=\"hljs-built_in\">GetHeader\u003C\u002Fspan>(kInferContentLength);\n  \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (infer_content_len != \u003Cspan class=\"hljs-literal\">nullptr\u003C\u002Fspan>) {\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> std::\u003Cspan class=\"hljs-built_in\">stoul\u003C\u002Fspan>(*infer_content_len);   \u003Cspan class=\"hljs-comment\">\u002F\u002F throws on non-numeric input\u003C\u002Fspan>\n  }\n\n  \u003Cspan class=\"hljs-type\">const\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">auto\u003C\u002Fspan> content_len = ctrl-&gt;\u003Cspan class=\"hljs-built_in\">http_request\u003C\u002Fspan>().\u003Cspan class=\"hljs-built_in\">GetHeader\u003C\u002Fspan>(kContentLength);\n  \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (content_len != \u003Cspan class=\"hljs-literal\">nullptr\u003C\u002Fspan>) {\n    \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> std::\u003Cspan class=\"hljs-built_in\">stoul\u003C\u002Fspan>(*content_len);         \u003Cspan class=\"hljs-comment\">\u002F\u002F same pattern\u003C\u002Fspan>\n  }\n\n  \u003Cspan class=\"hljs-built_in\">LOG\u003C\u002Fspan>(ERROR) &lt;&lt; \u003Cspan class=\"hljs-string\">&quot;Content-Length header is missing.&quot;\u003C\u002Fspan>;\n  \u003Cspan class=\"hljs-keyword\">return\u003C\u002Fspan> (\u003Cspan class=\"hljs-type\">size_t\u003C\u002Fspan>)\u003Cspan class=\"hljs-number\">-1L\u003C\u002Fspan>;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cp>\u003Ccode>std::stoul\u003C\u002Fcode> throws \u003Ccode>std::invalid_argument\u003C\u002Fcode> for non-numeric input and \u003Ccode>std::out_of_range\u003C\u002Fcode> for overflow. There is no \u003Ccode>try-catch\u003C\u002Fcode> here. There is no \u003Ccode>try-catch\u003C\u002Fcode> in any caller. There is no \u003Ccode>try-catch\u003C\u002Fcode> in brpc&#39;s service dispatch - service methods run in bthreads, and an uncaught exception in a bthread invokes \u003Ccode>std::terminate()\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>The same pattern repeats at \u003Ccode>call.cpp:50-51\u003C\u002Fcode> in \u003Ccode>Call::init_request_payload()\u003C\u002Fcode>, which runs for every endpoint that processes a request body.\u003C\u002Fp>\n\u003Cp>A single HTTP request with \u003Ccode>Infer-Content-Length: AAAA\u003C\u002Fcode> terminates the entire server process. Every in-flight request from every user dies with it. The header is non-standard, so no reverse proxy validates or strips it.\u003C\u002Fp>\n\u003Cfigure class=\"mermaid-figure\">\u003Cspan class=\"reticle-corner reticle-corner--tl\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--tr\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--bl\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cspan class=\"reticle-corner reticle-corner--br\" aria-hidden=\"true\">\u003C\u002Fspan>\u003Cfigcaption class=\"mermaid-figure__bar\">\u003Cspan class=\"mermaid-figure__label\">\u003Csvg viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\u003Crect x=\"9\" y=\"3\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Crect x=\"3\" y=\"16\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Crect x=\"15\" y=\"16\" width=\"6\" height=\"5\" rx=\"1\"\u002F>\u003Cpath d=\"M12 8v4M6 12h12M6 12v4M18 12v4\"\u002F>\u003C\u002Fsvg>Diagram\u003C\u002Fspan>\u003Cspan class=\"mermaid-figure__meta\">losec:\u002F\u002Fschematic\u003C\u002Fspan>\u003C\u002Ffigcaption>\u003Cpre class=\"mermaid\" role=\"img\" aria-label=\"Diagram\">graph LR\n  REQ[&quot;HTTP request with\\nInfer-Content-Length: AAAA&quot;] --&gt; PARSE[&quot;get_json_content_length()\\napi_service.cpp:305&quot;]\n  PARSE --&gt; STOUL[&quot;std::stoul(&#x27;AAAA&#x27;)&quot;]\n  STOUL --&gt; THROW[&quot;throws std::invalid_argument&quot;]\n  THROW --&gt; TERM[&quot;std::terminate()\\nabort()&quot;]\n  TERM --&gt; DEAD[&quot;Server process killed\\nall connections dropped&quot;]\n\u003C\u002Fpre>\u003C\u002Ffigure>\u003Ch2>What Held Up\u003C\u002Fh2>\n\u003Cp>A security audit that only reports what broke is incomplete. The surfaces we investigated and found well-defended tell us as much about the system&#39;s security posture as the vulnerabilities do.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Safetensors model loading.\u003C\u002Fstrong> Model weights are parsed by a \u003Cstrong>Rust crate\u003C\u002Fstrong> (safetensors v0.6.0) compiled as a static library with C FFI bindings. The Rust parser validates JSON header sizes, tensor offset\u002Fsize boundaries, and dtype values before the C++ consumer at \u003Ccode>state_dict.cpp\u003C\u002Fcode> ever sees the data. Memory-mapped files use \u003Ccode>MAP_PRIVATE | PROT_READ\u003C\u002Fcode>. There is no \u003Ccode>pickle.loads\u003C\u002Fcode>, no \u003Ccode>torch.load\u003C\u002Fcode>, no GGUF parser. This is the most secure model weight loading we have seen in any inference engine.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Jinja chat templates.\u003C\u002Fstrong> The minja C++ Jinja library receives user messages as structured \u003Ccode>nlohmann::ordered_json\u003C\u002Fcode> data at \u003Ccode>jinja_chat_template.cpp:76-113\u003C\u002Fcode>. The template itself is loaded from the model&#39;s \u003Ccode>tokenizer_config.json\u003C\u002Fcode> at initialization and is never user-controlled. Jinja&#39;s rendering pipeline treats \u003Ccode>{{ }}\u003C\u002Fcode> and \u003Ccode>{% %}\u003C\u002Fcode> in data values as literal text, not template directives. No SSTI.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Binary multimodal payload parsing.\u003C\u002Fstrong> The \u003Ccode>binary,&lt;length&gt;\u003C\u002Fcode> data URL format uses \u003Ccode>butil::StringToSizeT()\u003C\u002Fcode> for length parsing (rejects non-numeric, handles overflow) and \u003Ccode>MMPayload::get()\u003C\u002Fcode> bounds-checks against available data. No integer overflow, no OOB read.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>HTTP request smuggling.\u003C\u002Fstrong> xLLM&#39;s brpc fork is based on version 1.12.1, past the CVE-2024-23452 fix in 1.8.0. The custom \u003Ccode>Infer-Content-Length\u003C\u002Fcode> header only affects how xLLM reads from brpc&#39;s already-consumed body buffer (\u003Ccode>request_attachment()\u003C\u002Fcode>), not the TCP stream. No desync possible.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Sampling parameter validation.\u003C\u002Fstrong> Temperature is clamped to \u003Ccode>[0.0, 2.0]\u003C\u002Fcode> at \u003Ccode>request_params.cpp:597\u003C\u002Fcode>. Zero temperature is gracefully replaced with 1.0 at \u003Ccode>logits_utils.cpp:64\u003C\u002Fcode>. Top-k is clamped to vocab size. Token lengths are validated by the scheduler against the model&#39;s max context length. NaN values via raw protobuf (not HTTP\u002FJSON) can bypass range checks, but the impact is limited to single-request output corruption, not a crash.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Shared memory IPC.\u003C\u002Fstrong> The \u003Ccode>ForwardSharedMemoryManager\u003C\u002Fcode> creates segments with mode 0666 and predictable names, and its read functions (\u003Ccode>read_string\u003C\u002Fcode>, \u003Ccode>read_vector\u003C\u002Fcode>, \u003Ccode>read_tensor\u003C\u002Fcode>) lack bounds checking. This is technically exploitable by a local attacker. But \u003Ccode>enable_shm\u003C\u002Fcode> defaults to \u003Ccode>false\u003C\u002Fcode>, so the segments are never created in default deployment. The vulnerability is real but conditional on a non-default performance optimization flag.\u003C\u002Fp>\n\u003Ch2>The Systemic Pattern\u003C\u002Fh2>\n\u003Cp>None of the bugs we found required a novel exploitation technique. SSRF, path traversal, information disclosure, uncaught exception these are textbook classes that web application security has understood for two decades. What is interesting is not the individual bugs but the \u003Cstrong>architectural assumptions\u003C\u002Fstrong> that produced all four simultaneously.\u003C\u002Fp>\n\u003Cp>LLM inference engines occupy a strange middle ground in the software stack. They are built like internal infrastructure no auth, management APIs wide open, debug surfaces enabled, inter-node communication in plaintext but deployed at the trust boundary of a public-facing API. The implicit threat model is &quot;the network is trusted.&quot; When that assumption fails, every surface fails at once.\u003C\u002Fp>\n\u003Cp>This is not unique to xLLM. The same pattern produced:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>CVE-2025-6242\u003C\u002Fstrong> - vLLM SSRF via \u003Ccode>MediaConnector.load_from_url()\u003C\u002Fcode>, the exact same bug class.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>CVE-2024-50050\u003C\u002Fstrong> - Meta Llama Stack pickle deserialization over an unauthenticated ZMQ socket.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>CVE-2025-30165\u003C\u002Fstrong> - vLLM pickle deserialization over ZMQ, same pattern.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>CVE-2025-23254\u003C\u002Fstrong> - NVIDIA TensorRT-LLM, same pattern.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>The LLM serving ecosystem is replaying the early web framework era: ship for throughput, authenticate later. The difference is that the blast radius is larger an inference engine compromise exposes model weights, user prompts, internal network topology, and cloud credentials and the &quot;later&quot; keeps not arriving.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>The assumption that &quot;the network is trusted&quot; is load-bearing in every design decision. When that assumption fails, everything fails at once. The fix is not to patch individual bugs. The fix is to stop building inference engines like they will only ever run inside a VPN.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cp>One bright spot: xLLM&#39;s Rust-based safetensors parser and its accidental singleton config firewall show that defense-in-depth can emerge even without deliberate security engineering. The lesson is to make it deliberate.\u003C\u002Fp>\n\u003Ch2>References\u003C\u002Fh2>\n\u003Cul>\n\u003Cli>xLLM source: \u003Ccode>github.com\u002FxLLM-AI\u002Fxllm\u003C\u002Fcode> (Apache 2.0, commit \u003Ccode>37413cfe\u003C\u002Fcode>)\u003C\u002Fli>\n\u003Cli>vLLM SSRF: \u003Ca href=\"https:\u002F\u002Fnvd.nist.gov\u002Fvuln\u002Fdetail\u002FCVE-2025-6242\">CVE-2025-6242\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>brpc builtin services RCE: \u003Ca href=\"https:\u002F\u002Fnvd.nist.gov\u002Fvuln\u002Fdetail\u002FCVE-2025-60021\">CVE-2025-60021\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>brpc HTTP smuggling: \u003Ca href=\"https:\u002F\u002Fnvd.nist.gov\u002Fvuln\u002Fdetail\u002FCVE-2024-23452\">CVE-2024-23452\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>Meta Llama pickle deser: \u003Ca href=\"https:\u002F\u002Fnvd.nist.gov\u002Fvuln\u002Fdetail\u002FCVE-2024-50050\">CVE-2024-50050\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>Research conducted with Claude Code. Agent fleet parallelized surface enumeration and adversarial cross-verification; human judgment drove threat modeling and severity assessment.\u003C\u002Fli>\n\u003C\u002Ful>\n",[19,21,33],{"title":4,"slug":5,"excerpt":6,"tags":20,"category":11,"date":12,"author":13,"readTime":14,"thumbnail":15},[8,9,10],{"title":22,"slug":23,"excerpt":24,"tags":25,"category":29,"date":12,"author":30,"readTime":31,"thumbnail":32},"The Night I Decided to Kill Frida","the-night-i-decided-to-kill-frida","Frida is a dynamic instrumentation toolkit for developers, reverse-engineers, and security researchers. This post chronicles the night I decided to stop using Frida and the reasons behind it.",[26,27,28],"frida","hooking","cafe","Mobile Reverse Engineering","@hibana","3 min read","https:\u002F\u002Fraw.githubusercontent.com\u002FLosec-io\u002Fblogs\u002Fmain\u002Fdecided-to-kill-frida\u002Fimage-02.png",{"title":34,"slug":35,"excerpt":36,"tags":37,"category":40,"date":41,"author":42,"readTime":43,"thumbnail":44},"The Final Days of Huntr OSS: One Night, Three Reports, and $1,625 in Bounties","the-final-days-of-huntr-oss","Before Huntr shut down its traditional open-source bug bounty program and shifted toward Huntr 2.0, I returned to the platform one last time, discovered three vulnerabilities in NLTK in less than twelve hours alongside GPT-5.5 Ultra, and ultimately received $1,625 in bounties.",[38,39,10],"huntr","disclosure","Bug Bounty","2026-07-16","@khanhdlq","5 min read","https:\u002F\u002Fraw.githubusercontent.com\u002FLosec-io\u002Fblogs\u002Fmain\u002Fthe-final-days-of-huntr-oss\u002Fimage-10.png",1784281938238]