Replacement-String Injection Explained: When `$`` Steals the Template
An escape function can look perfectly safe while JavaScript quietly interprets the replacement string underneath it. Here's how the bug works, how the XSS chain develops, and why String.replace() makes a terrible template engine.
A lot of security reviews stop at the escape function.
They see < becoming <, quotes becoming entities, and the security alarm goes quiet. Unfortunately, JavaScript has a tiny little footnote that can turn a safe-looking string into a template vandalism kit.
The problem is simple: String.prototype.replace() does not always treat its replacement argument as plain text. When the replacement is a string, sequences such as $&, $`` and $’` have special meanings. OWASP also warns that output encoding only works when it matches the context where the data is inserted. Put those two facts together and a very ordinary server-side helper can become a browser-side injection primitive.
This article explains the bug from first principles, builds a generic exploit scenario, shows the vulnerable code, and finishes with practical fixes.
What Is Replacement-String Injection, Anyway?
Imagine a template function that replaces:
{{ username }}
with whatever the user supplied.
A developer may reasonably write:
function render(template, username) {
return template.replace("{{ username }}", escape(username));
}
At first glance, that looks sensible.
Now look closer at the API being used. JavaScript’s string replacement syntax reserves several patterns inside a string replacement value:
| Pattern | Meaning |
|---|---|
$$ | literal $ |
$& | the matched text |
| `$“ | everything before the match |
$' | everything after the match |
That is the trap.
If an attacker can place `$“ in the replacement string, the browser may receive content that was never literally present in the attacker input. The replacement operation manufactures it.
MDN documents these replacement patterns directly. citeturn617491search3
Why Should You Care?
This bug is dangerous when all of the following are true:
- Untrusted data is passed as the second argument to
String.replace(). - The replacement argument is a string, not a callback function.
- The application performs templating by replacing a literal marker.
- The replaced value eventually lands in HTML, JavaScript, an attribute, or another executable browser context.
- A second defense such as CSP is expected to carry the whole security burden.
Possible impact includes reflected XSS, stored XSS, browser-side privilege abuse, CSRF-like actions performed from the victim’s browser, and data exfiltration. XSS can also redirect a user or trigger authenticated actions in the security context of the vulnerable origin. citeturn617491search0turn617491search6
Worked Example: How the Bug Happens
Consider a small web application that creates a personalized message.
The server builds a page from this template:
<title>Message for {{ sender }}</title>
<p>{{ message }}</p>
A helper escapes HTML characters:
function escapeHtml(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function render(template, data) {
for (const key in data) {
const value = escapeHtml(data[key]);
template = template.replace(`{{ ${key} }}`, value);
}
return template;
}
The author has done several things that look right:
- They escape
<,>,&, and quotes. - They use a very small templating helper.
- They replace a literal marker rather than evaluating arbitrary template syntax.
The hidden problem is the last line.
Step 1: Identify the dangerous API behavior
The escaping helper does not remove $.
It does not need to. $ is not special in normal HTML.
It is special to a JavaScript replacement string.
For example:
const template = "A {{ value }} B";
console.log(
template.replace("{{ value }}", "$`ATTACK")
);
The replacement engine interprets `$“ as “insert the part of the original string that appeared before the matched text.”
So the result contains more template text than the attacker literally supplied.
That is a data transformation bug, not a conventional HTML escaping failure.
Step 2: Understand why a browser-facing sink makes it worse
Now place the same mechanism in a page containing inline JavaScript:
<script nonce="{{ nonce }}">
let message = "{{ message }}";
</script>
The application may still entity-encode quotes and angle brackets.
That does not make the surrounding JavaScript context safe if the templating operation can manufacture additional source text.
OWASP explicitly distinguishes HTML encoding from JavaScript-context encoding and warns against placing untrusted data directly into dangerous script contexts. citeturn617491search0
Step 3: Track the execution context, not just the input
A useful audit model is:
HTTP parameter
↓
validation
↓
escapeHtml()
↓
String.replace(template, replacement)
↓
rendered HTML
↓
browser parser
↓
script / DOM / navigation sink
The key mistake is assuming that escapeHtml() is the final interpretation step.
It is not.
String.replace() gets to interpret the replacement string too.
A Generic Browser-Side Chain
A realistic multi-stage chain can look like this:
attacker-controlled value
↓
replacement-string expansion
↓
template boundary changes
↓
JavaScript execution
↓
same-origin request from browser
↓
privileged server-side action
↓
sensitive value placed into a page URL
↓
cross-origin navigation
↓
Referer / URL leakage
The important lesson is that the first bug does not have to expose the final secret.
It only has to give the attacker enough browser-side code execution to perform the next action.
Why the browser matters
Suppose an internal endpoint only accepts a request from a loopback browser context and checks a same-origin Origin header.
A direct curl request from an attacker machine may fail.
Browser JavaScript executing inside the application can make the request from the application’s own origin. The server then sees a request coming from the trusted execution environment it expected.
That turns an apparently useless endpoint into a useful second-stage primitive.
A Common Mistake: Using the Right Escape in the Wrong Place
Here is another version of the same bug:
function renderValue(template, value) {
const safe = escapeHtml(value);
return template.replace("{{ value }}", safe);
}
The developer may say:
“The input is escaped, so it cannot be dangerous.”
That statement skips a layer.
The correct question is:
“What does every API do to this value before the browser interprets it?”
There are two interpreters here:
String.replace()interprets replacement patterns.- The browser interprets the resulting document.
Security reviews need to model both.
Vulnerable Code Example
function escapeHtml(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function renderMessage(template, userInput) {
const safe = escapeHtml(userInput);
// Dangerous: `safe` is a replacement string.
return template.replace("{{ message }}", safe);
}
The bug is not that escapeHtml() forgot <.
The bug is that a string replacement API is being used as the templating engine while attacker-controlled text still contains replacement metacharacters.
Patched Code
The simplest fix is to use a replacement callback:
function escapeHtml(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function renderMessage(template, userInput) {
const safe = escapeHtml(userInput);
return template.replace("{{ message }}", () => safe);
}
Why does this help?
Because replacement-string metacharacters such as `$“ are interpreted only when the replacement is supplied as a string. A callback returns the text as data instead of feeding it through replacement-string parsing. MDN documents this distinction explicitly. citeturn617491search3
Better still: use a real templating engine
For anything beyond a toy template, use a mature templating system with context-aware escaping rather than building one with String.replace().
The goal is not “escape more characters.”
The goal is “make sure untrusted data is always treated as data in the correct output context.”
What About CSP?
Content Security Policy is useful defense in depth, but it is not a substitute for fixing injection.
A nonce-based CSP looks roughly like:
Content-Security-Policy:
script-src 'nonce-random-value';
The browser allows a script only when its nonce matches the policy. MDN describes nonces as per-response values that are compared against the nonce attribute on the script element. citeturn617491search1turn617491search2
That is a strong control.
But CSP does not make an injection bug disappear.
If an attacker can find a same-origin JavaScript execution path, abuse a trusted script, navigate to a privileged page, or exploit another browser sink, the policy may limit some payloads without removing the underlying defect. OWASP explicitly recommends treating CSP as defense in depth rather than the only XSS control. citeturn617491search0turn617491search5
Testing and Audit Points
When reviewing a custom renderer, search for:
.replace(templateMarker, userValue)
replace("{{ key }}", value)
replace(`{{ ${key} }}`, value)
Then test replacement values containing:
$$
$&
$`
$'
Do not stop after checking whether <script> is escaped.
Also ask:
- Is the replacement argument a string or callback?
- What context receives the final rendered value?
- Does the value ever reach an inline
<script>? - Does it reach a URL or attribute?
- Does a browser or bot visit the generated content?
- Can the rendered page perform same-origin requests?
- Can a privileged endpoint place secrets in query strings?
- Can a cross-origin navigation leak the resulting URL?
Those questions often reveal the real chain faster than a giant payload list.
Defense Checklist
| Category | Recommended action |
|---|---|
| Templating | Use a real templating engine with context-aware escaping |
| Replacement API | Prefer a replacement callback when dynamic replacement is required |
| HTML output | Encode for HTML context |
| JavaScript output | Avoid inserting untrusted data directly into <script> |
| URLs | URL-encode values and validate destinations |
| CSP | Keep nonce-based CSP as defense in depth |
| Trusted Types | Enforce them for DOM XSS-sensitive sinks |
| Testing | Include $`` / $‘/$&/$$` in replacement-string tests |
| Privileged flows | Never rely on client-side JavaScript as an authorization boundary |
| Secrets | Do not put sensitive data into URLs or other referrer-visible locations |
Final Thoughts
This class of bug is a good reminder that secure code is about composition, not individual lines.
The escape function can be correct.
The CSP can be strict.
The endpoint can reject remote clients.
And the application can still fall over because one innocent-looking API interprets the replacement string before the browser ever sees it.
The fix is pleasantly boring: use a real renderer, use context-appropriate encoding, and do not feed attacker-controlled strings into replacement semantics when you really meant “literal text.”
String.replace() is a great utility. It is a surprisingly bad template engine.
References
- OWASP, Cross Site Scripting Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- OWASP, Content Security Policy Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html
- MDN,
String.prototype.replace(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace - MDN, Content-Security-Policy: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy
- MDN,
nonceHTML global attribute: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce