Safeguard
Industry Analysis

Code Injection via eval() and exec() Across Languages

eval() and exec() turn dynamic code execution into remote code execution. A cross-language look at how it happens in Python, JS, PHP, and Ruby.

Aman Khan
AppSec Engineer
7 min read

In March 2019, security researchers disclosed that Ansible's Jinja2 templating engine could be coerced into calling Python's eval() on attacker-controlled strings, earning the bug CVE-2019-8341 and a clean path to remote code execution on automation servers. It is a pattern that repeats every year, in every popular language: a developer reaches for eval(), exec(), Function(), or an equivalent "run this string as code" primitive to save time, and an attacker later supplies the string. PyYAML's yaml.load() shipped this exact flaw for years (CVE-2017-18342, CVE-2020-1747), vm2's JavaScript sandbox was broken by it three times in 2023 alone, and WordPress plugins still ship PHP eval() backdoors disguised as "template caching." Code injection via eval/exec is not a legacy problem — it is a design pattern that keeps getting rediscovered, and it deserves a plain explanation of how it works and how to stop shipping it.

What is eval/exec code injection, and why does it keep happening?

Eval/exec code injection happens when a program passes attacker-influenced input into a function that compiles and runs strings as live code, and it keeps happening because that capability is one line away in nearly every mainstream language. Python has eval() and exec(), JavaScript has eval() and new Function(), PHP has eval() and assert() (pre-8.0), Ruby has eval, instance_eval, and send, and Perl has eval in string form. Each exists for legitimate reasons — dynamic configuration, templating, plugin systems, REPLs, math expression parsers — but each collapses the boundary between "data" and "code." Once an attacker's input reaches the interpreter, they are no longer supplying a value; they are supplying instructions, and the same code path that trusted developer strings will just as happily trust __import__('os').system('id'). Static type systems, linters, and even code review routinely miss this because the vulnerable line looks identical to a hundred safe ones — the danger is entirely in where the string originated, which requires tracing data flow, not just reading syntax.

Which languages are most exposed, and what does the vulnerable code look like?

Python and JavaScript top the exposure list because their eval-equivalents are common in frameworks that process templates, configs, and user expressions. A typical Python case is a Flask route that builds a "calculator" feature with eval(request.args.get('expr')) — trivially exploitable with expr=__import__('os').popen('whoami').read(). Django and Flask debug consoles have shipped similar issues, including the Werkzeug debugger RCE (the same class of console-based eval bugs) that let an unauthenticated attacker execute code through an exposed debug pin. In Node.js, JSON.parse is safe, but developers who reach for eval(jsonString) instead — still common in older tutorials and hand-rolled config loaders — hand attackers arbitrary JavaScript execution in the server process. Sandboxing libraries do not fully solve this: vm2, downloaded millions of times per week to isolate untrusted JS, had three separate sandbox-escape CVEs in 2023 (CVE-2023-32314, CVE-2023-37903, CVE-2023-37466) that let crafted input break out of the sandbox and run code with the host's privileges. PHP's eval() shows up constantly in compromised WordPress and Joomla plugins as a one-line webshell (eval($_POST['c'])), and Ruby's instance_eval/send combination has produced mass-assignment-adjacent RCE bugs in Rails apps that pass user parameters into metaprogramming helpers.

What do real CVEs and breaches tell us about the blast radius?

Real incidents show that eval/exec injection routinely escalates from a single parameter to full host or supply-chain compromise, not just a crashed process. The Ansible/Jinja2 case (CVE-2019-8341) allowed template injection to reach Python's eval() and execute arbitrary code on any control node running the vulnerable filter — a single automation server that often has SSH keys and cloud credentials to an entire fleet. PyYAML's default loader (CVE-2017-18342) let any application calling yaml.load() on untrusted YAML — a shockingly common pattern in config files, CI pipelines, and Kubernetes tooling — execute arbitrary Python objects, effectively an eval-by-deserialization bug that persisted across releases until full_load/SafeLoader were made the default in PyYAML 5.1. The vm2 sandbox escapes in 2023 mattered specifically because vm2 was embedded in CI runners, bots, and low-code platforms marketed as "safely" executing user-submitted JavaScript; breaking the sandbox meant breaking the trust boundary the entire product was built on. And npm's serialize-javascript package shipped an eval-reachable flaw (CVE-2020-7660) in a utility used to embed JS in HTML, showing how deep into the dependency tree an eval sink can hide — teams that never call eval() directly can still ship it transitively through a templating or serialization library.

How is eval/exec injection different from SQL or command injection?

Eval/exec injection is broader than SQL or OS command injection because the attacker gets the full expressiveness of a general-purpose programming language instead of a constrained query grammar. SQL injection lets an attacker manipulate a database query; OS command injection lets them chain shell commands. Eval/exec injection lets them import modules, spawn subprocesses, read environment variables, open sockets, and manipulate the running process's memory and objects directly — in Python, a single eval() call can reach os, subprocess, socket, and ctypes with no shell involved at all, which means shell-metacharacter denylists (a common, weak command-injection mitigation) do nothing here. It also blends into legitimate application logic more easily: OWASP groups both under the same "Injection" category (A03:2021 in the OWASP Top 10), but scanners tuned for ' OR 1=1-- patterns typically have no signature for eval(user_input) sitting quietly inside a feature-flag evaluator or a math-expression parser. That mismatch is why eval/exec bugs disproportionately survive traditional DAST scanning and show up instead in manual pentests or, worse, in production incident reports.

How can teams find eval/exec sinks before they ship?

Teams find eval/exec sinks reliably only by tracing data flow from every untrusted input source to every dangerous sink, because grepping for eval( alone produces too many false positives (test fixtures, sandboxed math parsers) and misses wrapped or aliased calls (getattr(builtins, 'eval')(x), window['eval'], call_user_func('eval', ...)). Effective detection combines static taint analysis that flags HTTP parameters, deserialized objects, environment variables, and file contents reaching eval/exec/Function/assert/instance_eval sinks, dependency scanning that catches vulnerable versions of libraries like PyYAML (versions before 5.1), vm2 (versions before 3.9.17), and serialize-javascript (versions before 3.1.0), and code review checklists that require justifying every dynamic-execution call with a comment explaining why the input is trusted. Runtime allowlisting also helps: Python's eval() and exec() both accept restricted globals/locals dictionaries, and disabling __builtins__ inside them closes off the most common escape (though determined attackers have historically found gadget chains around naive sandboxes, which is exactly what happened to vm2). The most durable fix, where feasible, is architectural — replace eval()-based expression evaluation with a proper parser (Python's ast.literal_eval() for literals, a dedicated expression-grammar library like asteval or simpleeval for math, JSON.parse() instead of eval() for JSON) so the sink simply cannot execute arbitrary code no matter what reaches it.

How Safeguard Helps

Safeguard is built to catch exactly this class of bug — dangerous data flow into dynamic-execution sinks — before it reaches a merge, not after it reaches production. Our SAST engine ships taint-tracking rules for eval, exec, Function, assert, instance_eval, and their language-specific equivalents across Python, JavaScript/TypeScript, PHP, and Ruby, tracing untrusted sources (HTTP request data, deserialized payloads, environment variables, file reads) all the way to the sink so findings come with the actual path, not just a line number. Because so many eval/exec bugs arrive transitively — as PyYAML, vm2, or serialize-javascript did — Safeguard's software composition analysis cross-references your dependency tree against known-vulnerable versions and flags them alongside the direct-code findings in one report, so a review doesn't miss a sink hiding two layers deep in node_modules. For teams running CI/CD, Safeguard integrates as a pre-merge gate: pull requests that introduce a new eval/exec sink reachable from untrusted input get flagged with severity and remediation guidance (swap to ast.literal_eval, JSON.parse, or a sandboxed expression library) before a reviewer has to catch it by eye. And because SOC 2 auditors increasingly ask for evidence of secure-SDLC controls around injection classes, Safeguard's scan history gives you a durable, exportable record that this exact vulnerability class is checked on every change — not just discovered after an incident. If your codebase touches templating, config parsing, plugin systems, or "safe" JS sandboxes, running a Safeguard scan is the fastest way to find out whether an eval/exec sink is already sitting in your dependency tree or your own code, waiting for the right input.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.