Security News

Cybersecurity news aggregator

🔓
MEDIUM Vulnerabilities Reddit r/netsec

Kanboard Authenticated SQL Injection CVE-2026-33058 Writeup

  • What: Authenticated SQL injection vulnerability in Kanboard
  • Impact: Could allow unauthorized database access
Read Full Article →

Table of Contents 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 -> co...

Share this article