Ir al contenido
wannacode

Inyecciones: cuando los datos se vuelven una orden

Problema

Una miniatura con sorpresa

Los compradores suben fotos con sus reseñas. La herramienta de la tienda hace una miniatura de cada una: lanza el programa de imágenes (por ejemplo magick, de ImageMagick) con el nombre del archivo.

Aquí, en lugar del conversor de verdad, está echo. Solo imprime sus argumentos, así que la salida muestra exactamente lo que llegó al programa:

using System.Diagnostics;
void MakeThumbnail(string fileName)
{
string command = "echo resize " + fileName;
Run(new ProcessStartInfo("sh", ["-c", command]));
}
void Run(ProcessStartInfo info)
{
info.RedirectStandardOutput = true;
using var process = Process.Start(info)!;
Console.Write(process.StandardOutput.ReadToEnd());
process.WaitForExit();
}
MakeThumbnail("shoes.jpg");
MakeThumbnail("shoes.jpg; echo injected");
Salida
resize shoes.jpg
resize shoes.jpg
injected

El primer nombre es normal. El segundo es shoes.jpg; echo injected, y la salida tiene tres líneas, no dos. injected lo imprimió una orden que el comprador escribió en el nombre del archivo. Aquí es un inofensivo echo, pero en su lugar podría estar cualquier orden, y se ejecuta con los permisos del servidor de la tienda.

La idea

Texto que acaba en un lenguaje

Una orden para el shell es un lenguaje. En él, ; significa «fin de la orden, sigue otra». El código pegó el nombre del comprador en ese lenguaje, y los caracteres del nombre cambiaron el significado de la orden. Eso es una inyección de órdenes (command injection).

Ya has visto lo mismo con SQL. Cuando un programa pega ' OR '1'='1 en una consulta, la comilla cierra el texto antes de tiempo y la condición se vuelve verdadera para cada fila. El arreglo fue un parámetro: la consulta y el valor van a la base de datos por separado. Esa es la lección «Bases de datos» del curso del lenguaje y «Inyección SQL y parámetros» del curso de SQL, así que aquí no la repetimos.

Donde un texto acaba en un lenguaje, hay caracteres con significado:

Dónde acaba el texto Caracteres con significado Cómo sigue siendo datos
una consulta SQL ', --, ; un parámetro
una orden para el shell ;, &&, $( ), un espacio una lista de argumentos, sin shell
una ruta de archivo .., /, \, una ruta desde la raíz una ruta canónica y una comprobación, o un número de una lista
una página HTML <, >, ", & escape (la próxima lección)

La idea general: no dejes que los datos pasen a ser parte de la orden. Dáselos al sistema por separado, de una forma que el sistema nunca lea como una orden.

Míralo

Cómo el nombre se vuelve una orden

Los dos casos de la lección, antes y después del arreglo. El código junto a la escena cambia con el paso:

void MakeThumbnail(string fileName)
{
string command = "echo resize " + fileName;
Run(new ProcessStartInfo("sh", ["-c", command]));
}
Paso a paso, en palabras
  1. Un comprador sube una foto llamada «shoes.jpg; echo injected». La herramienta de la tienda hace con ella una miniatura.
  2. El código pega el nombre en una sola cadena para el shell. El nombre ya no es datos: es parte de una orden.
  3. Para el shell, «;» termina una orden. La cadena se vuelve dos órdenes, y la segunda la escribió el comprador. Aquí solo imprime «injected»; podría ser cualquier orden.
  4. La corrección: sin shell. El programa recibe una lista de argumentos, y el nombre entero es un argumento. En él, «;» es un carácter normal.
  5. La descarga de facturas une el nombre de la dirección a la carpeta invoices/. El nombre es «../settings.txt», y «..» significa «una carpeta hacia arriba».
  6. La ruta sale de invoices/ y se lee settings.txt, que está al lado. El nombre eligió otro archivo.
  7. La corrección: primero calcula dónde termina de verdad la ruta (la ruta canónica) y luego comprueba que sigue dentro de invoices/. settings.txt no lo está: rechazado.

Pruébalo

Encuentra la línea

La tienda envía un recibo: genera un PDF y lo manda al correo del comprador. ¿Qué línea le pasa texto del comprador al shell?

Elige la línea con el error.

La idea

Una lista de argumentos en lugar de un texto

El arreglo no es «limpiar» el nombre. Es que no haya un shell que lo lea. El programa se lanza con una lista de argumentos: el primero es el programa, y cada uno de los siguientes le llega entero, sean cuales sean los caracteres que contenga.

using System.Diagnostics;
void MakeThumbnail(string fileName)
{
var info = new ProcessStartInfo("echo");
info.ArgumentList.Add("resize");
info.ArgumentList.Add(fileName);
Run(info);
}
void Run(ProcessStartInfo info)
{
info.RedirectStandardOutput = true;
using var process = Process.Start(info)!;
Console.Write(process.StandardOutput.ReadToEnd());
process.WaitForExit();
}
MakeThumbnail("shoes.jpg");
MakeThumbnail("shoes.jpg; echo injected");
Salida
resize shoes.jpg
resize shoes.jpg; echo injected

El segundo nombre llegó al conversor como un solo argumento, con el ; dentro. No hay una segunda orden.

ProcessStartInfo con ArgumentList lanza el programa directamente, sin shell. .NET pasa cada elemento de la lista como un argumento aparte y pone él mismo las comillas que hacen falta.
Más precisamente, para curiosos
  • Lo mejor es no lanzar ningún programa externo. Para las imágenes hay bibliotecas: ImageSharp o SkiaSharp. Entonces no hay ninguna orden en la que pueda acabar el texto.
  • Conserva tus propios nombres. Guarda el archivo subido con un nombre que el programa inventa (un número o un UUID), y deja el nombre del comprador como datos en la base de datos. Así, a la orden y a la ruta solo llega tu nombre.
  • Un argumento que empieza por -. Sin shell, ; es inofensivo, pero el programa aún puede leer un nombre como -x como una de sus opciones. Por eso muchos programas aceptan -- («fin de las opciones») antes de los nombres de archivo, o pones ./ delante del nombre.
  • ProcessStartInfo("sh", "-c \"…\"") con un texto en Arguments es el mismo problema: .NET parte el texto en argumentos, y sh -c lee después todo el texto como una orden. En Windows, cmd.exe /c es el equivalente.

Problema

Una factura que no es una factura

Un comprador descarga una factura de /invoices/1041.txt. El servidor toma el nombre de la dirección y lo pega a la carpeta de facturas:

string Download(string name)
{
string file = Path.Combine(invoices, name);
return File.ReadAllText(file);
}
Console.WriteLine(Download("1041.txt"));
Console.WriteLine(Download("../settings.txt"));
Salida
Factura 1041: 59 €
payment_api_key=demo-not-real

El segundo nombre es ../settings.txt. En una ruta, .. significa «una carpeta más arriba». La ruta salió de invoices/, y el servidor leyó settings.txt, que está al lado, con la clave de los pagos.

Esto es un path traversal (recorrido de rutas): el nombre de la petición elige un archivo fuera de la carpeta que el código tenía en mente. El programa de aquí crea su propia carpeta temporal y lee solo dentro de ella; en un servidor real, .. puede subir muchos niveles.

La idea

Una ruta canónica y después una comprobación

El arreglo tiene dos pasos:

  1. Calcula dónde termina de verdad la ruta. Esa es la ruta canónica: completa, sin . ni ...
  2. Comprueba que la ruta canónica sigue dentro de la carpeta de facturas. Si no, rechaza.
string Download(string name)
{
string file = Path.GetFullPath(name, invoices);
if (!file.StartsWith(invoices, StringComparison.Ordinal))
{
return "Rechazado: fuera de la carpeta de facturas";
}
return File.ReadAllText(file);
}
Console.WriteLine(Download("1041.txt"));
Console.WriteLine(Download("../settings.txt"));
Salida
Factura 1041: 59 €
Rechazado: fuera de la carpeta de facturas
Path.GetFullPath(name, invoices) calcula la ruta completa respecto a la carpeta y quita . y ... La comprobación usa StringComparison.Ordinal (carácter por carácter), e invoices termina en /: sin eso, también pasaría una carpeta invoices-old.

La comprobación no busca caracteres peligrosos. Pregunta dónde termina la ruta, así que no depende de lo que se te haya ocurrido.

Todavía más seguro es que el nombre no sea una ruta en absoluto. La dirección lleva un número de factura, y el servidor busca el archivo en su propia tabla: un número que no está allí se rechaza. Ese es el ejercicio de abajo.

Más precisamente, para curiosos
  • Enlaces simbólicos. GetFullPath trabaja solo con el texto de la ruta. Si en la carpeta puede haber un enlace simbólico hacia fuera, comprueba además FileInfo.ResolveLinkTarget(true).
  • Una ruta desde la raíz. Path.Combine(invoices, "/etc/hosts") devuelve /etc/hosts: la ruta absoluta sustituye a la carpeta. La comprobación lo detecta.
  • Archivos comprimidos. Los nombres de los archivos dentro de un zip también son texto que viene de fuera. Al descomprimir hay que hacer la misma comprobación para cada archivo (la trampa tiene nombre: zip slip).
  • La preparación. El programa crea una carpeta temporal, escribe en ella las dos facturas y el archivo de configuración, y al final la borra. Así empieza:
string shop = Directory.CreateTempSubdirectory("shop").FullName;
// La carpeta de facturas, con "/" al final: la comprobación lo necesita.
string invoices = Path.Join(shop, "invoices") + Path.DirectorySeparatorChar;
Directory.CreateDirectory(invoices);
File.WriteAllText(Path.Join(invoices, "1041.txt"), "Factura 1041: 59 €");
File.WriteAllText(Path.Join(invoices, "1042.txt"), "Factura 1042: 118 €");
File.WriteAllText(Path.Join(shop, "settings.txt"), "payment_api_key=demo-not-real");

Pruébalo

La ruta canónica

Completa la línea que calcula dónde termina la ruta:

string Download(string name)
{
    string file = Path.(name, invoices);
    if (!file.StartsWith(invoices, StringComparison.Ordinal))
    {
        return "Rechazado: fuera de la carpeta de facturas";
    }
    return File.ReadAllText(file);
}

Pruébalo

Tres nombres

¿Qué imprime la descarga corregida para estos tres nombres?

Console.WriteLine(Download("1042.txt"));
Console.WriteLine(Download("../invoices/1042.txt"));
Console.WriteLine(Download("../settings.txt"));
Respuesta

Ejercicio

La factura por número

Resuelve la tarea en tu editor.

Por número, no por nombre. Escribe DownloadById(id). La dirección lleva solo un número de factura. Una tabla en el código dice qué número es cada archivo (1041 → 1041.txt, 1042 → 1042.txt). Un número que no está en la tabla se rechaza, y el nombre del archivo nunca viene de la petición. Pruébalo con 1041, 1042, ../settings.txt y 1043:

Esperado
Factura 1041: 59 €
Factura 1042: 118 €
Rechazado: no existe esa factura
Rechazado: no existe esa factura

Más tareas

  • La factura de otro. El número 1042 está en la tabla, pero la factura es de otro comprador. ¿Qué más tiene que comprobar el servidor antes de devolverla?
  • El nombre de la foto. Cambia la herramienta de miniaturas para que guarde el archivo subido con un nombre que el programa inventa (el número de la reseña y .jpg), y conserve el nombre del comprador solo como texto.

Test

Ponte a prueba

1. ¿Qué tienen en común la inyección SQL, la inyección de órdenes y el recorrido de rutas?
2. La herramienta de miniaturas lanza el conversor con una lista de argumentos, y el nombre de la foto es el último argumento. ¿Qué pasa con un «;» en el nombre?
3. La descarga corregida calcula la ruta canónica y comprueba que está dentro de invoices/. ¿Qué devuelve para «../invoices/1042.txt»?
4. ¿Por qué quitar «..» y «;» de la entrada no es el arreglo?
5. Un comprador sube una foto. ¿Cuál es la forma más segura de guardarla en el disco?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·