[BugBounty] XSS with Markdown — Exploit & Fix on OpenSource

Finding and debugging Open Sources is a fun thing. It helps me improve my pentest and programming skills. And what’s more fun when it supports bounty and CVE from huntr.dev’s platform

I will have an article about the process of joining the huntr platform and earning my first bounty and CVE.

Back to the main topic of the article is XSS with Markdown. If you don’t know what it is. Please read the following articles

What is XSS : https://portswigger.net/web-security/cross-site-scripting

What is Markdown: https://www.markdownguide.org

How to Exploit xss via Markdown

Markown Link element

[title](https://www.example.com)

When rendered by application, it look like

<a href="https://www.example.com">title</a>

Markown Image element

![alt text](https://www.example.com/image.jpg)

When rendered by application, it look like

<img src="https://www.example.com/image.jpg" alt="alt text">

Through this is taking advantage of applications that do not carefully process data as well as special characters before rendering, so we can trigger xss with payloads like:

Markdown Link element — Payload

[XSS](javascript:alert('xss'))

When rendered by application, it look like

<a href="javascript:alert('xss')">

Markdown Image element — Payload

![alt text]("onerror="alert('XSS'))

When rendered by application, it look like

<img src="" onerror="alert('XSS') alt="alt text">

See more payloads at https://github.com/cujanovic/Markdown-XSS-Payloads

Finding Vulnerabilities on Open Source

Kimai2 : Kimai v2 is a web-based multiuser time-tracking application. Free for everyone: freelancers, agencies, companies, organizations — all can track their times, generate invoices and more.

Django-helpdesk : A Django application to manage tickets for an internal helpdesk. Formerly known as Jutda Helpdesk.

Start with Kimai2 first. An application written in PHP language.

At the menu for Administration, When accessing Projects and selecting a project we will have a Comment section and have Markdown support.

Try commenting with Image payload

![alt text]("onerror="alert('XSS'))

Special characters are all encoded and cannot be triggered xss. So try with Link payload

[XSS](javascript:alert('xss'))

Hmmm!! That looks positive. Try clicking and see if it works.

The Console error Uncaught SyntaxError: expected expression, got ‘&’. I think it’s possible to run the alert, but it’s crashing due to the & character of &#039; . So just don’t use single quotes.

One mark I often use when exploit xss is the pair of grave accents.

[XSS](javascript:alert(`xss`))

Everything was clearer. Try clicking on it.

Boomm!! XSS trigger success. Get money ^^!!

You can directly call functions that javascript supports without using quotes such as document.doamin, document.cookie.

[XSS](javascript:alert(document.domain))

We’ll do the same for django-helpdesk . An application written in python

Try with Image payload for Comment

![alt text]("onerror="alert('XSS'))

Double quotes cannot be escaped from the img tag. So try with Link payload.

[XSS](javascript:alert('xss'))

It was received, but the apostrophe appears to have been filtered out. Try with grave accents.

[XSS](javascript:alert(`xss`))

Looks good but when I click on it, the console error Uncaught ReferenceError: xss is not defined

So we can use functions available in javascript like document.domain or document.cookie

[XSS](javascript:alert(document.domain))

Oke. Done!! Finding vulnerabilities seems simple, so how to fix it.?

How to fixes

Start with Kimai2

Looking to the template for the comment section comments.html.twig, we see that the application uses md2html to render for Markdown

<div class="direct-chat-text">
{{ comment.message|replace(replacer)|md2html }}
</div>

md2html is declared in the getFilters() function in the file RuntimeExtensions.php

public function getFilters()
{
return [
new TwigFilter('md2html', [MarkdownExtension::class, 'markdownToHtml'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
new TwigFilter('desc2html', [MarkdownExtension::class, 'timesheetContent'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
new TwigFilter('comment2html', [MarkdownExtension::class, 'commentContent'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
new TwigFilter('comment1line', [MarkdownExtension::class, 'commentOneLiner'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
new TwigFilter('colorize', [ThemeExtension::class, 'colorize']),
];
}

We can see it will use markdownToHtml in the MarkdownExtension::class. Single and double quotes handled with pre_escape. So we don’t need to worry about Image element when rendering HTML anymore. We will focus on processing for Link element.

Accessing the markdownToHtml function we see that there aren’t any filters for $content.

public function markdownToHtml(string $content): string
{
return $this->markdown->toHtml($content, false);
}

So how do we fix this now?

After searching on Stackoverflow, Github, … I had an idea for the following fix.

  • Use regex to identify Link element.
  • Create a set of allowed url schemes.
  • Check $content with regex if true, then check with url scheme.
  • Use replace to make $content safe when rendering.

markdownToHtml function after code

public function markdownToHtml(string $content): string
{
$ALLOWED_URL_SCHEMES = array("ftp", "ftps", "http", "https", "mailto", "sftp", "ssh", "tel", "telnet", "tftp", "vnc");
$pattern = '/([\[\s\S\]]*?)\(([\s\S]*?):([\s\S]*?)\)/';
# Regex check
preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
if($matches) {
foreach ($matches as $match) {
// get value of group regex
$scheme = $match[2];
}
// scheme check
if(in_array($scheme, $ALLOWED_URL_SCHEMES)) {
$replacement = '$1($2:$3)';
} else {
$replacement = '$1($3)';
}

$content = preg_replace($pattern, $replacement, $content);
}

return $this->markdown->toHtml($content, false);
}

Code explanation

$ALLOWED_URL_SCHEMES : Contains an array of allowed schemes.

$pattern : regex string for [title](https://www.example.com) . At first I used the pattern : /[(.+)\]\((.+).*:(.+)\)/ . But it is easily bypassed by multiple-line. And after searching I fixed the bypass by mutiple-line using \s\S for the pattern.

We will initially use the preg_match_all function to check if $content matches. Then we will get the value in group 2 in the regex to check with $ALLOWED_URL_SCHEMES . If the scheme in group 2 is in $ALLOWED_URL_SCHEMES we will create $replacement which is a combination of group 1 , 2 and 3 otherwise we will create $replacement which is a combination of group 1 and 3. And then put in the preg_replace function to override $content

Result after fix

With payload

[title](https://www.example.com)

$content value will be:

[title](https://www.example.com)

When rendered by application, it look like

<a href="https://www.example.com">title</a>

With payload

[XSS](javascript:alert('xss'))

$content value will be:

[XSS](alert('xss'))

When rendered by application, it look like

<a href="alert('xss')">XSS</a>

Do the same for django-helpdesk

Looking at the template of the ticket ticket_desc_table.html, we see that the Description uses the get_markdown function to render forMarkdown

<tr>
<td id="ticket-description" colspan='4'>
<h4>{% trans "Description" %}</h4>
{{ ticket.get_markdown|urlizetrunc:50|num_to_link }}
</td>
</tr>

get_markdown is declared in the get_markdown() function in the file models.py

def get_markdown(text):
if not text:
return ""
return mark_safe(
markdown(
text,
extensions=[
EscapeHtml(), 'markdown.extensions.nl2br',
'markdown.extensions.fenced_code',
]
)
)

Likewise, single and double quotes are handled by EscapeHtml. So the Image element can’t be escaped. Edit and then code to suit python and we get the following

Add ALLOWED_URL_SCHEMES for settings.py

ALLOWED_URL_SCHEMES = getattr(settings, 'ALLOWED_URL_SCHEMES', (     'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',))

Add new code for get_markdown function in models.py

def get_markdown(text):
if not text:
return ""
pattern = fr'([\[\s\S\]]*?)\(([\s\S]*?):([\s\S]*?)\)'
# Regex check
if re.match(pattern, text):
# get get value of group regex
scheme = re.search(pattern, text, re.IGNORECASE).group(2)
# scheme check
if scheme in helpdesk_settings.ALLOWED_URL_SCHEMES:
replacement = '\\1(\\2:\\3)'
else:
replacement = '\\1(\\3)'
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) return mark_safe(
markdown(
text,
extensions=[
EscapeHtml(), 'markdown.extensions.nl2br',
'markdown.extensions.fenced_code',
]
)
)

Result after fix

#IamPhuc #PenetrationTester #OSCP #eCPPT #eMAPT #eWAPT