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:
| |
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:
| |
This method is used internally by libs/picodb/lib/PicoDb/Database.php, which
in turn exposes a higher-level execute() method:
| |
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:
| |
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:
| |
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:
| |
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?!
| |
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:
| |
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():
| |
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:
| |
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:
| |
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:
| |
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:
| |
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:
| |
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.