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");resize shoes.jpgresize shoes.jpginjectedEl 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]));}void MakeThumbnail(string fileName){ string command = "echo resize " + fileName; Run(new ProcessStartInfo("sh", ["-c", command]));}void MakeThumbnail(string fileName){ var info = new ProcessStartInfo("echo"); info.ArgumentList.Add("resize"); info.ArgumentList.Add(fileName); Run(info);}string Download(string name){ string file = Path.Combine(invoices, name); return File.ReadAllText(file);}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);}Paso a paso, en palabras
- Un comprador sube una foto llamada «shoes.jpg; echo injected». La herramienta de la tienda hace con ella una miniatura.
- El código pega el nombre en una sola cadena para el shell. El nombre ya no es datos: es parte de una orden.
- 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.
- 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.
- 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».
- La ruta sale de invoices/ y se lee settings.txt, que está al lado. El nombre eligió otro archivo.
- 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 última. El correo es texto del comprador, y new ProcessStartInfo("sh", ["-c", …]) se lo pasa al shell como parte de la orden. Las otras dos líneas pasan una lista de argumentos, y pdf se compone con el número del pedido, no con texto del comprador.
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");resize shoes.jpgresize shoes.jpg; echo injectedEl 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-xcomo 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 enArgumentses el mismo problema: .NET parte el texto en argumentos, ysh -clee después todo el texto como una orden. En Windows,cmd.exe /ces 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"));Factura 1041: 59 €payment_api_key=demo-not-realEl 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:
- Calcula dónde termina de verdad la ruta. Esa es la ruta canónica: completa, sin
.ni... - 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"));Factura 1041: 59 €Rechazado: fuera de la carpeta de facturasPath.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.
GetFullPathtrabaja solo con el texto de la ruta. Si en la carpeta puede haber un enlace simbólico hacia fuera, comprueba ademásFileInfo.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);
}Path.GetFullPath(name, invoices) calcula la ruta completa sin ... La comprobación de la línea siguiente mira eso, no el texto de la dirección.
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"));1042.txt está en la carpeta. ../invoices/1042.txt sale de invoices/ y vuelve a entrar: la ruta canónica es invoices/1042.txt, así que pasa. ../settings.txt termina fuera de la carpeta: rechazado.
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:
Factura 1041: 59 €Factura 1042: 118 €Rechazado: no existe esa facturaRechazado: no existe esa facturaMás tareas
- La factura de otro. El número
1042está 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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas · Bases de datos · Listas, diccionarios y LINQ · Entrada no confiable
- Continúa en: XSS: un script ajeno en tu página
- Ejercicios sobre el tema: Seguridad para programadores