The Magic Informer
The Magic Informer is a HackTheBox challenge featuring a Node.js application with multiple critical vulnerabilities. This writeup demonstrates how I chained LFI, JWT bypass, and SSRF to achieve RCE.
Box Info
Name | The Magic Informer |
---|---|
Type | Challenge |
Difficulty | Easy |
Creator | Rayhan0x01 & makelaris |
Release Date | 17 December 2022 |
Reconnaissance
Application Overview
The web application features:
- The landing page of the site
- User registration/login system
- After logging in we see a job application form with file upload
Source Code Analysis
Key discoveries from code review:
- Debug Endpoint with Command Injection
let cmdStr = `sqlite3 -csv admin.db "${safeSql}"`; const cmdExec = execSync(cmdStr);
- AdminMiddleware
import { decode } from "../helpers/JWTHelper.js";
const AdminMiddleware = async (req, res, next) => {
try{
if (req.cookies.session === undefined) {
if(!req.is('application/json')) return res.redirect('/');
return res.status(401).json({ status: 'unauthorized', message: 'Authentication required!' });
}
return decode(req.cookies.session)
.then(user => {
req.user = user;
if (req.user.username !== 'admin') return res.redirect('/dashboard');
return next();
})
.catch(() => {
res.redirect('/logout');
});
} catch(e) {
console.log(e);
return res.redirect('/logout');
}
}
export { AdminMiddleware };
- LocalMiddleware
const LocalMiddleware = async (req, res, next) => {
if (req.ip == '127.0.0.1' && req.headers.host == '127.0.0.1:1337') {
return next();
}
return res.status(401).json({ message: 'Blocked: This endpoint is whitelisted to localhost only.' });
}
export { LocalMiddleware };
Exploit Chain
LFI via Path Traversal
Discovered in the /download
endpoint, which i got after uploading a resume in the job application form:
resume = resume.replaceAll('../', ''); // Incomplete sanitization
Intercepted the request in burp
identified error indecating path of valid file locations
tried some lfi payloads, but no luck, kept getting errors
Then i checked the route file for this /download endpoint and figured out that the code was replacing all the ../
characters with ‘’
router.get('/download', AuthMiddleware, async (req, res) => {
return db.getUser(req.user.username)
.then(user => {
if (!user) return res.redirect('/login');
let { resume } = req.query;
resume = resume.replaceAll('../', '');
return res.download(path.join('/app/uploads', resume));
})
.catch(e => {
return res.redirect('/login');
})
});
tried the payload /download?resume=%2e%2e/%2e/%2e/%2e/etc/passwd
but still it didn’t work
lets now check the payloadallthings site for some lfi payloads…
And after multilple trial and error Filter bypass payloads from PayloadAllThings finally worked and sucessfully retreived system files
After this i felt like a deadend then as i wasn’t able to figure out what next then agian i checked the reviews section of this machine and there i saw cookie poisoning
Cookie Poisoning Leading to Privilege Escalation
then i checked the source code for the jwt mechanism and figured out that while decoding token it was not varifying the signature if it is ok or not the authorization checks were happening based on the uername in the token
import jwt from "jsonwebtoken";
import crypto from "crypto";
const APP_SECRET = crypto.randomBytes(69).toString('hex');
const sign = (data) => {
data = Object.assign(data);
return (jwt.sign(data, APP_SECRET, { algorithm:'HS256' }))
}
const decode = async(token) => {
return (jwt.decode(token)); //-->vulnerable code
}
export { sign, decode };
then by intercepting the request i changed the username to admin
which we previously identified from the AdminMiddleware
and injected the poisoned token in the session cookie and got redirected to the /admin
endpoint and successfully escalated the user role to an admin account.
Browser manipulation worked more reliably than Burp for JWT testing due to header handling differences.
Then landed on this /sms-setting
page
SSRF
upon intercepting the request i identified that it was calling the /api/sms/test
endpoint so I then started investigating the route of this path.
(I am checking it through the provided source code, but it could also be done via the lfi vulnerablity by traversing to the /app/route/index.js
endpoint.)
router.post('/api/sms/test', AdminMiddleware, async (req, res) => {
const { verb, url, params, headers, resp_ok, resp_bad } = req.body;
if (!(verb && url && params && headers && resp_ok && resp_bad)) {
return res.status(500).send(response('missing required parameters'));
}
let parsedHeaders = {};
try {
let headersArray = headers.split('\n');
for(let header of headersArray) {
if(header.includes(':')) {
let hkey = header.split(':')[0].trim()
let hval = header.split(':')[1].trim()
parsedHeaders[hkey] = hval;
}
}
}
catch (e) { console.log(e) }
let options = {
method: verb.toLowerCase(),
url: url,
timeout: 5000,
headers: parsedHeaders
};
if (verb === 'POST') options.data = params;
axios(options)
.then(response => {
if (typeof(response.data) == 'object') {
response.data = JSON.stringify(response.data);
}
return res.json({status: 'success', result: response.data})
})
.catch(e => {
if (e.response) {
if (typeof(e.response.data) == 'object') {
e.response.data = JSON.stringify(e.response.data);
}
return res.json({status: 'fail', result: e.response.data})
}
else {
return res.json({status: 'fail', result: 'Address is unreachable'});
}
})
});
then i modified the url to one of the urls mentioned in the LocalMiddleware and got a different error
Then i change the method from post to get and got a success status
Though i wasn’t sure what to do with it after that so i explored the application a bit more and identified the sql prompt section
Then again got this error
tried almost everything to bypass this but no luck.
However we have already bypassed the localMiddleware
via the sms-settings
functionality so we can try ssrf via tha functionality by injecting these queries in the parameter.
Now we are getting unauthentication error seems like we can bypass it by passing the session cookie.
I tried the pass from the code base that didn’t worked then grabed the new pass via the LFR
again same :) Then i reffered some writeups and identified i didn’t add the Cookie header before passing the session token (silly mistakes).
and here we got a success request
Path to RCE
Now we will be able to execute commands / perform ssrf
extracted the version of db
DB type: sqlite
version 3.35.5
Dumped sqlite_master
schema
i checked all the db data but nothing useful then i went for looking ways to break out of the input field so that i could executed direct queries on the system.
For that the defense mechanism looked something like this( they were converting al "
to '
)
try
{
let safeSql = String(sql).replaceAll(/"/ig, "'");
let cmdStr = `sqlite3 -csv admin.db "${safeSql}"`;
const cmdExec = execSync(cmdStr);
return res.json({sql, output: cmdExec.toString()});
}
I bypassed it by this and executed the id
command to cross check i exectue multiple command and received the following outputs
whoami
command…
then tried the ls -la
command
payload used ls -la | base64
-> but still didn’t got the entire output
So then tried this ls -la | base64 -w0
(because in the previous command, new line gets added by default after every 76 chars) and got the full output
then in the root ('/')
directory got this readflag binary and we will receive the flag after executing it
and here we got the flag
Remediation
Secure File Downloads
const resolvedPath = path.resolve('/app/uploads', resume);
if (!resolvedPath.startsWith('/app/uploads')) {
throw new Error('Invalid path');
}
JWT Best Practices
jwt.verify(token, SECRET_KEY); // Always verify signatures
SSRF Protection
if (!isAllowedURL(url)) {
throw new Error('URL not permitted');
}
/exit
Challenge conquered. Another flag captured. systemd service stopped 🚩