Zurück zum Blog

Folgen und abonnieren

Nur auf Englisch verfügbar

Diese Seite ist momentan nur auf Englisch verfügbar. Wir entschuldigen uns für die Unannehmlichkeiten. Bitte besuchen Sie diese Seite später noch einmal.

So filtern Sie mit Compute nach PNG-Dateien, die von Acropalypse betroffen sind

Andrew Betts

Principal Developer Advocate, Fastly

Ist Ihnen A„crop“alypse bereits ein Begriff? Vergangene Woche veröffentlichten Simon Aarons und David Buchanan ihre Entdeckung</u>, dass Bilder, die mit der Android App Markup zugeschnitten wurden, oft große Teile des ursprünglichen, nicht zugeschnittenen Bildes enthielten. Da Nutzer nicht wussten, dass die beim Zuschneiden entfernten Bildteile unter Umständen nicht vollständig gelöscht wurden, handelt es sich bei dieser Schwachstelle auch um ein Datenschutzproblem. Entfernte Bildteile können schließlich auch personenbezogene Daten, Account-Namen, Inhalte von Benachrichtigungen und zahlreiche weitere sensible Informationen enthalten, die auf dem ursprünglichen Screenshot zu sehen waren. Buchanan führte weiter an, dass sich diese Schwachstelle mithilfe von CDNs auf transparente Weise bekämpfen ließe</u>. Demnach soll ein Filter auf der Edge helfen, den Nutzern eine Last von den Schultern zu nehmen und das Internet zu einem sicheren Ort zu machen. 

Wir nehmen diese Herausforderung gerne an!

Obwohl das Problem bereits im Kern durch einen Fix in der Markup App behoben wurde, lässt sich der Schaden nicht mehr rückgängig machen. Bilder, die vor dem Fix zugeschnitten wurden, sind nach wie vor auf allen Plattformen und Websites im Internet zu finden, und die Markup App kann vermeintlich beschnittene Inhalte nicht rückwirkend aus Bildern entfernen, die bereits auf anderen Geräten geteilt wurden. Ein unsachgemäß zugeschnittenes Bild, das vor dem Fix auf eine Social-Media-Plattform hochgeladen wurde, bleibt weiterhin im Umlauf und stellt ein Datenschutzrisiko für den Urheber dar.

Mit Compute von Fastly lassen sich Probleme wie dieses schnell lösen. Bei Anwendung eines Fix können Sie vermeintlich entfernte Daten aus sämtlichen Bildern löschen, die mithilfe von über die Fastly Edge ausgelieferten Apps oder Services geteilt werden.

Die einfache Lösung

Mit Fastlys Image Optimizer lassen sich überflüssige Inhalte nachweislich aus sämtlichen Bildern entfernen, die mit diesem Feature bearbeitet werden. Wenn Sie Image Optimizer also bereits bei Ihrem Fastly Service nutzen, werden Ihre Bilder so gefiltert, dass Acropalypse-Daten entfernt werden.

Aber was, wenn Sie wie einige unserer Kunden Image Optimizer gar nicht nutzen?

Die Hintergründe des Problems

PNG-Dateien besitzen eine wohlbekannte Byte-Sequenz, die diese Dateien ausmacht. Die „magischen Bytes“ lauten wie folgt:

89 50 4E 47

Im hexdump einer beliebigen PNG-Datei sieht das Ganze folgendermaßen aus:

○ 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

Ein ähnlicher Marker befindet sich am Ende von PNG-Dateien. Er besteht aus einer Sequenz aus vier Nullbytes, gefolgt von dem ASCII-Code „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`.|

Die Schwierigkeit dabei ist, dass manche Tools wie Markup von Android beim Zuschneiden eines Bildes die ursprünglichen Bilddaten in den Leerstellen am Ende der Datei speichern.

Hier ein anschauliches Beispiel für dieses Problem aus dem Tweet von Simon Aarons:

Aber wie können wir jetzt unterbinden, dass weiterhin sensible Nutzerdaten an die Öffentlichkeit gelangen?

Die Allroundlösung: Compute

Zunächst schreiben wir ein paar Zeilen Edge-Code in Fastly Fiddle. Als Erstes erstelle ich einen einfachen Request Handler, der die Anfrage an ein Backend sendet und die Antwort an den Client zurückgibt.

Das Ganze sieht folgendermaßen aus:

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;
}

In Fastly Fiddle sehen wir Folgendes:

Ich kann mein Fiddle jetzt so konfigurieren, dass http-me.glitch.me als Backend verwendet wird. Dabei handelt es sich um einen Fastly Server, über den ich Antworten senden kann, die für diese Art von Test von Nutzen sein können. Den Pfad kann ich auf /image-png setzen, damit HTTP-me eine PNG-Datei zurückgibt.

Jetzt muss ich meine acropalypseFilter-Funktion noch etwas weiter ausgestalten. Hier mein Lösungsansatz:

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;
}

Gehen wir das Ganze einmal Schritt für Schritt durch:

  1. Wir haben einige Variablen für die Byte-Sequenzen erstellt, die uns verraten, ob die Antwort eine PNG-Datei ist und ob wir das Ende der Datei erreicht haben.

  2. Am Ende der Funktion erstellt der Block if (response.body) ein TransformStream-Objekt

    und gibt ein neues Response-Objekt zurück, das auf den lesbaren Teil des TransformStream zurückgreift. Als Nächstes müssen wir die Backend-Antwort auslesen und Daten zum überschreibbaren Teil des TransformStream pushen.

  3. Da wir ein Response-Objekt von der Funktion acropalypseFilter zurückgeben möchten, darf acropalypseFilter nicht auf async gesetzt werden. Andernfalls würde die Funktion anstatt eines Response-Objekts ein Promise-Objekt zurückgeben. Ich habe also die Child-Funktion processChunks erstellt,

    die auf async gesetzt werden kann. Diese Funktion lässt sich auch aufrufen, ohne zuerst auf das Promise-Objekt warten zu müssen, da das Response-Objekt auch zurückgegeben werden kann, bevor der Stream beendet ist (das ist sogar besser!).

  4. Innerhalb der Child-Funktion processChunks erstellen wir nun eine Read-Schleife und lesen Datenblöcke aus der Backend-Antwort aus. Was wir suchen, ist der magische Marker, der uns bestätigt, dass es sich bei der Datei um eine PNG-Datei handelt. Sobald wir ihn gefunden haben, suchen wir nach dem Marker für das Ende der PNG-Datei. Wenn wir diesen gefunden haben,

    können wir alles, was auf diesen Marker folgt, durch Nullen ersetzen. Hierbei handelt es sich um den wichtigsten Schritt zur Bekämpfung des Datenlecks.

Aber warum brechen wir die Antwort an dieser Stelle nicht einfach ab? Die Krux an dieser Geschichte ist, dass wir die Antwort-Header bereits gesendet haben, die höchstwahrscheinlich eine Content-Length-Syntax enthalten. So viele Daten müssen wir also auf jeden Fall durchlassen.

Und noch etwas: Bei dem chunk, den uns die reader.read()-Methode liefert, handelt es sich um einen ArrayBuffer-Constructor, den wir durchsuchen müssen, um die gesuchten charakteristischen Byte-Sequenzen zu finden. Da es keine Möglichkeit gibt, dies nativ in JavaScript zu tun, habe ich eine Funktion geschrieben, die nach einer Folge von Elementen in einem Array sucht:

// 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;
}

Das lässt sich sicherlich noch besser darstellen. Aber mir geht es ja hier nur darum, kurz zu demonstrieren, was mit Edge Computing alles möglich ist.

Sehen Sie sich gerne mein Fiddle an und klonen Sie es, um Ihre eigene Version zu erstellen.