This vulnerability allows arbitrary CSS injection through the Math mode of README files, effectively permitting attackers to manipulate the style of GitHub pages. Recently, this exploit gained significant attention on X (formerly Twitter), with users showcasing their customized GitHub profiles using this method. This article details the vulnerability, how it can be exploited, and provides a historical overview of similar vulnerabilities, including insights into MathJax’s code to understand why this issue exists.
How to Achieve the Injection
GitHub employs MathJax to render math expressions in markdown content, such as README files, issue comments, and pull request comments. One of the LaTeX macros supported by MathJax is \unicode
, which allows the rendering of a Unicode character with a customizable font style:
latexCopy code\unicode[myfont](x0000)
It turns out that any inline CSS can be injected within the square brackets:
latexCopy code\unicode[myfont; color: red; position: fixed; top: 0](x0000)
The CSS is injected into the style attribute of the <mtext>
element that displays the Unicode character, allowing attackers to style their GitHub profile pages arbitrarily.
Diving into MathJax’s Source Code
To understand how this injection is possible, we need to examine MathJax’s source code, specifically the code handling the \unicode
macro.
The Unicode Macro
javascriptCopy codeUnicodeMethods.Unicode = function (parser: TexParser, name: string) {
let HD = parser.GetBrackets(name);
let HDsplit = null;
let font = null;
if (HD) {
if (HD.replace(/ /g, '').match(/^(\d+(\.\d*)?|\.\d+),(\d+(\.\d*)?|\.\d+)$/)) {
HDsplit = HD.replace(/ /g, '').split(/,/);
font = parser.GetBrackets(name);
} else {
font = HD;
}
}
let def: EnvList = {};
let variant = parser.stack.env.font as string;
if (font) {
UnicodeCache[N][2] = def.fontfamily = font.replace(/'/g, '\'');
if (variant) {
if (variant.match(/bold/)) {
def.fontweight = 'bold';
}
if (variant.match(/italic|-mathit/)) {
def.fontstyle = 'italic';
}
}
} else if (variant) { /* ... */ }
let node = parser.create('token', 'mtext', def, numeric(n));
NodeUtil.setProperty(node, 'unicode', true);
}
Here’s a breakdown of the code:
parser.GetBrackets(name)
retrieves the string within the square brackets and stores it inHD
.font
also stores the string, depending on certain conditions applied toHD
.def.fontfamily
holds the font value, with single quotes escaped.- The code then creates a parser node representing an
<mtext>
element, passingdef
as its attributes.
This process allows def.fontfamily
to hold any string, including inline CSS, without proper sanitization.
The LaTeX Parser
The create
method of TexParser
is invoked to create nodes:
javascriptCopy codepublic create(kind : string, ...rest : any[]): MmlNode {
return this.configuration.nodeFactory.create(kind, ...rest);
}
The nodeFactory
is responsible for constructing the node representation of the Unicode macro. This leads to the AbstractMmlNode
class where attributes are set:
javascriptCopy codeexport abstract class AbstractMmlNode extends AbstractNode implements MmlNode {
constructor(factory : MmlFactory, attributes : PropertyList = {}, children : MmlNode[] = []) {
super(factory);
if (this.arity < 0) {
this.childNodes = [factory.create('inferredMrow')];
this.childNodes[0].parent = this;
}
this.setChildren(children);
this.attributes = new Attributes(
factory.getNodeClass(this.kind).defaults,
factory.getNodeClass('math').defaults,
);
this.attributes.setList(attributes);
}
}
The attributes
object holds the styles, including fontfamily
.
Outputting to the DOM
MathJax then converts the compiled expressions into DOM nodes. This is handled by the CHTML
class, which extends CommonOutputJax
:
javascriptCopy codeexport class CHTML<N, T, D> extends
CommonOutputJax<N, T, D, CHTMLWrapper<N, T, D>, CHTMLWrapperFactory<N, T, D>, CHTMLFontData, typeof CHTMLFontData> {
protected processMath(math: MmlNode, parent: N) {
this.factory.wrap(math).toCHTML(parent);
}
}
The processMath
method invokes toCHTML
of the node wrapper class, which is CHTMLmtext
for <mtext>
:
javascriptCopy codeexport class CHTMLWrapper<N, T, D> extends
CommonWrapper<CHTML<N, T, D>, CHTMLWrapper<N, T, D>, CHTMLWrapperClass, CHTMLCharOptions, CHTMLDelimiterData, CHTMLFontData> {
public toCHTML(parent: N) {
const chtml = this.standardCHTMLnode(parent);
for (const child of this.childNodes) {
child.toCHTML(chtml);
}
}
protected handleStyles() {
if (!this.styles) return;
const styles = this.styles.cssText;
if (styles) {
this.adaptor.setAttribute(this.chtml, 'style', styles);
}
}
}
The handleStyles
method sets the style attribute using this.styles.cssText
, which is generated from a Styles
object:
javascriptCopy codepublic get cssText(): string {
const styles = [] as string[];
for (const name of Object.keys(this.styles)) {
const parent = this.parentName(name);
if (!this.styles[parent]) {
styles.push(name + ': ' + this.styles[name] + ';');
}
}
return styles.join(' ');
}
The vulnerability lies in how cssText
is constructed. The fontfamily
value, which includes the injected CSS, is converted to a string without proper sanitization, allowing the CSS injection to occur.
Mitigation
A safer approach involves directly manipulating the style object of the DOM element, which prevents invalid CSS values from being set:
javascriptCopy codedocument.getElementById('my-elem').style.fontFamily = "open-sans";
Attempting to inject invalid CSS through this method will be ignored by the browser.
Historical Context and Related Exploits
Past Exploits and Related Bugs
The CSS injection in MathJax, that @cloud11665 exploited in GitHub was public a year ago MathJax Issue #3129. Much before that, @SecurityMB found a similar bug (\unicode{}
) but it was an XSS back then XSS in Google Colaboratory.
Bug Report: CSS Injection through Font-Family in Unicode Command (Issue #3129)
- Reported by: @opcode86 on Nov 12, 2023
- Summary: A user is able to inject custom CSS even if commands like
\style
are disabled. The style gets rendered into the style attribute of the element containing the Unicode character. - Steps to Reproduce:
- Go to any website that uses MathJax and allows the
\unicode
command. - Enter the following code into the parser:
\unicode[some-font; color:red; height: 100000px;]{x1234}
.
- Go to any website that uses MathJax and allows the
- Technical Details:
- MathJax Version: 3.2.2 (latest commit: 8565f9da973238e4c9571a86a4bcb281b1d98d9b)
- Client OS: Windows 10 Education 19045.3570
- Browser: Chrome 119.0.6045.123
@dpvc (MathJax maintainer) acknowledged the report and suggested using the safe extension to help reduce problems caused by malevolent users. However, this extension does not handle the specific issue of CSS injection. A proposed configuration to filter the fontfamily
attribute was provided:
javascriptCopy codeMathJax = {
loader: {load: ['ui/safe']},
startup: {
ready() {
MathJax.startup.defaultReady();
const safe = MathJax.startup.document.safe;
safe.filterAttributes.set('fontfamily', 'filterFamily');
safe.filterMethods.filterFamily = function (safe, family) {
return family.split(/;/)[0];
};
}
}
};
This configuration filters the fontfamily
attribute to remove the first ;
and anything following it. The issue was promptly addressed and fixed by the MathJax team.
XSS in Google Colaboratory + Bypassing Content-Security-Policy
Michał Bentkowski discovered an XSS vulnerability in Google Colaboratory, which uses the Jupyter Notebook framework, in February 2018. The issue involved the \unicode
macro of MathJax. Despite initial protection mechanisms, such as Content-Security-Policy (CSP), the vulnerability persisted due to inadequate sanitization of the \unicode
macro. Bentkowski demonstrated how to bypass CSP using script gadgets in popular JS frameworks like Polymer.
The Significance of These Findings
These findings highlight the critical importance of input sanitization and security practices in web applications. The history of this vulnerability shows that even widely used and respected libraries like MathJax can have security flaws that persist over time. The ongoing discovery of such vulnerabilities and the efforts to fix them underscore the need for continuous vigilance and improvement in security practices.
Conclusion
This incident highlights the importance of sanitizing user inputs to prevent injection attacks. Libraries like DOMPurify can help sanitize HTML, MathML, and SVG. Always use library-provided mechanisms for handling user input to avoid similar vulnerabilities.
How Can Netizen Help?
Netizen ensures that security gets built-in and not bolted-on. Providing advanced solutions to protect critical IT infrastructure such as the popular “CISO-as-a-Service” wherein companies can leverage the expertise of executive-level cybersecurity professionals without having to bear the cost of employing them full time.
We also offer compliance support, vulnerability assessments, penetration testing, and more security-related services for businesses of any size and type.
Additionally, Netizen offers an automated and affordable assessment tool that continuously scans systems, websites, applications, and networks to uncover issues. Vulnerability data is then securely analyzed and presented through an easy-to-interpret dashboard to yield actionable risk and compliance information for audiences ranging from IT professionals to executive managers.
Netizen is an ISO 27001:2013 (Information Security Management), ISO 9001:2015, and CMMI V 2.0 Level 3 certified company. We are a proud Service-Disabled Veteran-Owned Small Business that is recognized by the U.S. Department of Labor for hiring and retention of military veterans.
Questions or concerns? Feel free to reach out to us any time –
https://www.netizen.net/contact