CVE-2024-36399 Kanboard Project Takeover via IDOR

June 7, 2024 in linux, security ‐ 10 min read

In this article I will document the journey that lead to me finding an IDOR vulnerability in kanboard.

Kanboard

Kanboard is a free and open source Kanban project management software. ~ kanboard.org

kanboard

I have used it quite intensivley for various endeavours. For all of my projects while studying for my computer science degree. For my wedding. For more projects than I can count really.

Having used this software alot I felt confident in diving into its codebase. PHP is not my strong suit but after some time I began to understanding the moving parts that make kanboard tick.

Read the docs

Kanboard has a great documentation. https://docs.kanboard.org/

Also the code is commented. Thank you for that.

The hunt begins

What initialy peaked my interrest was how thumbnails for users get generated. To cut a long story short, I traced all the places the user input was used in the code and found nothing.

Having started to dive into the code, my next thought was to see how the file upload was handeled. There are two ways to upload files to tickets in kanboard, “Attach a document” and “Add a screenshot”.

The code containing the upload looked fine so i focused on the way files can be viewed. When viewing an image in kanboard, the following GET request gets generated.

GET /?controller=FileViewerController&action=image&file_id=1&task_id=1

I tried hunting for an IDOR by changing the file_id or task_id parameter but permission checking seems solid.

Then I tried uploading files that are not images using the screenshot upload function and found many dead ends in libraries that expect images.

The above mentioned GET Request shows a pattern is one that we will find all over this application. Seems to be some sort of routing logic.

Using this scheme:

  • controller=NameOfController
  • action=NameOfAction

Using this scheme we can directly call all public functions.

GET /?controller=DesiredController&action=desiredFunction&param_1=value&param_2=value

If we want to call protected functions we have to find public ones that utalize them. Having learned this I stared looking at other methods to access uploaded files and found the show() method in the FileViewerController.

/**
 * Show file content in a popover
 *
 * @access public
 */
public function show()
{
    $file = $this->getFile();
    $type = $this->helper->file->getPreviewType($file['name']);
    $params = array('file_id' => $file['id'], 'project_id' => $this->request->getIntegerParam('project_id'));

    if ($file['model'] === 'taskFileModel') {
        $params['task_id'] = $file['task_id'];
    }

    $this->response->html($this->template->render('file_viewer/show', array(
        'file' => $file,
        'params' => $params,
        'type' => $type,
        'content' => $this->getFileContent($file),
    )));
}

This function is using a template to render the contents of a file. The template is app/Template/file_viewer/show.php

<div class="page-header">
    <h2><?= $this->text->e($file['name']) ?></h2>
</div>
<div class="file-viewer">
    <?php if ($file['is_image']): ?>
        <img src="<?= $this->url->href('FileViewerController', 'image', $params) ?>" alt="<?= $this->text->e($file['name']) ?>">
    <?php elseif ($type === 'markdown'): ?>
        <article class="markdown">
            <?= $this->text->markdown($content) ?>
        </article>
    <?php elseif ($type === 'text'): ?>
        <pre><?= $this->text->e($content) ?></pre>
    <?php endif ?>
</div>

This template renders the file differently, depending on its filetype.

  • markdown: gets rendered as markdown
  • text: gets rendered as text
  • image: gets rendered as an image

I was interested in finding out how this filetype is determained. In the show() function we see this line:

$type = $this->helper->file->getPreviewType($file['name']);

This is using the getPreviewType function in app/helper/FileHelper.php

/**
 * Get the preview type
 *
 * @access public
 * @param  string $filename
 * @return string
 */
public function getPreviewType($filename)
{
    switch (get_file_extension($filename)) {
        case 'md':
        case 'markdown':
            return 'markdown';
        case 'txt':
            return 'text';
    }

    return null;
}

get_file_extension is defined in app/functions.php

/**
 * Get file extension
 *
 * @param  $filename
 * @return string
 */
function get_file_extension($filename)
{
    return strtolower(pathinfo($filename, PATHINFO_EXTENSION));
}

Piecing all this together it seems that if the uploaded file ends in .md, .markdown the file will be rendered as markdown when accessed with show(). If the file ends in .txt it will be rendered as text.

How does kanboard figure out if the file is an image then?

If we look at app/Template/file_viewer/show.php again we see this

    <?php if ($file['is_image']): ?>

get_image is a database field that gets set when a file gets uploaded in app/Model/FileModel.php

I traced the different methods of uploading files. Some modify the uploaded files a bit, like changing the filename, but they all end up calling create() in app/Model/FileModel.php

/**
 * Create a file entry in the database
 *
 * @access public
 * @param  integer $foreign_key_id Foreign key
 * @param  string  $name           Filename
 * @param  string  $path           Path on the disk
 * @param  integer $size           File size
 * @return bool|integer
 */
public function create($foreign_key_id, $name, $path, $size)
{
    $values = array(
        $this->getForeignKey() => $foreign_key_id,
        'name' => substr($name, 0, 255),
        'path' => $path,
        'is_image' => $this->isImage($name) ? 1 : 0,
        'size' => $size,
        'user_id' => $this->userSession->getId() ?: 0,
        'date' => time(),
    );

    $result = $this->db->table($this->getTable())->insert($values);

    if ($result) {
        $file_id = (int) $this->db->getLastId();
        $this->fireCreationEvent($file_id);
        return $file_id;
    }

    return false;
}

At this point we can see that isImage gets called.

/**
 * Check if a filename is an image (file types that can be shown as thumbnail)
 *
 * @access public
 * @param  string   $filename   Filename
 * @return bool
 */
public function isImage($filename)
{
    switch (get_file_extension($filename)) {
        case 'jpeg':
        case 'jpg':
        case 'png':
        case 'gif':
            return true;
    }

    return false;
}

This means that if our file ends in one of these extensions it gets treated as an image. As we can see there are no checks if the uploaded files are actually what they are besides the file extensions.

I tried to use this to upload php files disquised as other filetypes.

While playing around with different ways of uploading and accessing files I realised that the filename gets reflected by the show() in the FileViewerController.

image
excellent

However when trying different payloads I realised that when viewing the content the output gets sanitized.

sanitized

A quick look into the database showed that the payload was stored successfully but that something sanatized it before rendering.

If we take another look at app/Template/file_viewer/show.php we can see that both the alt text and the content of the <pre> tag get fed to a ominous e() function.

kanboard

This function can be found in app/Helper/TextHelper.php

/**
 * HTML escaping
 *
 * @param  string   $value    Value to escape
 * @return string
 */
public function e($value)
{
    return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8', false);
}

Another dead end. htmlspecialchars is a PHP builtin function that converts special chars to HTML entities. This effectivley blocks all attempts at inserting HTML tags like <script> and prevents stored XSS vulnerabilities.

Since the payload made it to the database I now needed an endpoint that would not properly sanatize it, so I started looking for places where values were used without this e() function.

Once again looking at the show.php I realized that e() does not get used on markdown.

<?php if ($file['is_image']): ?>
    <img src="<?= $this->url->href('FileViewerController', 'image', $params) ?>" alt="<?= $this->text->e($file['name']) ?>">
<?php elseif ($type === 'markdown'): ?>
    <article class="markdown">
        <?= $this->text->markdown($content) ?>
    </article>
<?php elseif ($type === 'text'): ?>
    <pre><?= $this->text->e($content) ?></pre>
<?php endif ?>

The function markdown can also be found in app/Helper/TextHelper.php

public function markdown($text, $isPublicLink = false)
{
    $parser = new Markdown($this->container, $isPublicLink);
    $parser->setMarkupEscaped(MARKDOWN_ESCAPE_HTML);
    $parser->setBreaksEnabled(true);
    return $parser->text($text ?: '');
}

Turns out kanboard is using a markdown parser https://parsedown.org/. Seems like another dead end.

However at this point I started digging into ways to use the markdown rendering as an attack vector.

After going deep down the rabbit hole of CSP Bypasses I realized I could get the visitors of a Task to issue a GET request by simply embedding an image in markdown.

![some text](http://somesite/)

I believe this to be intended behavior since the CSP is specifically set to support this.

content-security-policy default-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:;

This however can be leveraged for CSRF attacks on endpoints that do not verify a CSRF Token. So I started looking for endpoints that do not verify the CSRF Tokens.

The vulnerability

We know that by leaving a comment on a task like

![some text](https://<Kanboard URL>/?controller=someController&action=someAction&parameter=value)

We cause the viewer of this task to generate a HTTP GET request to the specified endpoint. This means all we need now is an endpoint that:

  • does not require or verify the CSRF token
  • accepts GET requests
  • takes in url parameters

So I went through the controllers one by one looking for endpoints that do not verify the CSRF token. When I found one I tried to figure out what it does and if calling it using a GET with specific URL parameters could do any damage.

Using this method I found that addUser() in app/Controller/ProjectPermissionController.php does not verify the CSRF token. Promising. This function gets called when you are adding a user to a project. Upon intercepting the request in Burp it seemed odd to me that the project_id gets passed both as a URL Parameter and in the request body.

Missing Project ID

Lets take a look at this function.

/**
 * Add user to the project
 *
 * @access public
 */
public function addUser()
{
    $project = $this->getProject();
    $values = $this->request->getValues();

    if (empty($values['user_id']) && ! empty($values['external_id']) && ! empty($values['external_id_column'])) {
        $values['user_id'] = $this->userModel->getOrCreateExternalUserId($values['username'], $values['name'], $values['external_id_column'], $values['external_id']);
    }

    if (empty($values['user_id'])) {
        $this->flash->failure(t('User not found.'));
    } elseif ($this->projectUserRoleModel->addUser($values['project_id'], $values['user_id'], $values['role'])) {
        $this->flash->success(t('Project updated successfully.'));
    } else {
        $this->flash->failure(t('Unable to update this project.'));
    }

    $this->response->redirect($this->helper->url->to('ProjectPermissionController', 'index', array('project_id' => $project['id'])));
}

First thing to note is that $values = $this->request->getValues(); gets the values that this function uses from the POST body of the request. At this point I am still looking for a way to abuse the missing CSRF parameter. I could change the URL Parameter but without a POST body this would not lead anywhere. Then I thought again. Actually I have a way of controlling the POST body. If I do not get someone to trigger this function via CSRF but rather use it as intended and change the post body…

Missing Project ID

And to my surprise it actually worked. To make sure I did not just someone somehow messed up I set up a fresh kanboard instance. There I created the user “unprivileged” and created two projects named “private” and “public”. Only the admin had permissions to see and modify those project. I then added the user “unprivileged” to the “public” project as a project manager. Only then is this user allowed to call the addUser() function in the ProjectPermissionController.

Then I logged in to the “unprivileged” account and again replaced the values. To my surprise when checking the list of my projects I was indeed able to see the private project.

Project View

When checking the permissions of this project I could see that I had indeed added myself to the project “private” as a project manager.

Project View

Reporting the vulnerability

Not only did Frédéric Guillot create this amazing FOSS software, he also setup a Security Policy at https://github.com/kanboard/kanboard/security that allows for a simple responsible disclosure process.

One day after reporting the vulnerability Frédéric confirmed it, requested a CVE for it and provided a patch. Three days after reporting the vulnerability github confirmed the vulnerability and issued CVE-2024-36399 for it. Five days after reporting the vulnerability the Security Advisory and a new release gets published.

As a user of kanboard and a newcomer when it comes to security research this process has been a very smooth experience.

Final thoughts

Thank you for reading through this exhaustivly long blogpost. I included some of the dead ends I ran into to showcase the process of finding security vulnerabilities. You sometimes spend days or weeks on end trying to really get to know whatever system you are pentesting and not find anything. But even if I had not found anything in Kanboard this project would still have been valuable. CSRF via Markdown, CSP bypasses, tracing PHP function calls… I learned alot during this project. And those are only the things I chose to write down. There were many more rabbit holes that led nowhere, yet I still learned things.

So when I comes to vulnerability research, do not shy away if you don’t find anything right off the bat. Stay at it.

So long and happy hacking,

Ori :)