Bug: Arbitrary Javascript injection in PDF.js through FontMatrix
Status: VERIFIED
Priority: P1
Resolution: FIXED
Created: 2024-04-26 13:35:08+00:00
Resolved: 2025-01-03 12:34:26+00:00

(Tested with Firefox 124.0b9 on Ubuntu and with PDF.js latest from Git.)

Arbitrary Javascript code can be executed in the PDF.js context (unrelated to the Javascript sandbox) by abusing a lack of type checking in the glyph path compilation process, specifically when a Type 1 font is involved. A prerequisite is that `isEvalSupported` needs to be true (it is by default).

This results in a somewhat universal XSS when a PDF is opened with Firefox (cross-origin restrictions are still in place). An attacker can control the PDF.js window and do things like spy on the user’s activity, trigger downloads using `pdf.js.message` events (even file:// URLs can be “downloaded” in this way) or leak the PDF’s local file path from `window.PDFViewerApplication.url`.

In the cases where PDF.js is used in a web-application, this leads to a stored XSS attack on the respective page’s origin.

# Details

The method `FontFaceObject.getPathGenerator(...)` compiles glyphs to Javascript functions that will return the associated path. In case `isEvalSupported` is set to true, it does this by concatenating them into a list of Javascript statements and creating a Function object out of that: (https://github.com/mozilla/pdf.js/blob/90d4b9c2c0df7cadbd5c2388431b208f08da1ed6/src/display/font_loader.js#L450 )
```
    // If we can, compile cmds into JS for MAXIMUM SPEED...
    if (this.isEvalSupported && FeatureTest.isEvalSupported) {
      const jsBuf = [];
      for (const current of cmds) {
        const args = current.args !== undefined ? current.args.join(",") : "";
        jsBuf.push("c.", current.cmd, "(", args, ");\n");
      }
      // eslint-disable-next-line no-new-func
      return (this.compiledGlyphs[character] = new Function(
        "c",
        "size",
        jsBuf.join("")
      ));
    }
```
This list of commands, `cmds`, is generated by the method `CompiledFont.compileGlyph(...)` and the `compileGlyphImpl` variants for the different font types: (trimmed from https://github.com/mozilla/pdf.js/blob/90d4b9c2c0df7cadbd5c2388431b208f08da1ed6/src/core/font_renderer.js#L798 )
```
  compileGlyph(code, glyphId) {
    ...
    let fontMatrix = this.fontMatrix;
    ...
    const cmds = [
      { cmd: "save" },
      { cmd: "transform", args: fontMatrix.slice() },
      { cmd: "scale", args: ["size", "-size"] },
    ];
    this.compileGlyphImpl(code, cmds, glyphId);

    cmds.push({ cmd: "restore" });

    return cmds;
  }
```
For the arguments of the `transform` command it takes the `fontMatrix` array from the current font object. It turns out that under specific circumstances (seemingly only for Type1 fonts) this array is grabbed from a PDF dictionary and not (also) from the font data itself. Hence, the array can contain arbitrary PDF primitives including strings. The retrieval happens in `PartialEvaluator.translateFont`:

```
    const properties = {
      ...
      fontMatrix: dict.getArray("FontMatrix") || FONT_IDENTITY_MATRIX,
      ...
    };
```
(Note: with other font types this does not appear to be a problem as the `FontMatrix` is later grabbed from the font data itself (e.g. CFF dicts or Type1 “PostScript” parsing) where the parsers explicitly expect numeric array elements. Even if it is not present in the font data, it is overwritten with a standard matrix.)

This means that string-type elements get concatenated literally in the resulting Javascript. Hence we can craft a PDF with a Type1 font and a `Font` object with this `FontMatrix` definition;
```
  /FontMatrix [1 2 3 4 5 (6\); alert\('foobar')]
```

resulting in this Javascript code being executed when the glyph is rendered:
```
c.save();
c.transform(1,2,3,4,5,6); alert('foobar');
c.scale(size,-size);
c.moveTo(0,0);
c.restore();
```

Attached is a plain-text minimal PoC.

# Possible solution

Type-validation could be added either in `compileGlyph`, or in `translateFont`. In the latter case, maybe it is beneficial to implement something like `dict.getNumericArray` in addition to `dict.getArray` for situations like this.