Filtrado de archivos PNG mediante Compute ante el Acropalypse
¿Qué es el a«crop»alypse? La semana pasada, Simon Aarons y David Buchanan compartieron su descubrimiento</u> de que las imágenes recortadas con la aplicación de edición Markup de Android solían esconder en el archivo gran parte de la imagen original sin recortar. Esto representa un problema de privacidad, puesto que los usuarios desconocen que la parte de la imagen que han borrado puede seguir ahí e incluir datos personales, nombres de cuentas, contenido de notificaciones y muchísima más información privada que se encuentre en cualquier parte de la pantalla del teléfono. David sugirió que las CDN podían mitigar la vulnerabilidad con total transparencia</u> desplegando un filtro en el edge, lo cual libera de esa responsabilidad al usuario y hace de internet un lugar mejor.
¡Aceptamos el reto!
Por mucho que se haya resuelto la raíz del problema con una corrección de la aplicación de edición Markup, el daño ya está hecho, y es que las imágenes recortadas antes de la corrección siguen circulando por plataformas y sitios de internet, y no se puede borrar el contenido «recortado» de las imágenes que ya se hayan enviado. Una imagen recortada «en teoría» y cargada a una red social antes de la corrección seguirá ahí, poniendo en riesgo la privacidad del autor o autora.
Compute de Fastly resulta idóneo para resolver con rapidez problemas como este: mediante una corrección, se pueden eliminar los datos «recortados» de toda imagen que pase por aplicaciones y servicios distribuidos por el edge de Fastly.
La respuesta fácil
Ya contamos con una funcionalidad de optimización de imágenes, y hemos comprobado que cualquier imagen que pasa por el optimizador pierde el contenido extra. Así, si usas Image Optimizer en tu servicio de Fastly, ya filtras las imágenes de modo que se borren los datos que dieron pie al «acropalypse».
No obstante, no todo el mundo utiliza la optimización de imágenes con su servicio de Fastly: ¿qué pasa entonces?
Entendamos el problema
Los archivos PNG tienen una secuencia de bytes conocida de sobra que los identifica; los llamados «bytes mágicos», que son los siguientes:
89 50 4E 47
Veámoslos en el hexdump
de un archivo PNG cualquiera:
○ head -c 100 Screenshot\ 2023-03-01\ at\ 10.35.01.png | hexdump -C
00000000 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 |.PNG........IHDR|
00000010 00 00 06 cc 00 00 04 e6 08 06 00 00 00 85 ef 84 |................|
00000020 f2 00 00 0a aa 69 43 43 50 49 43 43 20 50 72 6f |.....iCCPICC Pro|
00000030 66 69 6c 65 00 00 48 89 95 97 07 50 53 e9 16 c7 |file..H....PS...|
00000040 bf 7b d3 43 42 49 42 28 52 42 6f 82 74 02 48 09 |.{.CBIB(RBo.t.H.|
00000050 a1 85 de 9b a8 84 24 40 28 21 26 04 05 bb b2 b8 |......$@(!&.....|
00000060 82 6b 41 44 |.kAD|
00000064
Los archivos PNG terminan con un marcador parecido, llamado IEND, que también es fácil de encontrar en la secuencia de bytes (una secuencia de cuatro bytes nulos y los caracteres ASCII del IEND):
○ tail -c 100 Screenshot\ 2023-03-01\ at\ 10.35.01.png | hexdump -C
00000000 98 cf ff 9d 2f 79 c0 7b c3 ec de 7b ef 8d 1b 96 |..../y.{...{....|
00000010 2c 0b 1e f0 24 c3 a2 32 f9 4a fd 95 c6 3a 7c 13 |,...$..2.J...:|.|
00000020 a1 1a 78 6c 47 70 c6 fb dc 41 dd f2 8d 4b 79 d1 |..xlGp...A...Ky.|
00000030 35 f2 6b 89 ef f0 d8 3e e8 ed 23 70 c6 3f 5e 6f |5.k....>..#p.?^o|
00000040 6f 98 75 80 4b 7c da 01 1e 1c 72 80 93 a8 ff 37 |o.u.K|....r....7|
00000050 c8 1a 61 82 9a 74 43 f6 00 00 00 00 49 45 4e 44 |..a..tC.....IEND|
00000060 ae 42 60 82 |.B`.|
El problema es que ciertas herramientas, como la de marcado de Android, recortan una imagen pero incluyen los datos de la imagen original en el espacio que queda al final del archivo.
El artículo de Simon lo ilustra a la perfección:
Y bien, ¿cómo lo hacemos para evitar que se filtren los datos privados de los usuarios?
Compute al rescate
Veamos Fastly Fiddle por dentro y escribamos algo de código en el edge. Para empezar, crearé un controlador básico de peticiones, que se dedica a enviar la petición a un backend y devuelve la respuesta al cliente.
Tiene este aspecto:
addEventListener("fetch", event => event.respondWith(handleRequest(event)));
async function handleRequest(event) {
const response = await fetch(event.request, {backend: 'origin_0'});
return acropalypseFilter(response);
}
function acropalypseFilter(resp) {
return resp;
}
Y en Fiddle verás esto:
Puedo configurar mi fiddle para que use http-me.glitch.me
como backend. Se trata de un servidor que mantiene Fastly para proporcionar respuestas que pueden resultar de utilidad para este tipo de probaturas. Pondremos /image-png
como ruta, para que HTTP-me dé una imagen PNG.
Ahora toca darle forma a mi función acropalypseFilter
; esto es lo que me ha salido:
function acropalypseFilter(response) {
// Define the byte sequences for the PNG magic bytes and the IEND marker
// that identifies the end of the image
const pngMarker = new Uint8Array([0x89,0x50,0x4e,0x47]);
const pngIEND = new Uint8Array([0x00,0x00,0x00,0x00,0x49,0x45,0x4e,0x44]);
// Define an async function so we can use await when processing the stream
async function processChunks(reader, writer) {
let isPNG = false;
let isIEND = false;
while (true) {
// Fetch a chunk from the input stream
const { done, value: chunk } = await reader.read();
if (done) break;
// If we have not yet found a PNG marker, see if there's one
// in this chunk. If there is, we have a PNG that is potentially
// vulnerable
if (!isPNG && seqFind(chunk, pngMarker) !== -1) {
console.log("It's a PNG");
isPNG = true;
}
// If we know we're past the end of the PNG, any remaining data
// in the file is the hidden data we want to remove. Since we already
// sent the Content-Length header, we'll pad the rest of the response
// with zeroes.
if (isIEND) {
writer.write(new Uint8Array(chunk.length));
continue;
// If it's a PNG but we're yet to get to the end of it...
} else if (isPNG) {
// See if this chunk contains the IEND marker
// If so, output the data up to the marker and replace the rest of the
// chunk with zeroes.
const idx = seqFind(chunk, pngIEND);
if (idx > 0) {
console.log(`Found IEND at ${idx}`);
isIEND = true;
writer.write(chunk.slice(0, idx));
writer.write(new Uint8Array(chunk.length-idx));
continue;
}
}
// Either we're not dealing with a PNG, or we're in a PNG but have not
// reached the IEND marker yet. Either way, we can simply copy the
// chunk directly to the output.
writer.write(chunk);
}
// After the input stream ends, we should do cleanup.
writer.close();
reader.releaseLock();
}
if (response.body) {
const {readable, writable} = new TransformStream();
const writer = writable.getWriter();
const reader = response.body.getReader();
processChunks(reader, writer);
return new Response(readable, response);
}
return response;
}
Veámoslo paso a paso:
Hemos configurado unas variables que contienen las secuencias de bytes que nos dirán si la respuesta es un archivo PNG y si hemos llegado al final.
Abajo del todo, el bloque
if (response.body)
crea un TransformStreamy devuelve una nueva Response que consume la parte de lectura. Ahora tenemos que leer la respuesta del backend e insertar datos en la parte de escritura del TransformStream.
Como queremos devolver una
Response
desde la funciónacropalypseFilter
, esta no puede serasync
, puesto que si así se declarase, no devolvería una respuesta sino una promesa. Ante esto, he definido una función secundaria,processChunks
,que sí puede ser asincrónica. También podemos llamarla sin esperar a la promesa, porque no pasa nada si devolvemos la respuesta antes de que haya terminado la transmisión; de hecho, es mejor.
En
processChunks
, hemos creado un bucle de lectura para que repase fragmentos de datos de la respuesta del backend. Buscamos el marcador mágico que dice que el archivo es un PNG y, si lo encontramos, buscaremos el marcador que indica el final del PNG. Si encontramos esto último,todo lo que le siga lo reemplazaremos por ceros: ahí está la clave para resolver la filtración de datos.
¿Y por qué no cortamos la respuesta ahí? La cuestión es que ya hemos enviado los encabezados de la respuesta, y lo más seguro es que incluyeran un Content-Length
, por lo que tenemos que emitir la cantidad indicada de datos.
Otra cosa: el chunk
que resulta del método reader.read()
es un ArrayBuffer, y tenemos que realizar una búsqueda en el mismo para encontrar las secuencias especiales de bytes que queremos. Esto no se puede hacer de forma nativa en JavaScript, así que inventé una función para buscar una secuencia de elementos en una matriz:
// Find a sequence of elements in an array
function seqFind(input, target, fromIndex = 0) {
const index = input.indexOf(target[0], fromIndex);
if (target.length === 1 || index === -1) return index;
let i, j;
for (i = index, j = 0; j < target.length && i < input.length; i++, j++) {
if (input[i] !== target[j]) {
return seqFind(input, target, index + 1);
}
}
return (i === index + target.length) ? index : -1;
}
Está claro que se puede mejorar, pero para probar las posibilidades de la informática en el edge no está nada mal.
Aquí encontrarás mi fiddle, que puedes clonar y versionar a tu gusto.