cms_risk_check.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """PostToolUse hook: 国内常用 CMS(帝国/织梦/Discuz/WordPress/PHPCMS)高危风险检测。
  4. 检测 5 类风险:
  5. 1. 文件包含漏洞:include/require + $_GET/$_POST/$_REQUEST/$_COOKIE
  6. 2. 命令执行:eval/system/exec/passthru/shell_exec/popen/proc_open/assert + 用户输入
  7. 3. 反射型 XSS:echo/print/printf + $_GET/$_POST/$_REQUEST 未转义
  8. 4. 任意文件上传:move_uploaded_file 目标路径不可控
  9. 5. SSRF/远程包含:include/require + http:// 或 https://
  10. 安全模式(不报警):
  11. - htmlspecialchars/htmlentities/esc_html/esc_url/e() 转义函数
  12. - intval/floatval 强制类型转换
  13. - 常量/配置变量(如 DIR_FS_*, __DIR__)
  14. """
  15. import sys
  16. import os
  17. import json
  18. import re
  19. # 仅检查 PHP 文件(国内 CMS 主要语言)
  20. TARGET_EXT = ".php"
  21. # 用户输入源(PHP 超全局变量)
  22. USER_INPUT = r"\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\b"
  23. # 转义/安全函数(同行出现则跳过)
  24. SAFE_FUNC = (
  25. r"htmlspecialchars", r"htmlentities", r"strip_tags",
  26. r"esc_html", r"esc_attr", r"esc_url", r"esc_js",
  27. r"intval", r"floatval", r"doubleval",
  28. r"\be\(", # Laravel Blade 的 e() 转义
  29. r"wp_kses", r"sanitize_text_field",
  30. r"addslashes",
  31. )
  32. # 风险模式
  33. PATTERNS = [
  34. # 1. 文件包含漏洞
  35. {
  36. "id": "FILE_INCLUDE",
  37. "regex": re.compile(
  38. r"(?:include|require|include_once|require_once)\s*\(?\s*"
  39. r"(?:\$_(?:GET|POST|REQUEST|COOKIE)\b"
  40. r"|\$[a-z_]\w*\s*\.\s*\$_(?:GET|POST|REQUEST|COOKIE)\b)",
  41. re.IGNORECASE
  42. ),
  43. "msg": "文件包含漏洞:include/require 直接使用用户输入,可能导致 LFI/RFI",
  44. },
  45. # 2. 远程文件包含(SSRF)
  46. {
  47. "id": "RFI_SSRF",
  48. "regex": re.compile(
  49. r"(?:include|require|include_once|require_once)\s*\(?\s*"
  50. r"['\"]https?://",
  51. re.IGNORECASE
  52. ),
  53. "msg": "远程文件包含/SSRF:include/require 远程 URL,极高风险",
  54. },
  55. # 3. 命令执行:eval/assert + 用户输入
  56. {
  57. "id": "CODE_EXEC",
  58. "regex": re.compile(
  59. r"(?:eval|assert)\s*\(\s*"
  60. r"(?:\$_(?:GET|POST|REQUEST|COOKIE)\b"
  61. r"|base64_decode\s*\(\s*\$_)",
  62. re.IGNORECASE
  63. ),
  64. "msg": "代码执行漏洞:eval/assert 执行用户输入,极高风险(常见后门)",
  65. },
  66. # 4. 命令执行:system/exec/passthru/shell_exec/popen/proc_open + 用户输入
  67. {
  68. "id": "CMD_EXEC",
  69. "regex": re.compile(
  70. r"(?:system|exec|passthru|shell_exec|popen|proc_open)\s*\(\s*"
  71. r"(?:\$_(?:GET|POST|REQUEST|COOKIE)\b"
  72. r"|\$[a-z_]\w*\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)\b)",
  73. re.IGNORECASE
  74. ),
  75. "msg": "命令执行漏洞:system/exec/passthru 执行用户输入",
  76. },
  77. # 5. 反射型 XSS:echo/print + $_GET/$_POST 未转义
  78. {
  79. "id": "XSS_REFLECT",
  80. "regex": re.compile(
  81. r"(?:echo|print|printf)\b[^;]{0,80}"
  82. r"\$_(?:GET|POST|REQUEST)\b",
  83. re.IGNORECASE
  84. ),
  85. "msg": "反射型 XSS:直接输出用户输入未转义",
  86. },
  87. # 6. 任意文件上传:move_uploaded_file + 可控路径
  88. {
  89. "id": "FILE_UPLOAD",
  90. "regex": re.compile(
  91. r"move_uploaded_file\s*\([^,]+,\s*"
  92. r"(?:\$_(?:GET|POST|REQUEST)\b"
  93. r"|\$[a-z_]\w*\s*\.\s*\$_(?:GET|POST|REQUEST)\b)",
  94. re.IGNORECASE
  95. ),
  96. "msg": "任意文件上传:move_uploaded_file 目标路径含用户输入",
  97. },
  98. # 7. SQL 拼接 + 帝国CMS 特有 $empire->query 拼接
  99. {
  100. "id": "CMS_SQL_QUERY",
  101. "regex": re.compile(
  102. r"(?:empire->query|empire->fetch|db->query|db->fetchAll)\s*\(\s*"
  103. r"[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  104. re.IGNORECASE
  105. ),
  106. "msg": "CMS SQL 注入:帝国/织梦 DB 对象拼接用户输入",
  107. },
  108. # 8. 织梦 DedeCMS 特有:$dsql->ExecuteNoneQuery 拼接
  109. {
  110. "id": "DEDESQL",
  111. "regex": re.compile(
  112. r"dsql->(?:ExecuteNoneQuery|Execute|GetOne)\s*\(\s*"
  113. r"[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  114. re.IGNORECASE
  115. ),
  116. "msg": "织梦 DedeCMS SQL 注入:$dsql 拼接用户输入",
  117. },
  118. # 9. Discuz! 特有:DB::query 拼接
  119. {
  120. "id": "DISCUZSQL",
  121. "regex": re.compile(
  122. r"DB::query\s*\(\s*[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  123. re.IGNORECASE
  124. ),
  125. "msg": "Discuz! SQL 注入:DB::query 拼接用户输入",
  126. },
  127. # 10. WordPress 特有:$wpdb->query 拼接
  128. {
  129. "id": "WPSQL",
  130. "regex": re.compile(
  131. r"wpdb->(?:query|get_results|get_var|get_row|get_col)\s*\(\s*"
  132. r"[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  133. re.IGNORECASE
  134. ),
  135. "msg": "WordPress SQL 注入:$wpdb->query/get_results 拼接用户输入",
  136. },
  137. # 11. PHPCMS 特有:$this->db->query / pc_base::load_model 拼接
  138. {
  139. "id": "PHPCMS_SQL",
  140. "regex": re.compile(
  141. r"(?:this->db->query|this->db->select|this->db->get_one)\s*\(\s*"
  142. r"[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  143. re.IGNORECASE
  144. ),
  145. "msg": "PHPCMS SQL 注入:$this->db->query/select 拼接用户输入",
  146. },
  147. # 12. Drupal 特有:db_query / db_select 拼接
  148. {
  149. "id": "DRUPAL_SQL",
  150. "regex": re.compile(
  151. r"(?:db_query|db_select|db_delete|db_update|db_insert|db_merge)\s*\(\s*"
  152. r"[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  153. re.IGNORECASE
  154. ),
  155. "msg": "Drupal SQL 注入:db_query/db_select 拼接用户输入",
  156. },
  157. # 13. Joomla 特有:$db->setQuery / $db->loadResult 拼接
  158. {
  159. "id": "JOOMLA_SQL",
  160. "regex": re.compile(
  161. r"db->(?:setQuery|loadResult|loadObjectList|loadAssocList|loadRow)\s*\(\s*"
  162. r"[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  163. re.IGNORECASE
  164. ),
  165. "msg": "Joomla SQL 注入:$db->setQuery/loadResult 拼接用户输入",
  166. },
  167. # 14. ThinkPHP 特有:Db::query / M()->query / $model->query 拼接
  168. {
  169. "id": "THINKPHP_SQL",
  170. "regex": re.compile(
  171. r"(?:Db::query|Db::execute|M\(\w+\)->query|model->query|model->execute)\s*\(\s*"
  172. r"[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  173. re.IGNORECASE
  174. ),
  175. "msg": "ThinkPHP SQL 注入:Db::query/M()->query 拼接用户输入",
  176. },
  177. # 15. CodeIgniter 特有:$this->db->query 拼接
  178. {
  179. "id": "CI_SQL",
  180. "regex": re.compile(
  181. r"this->db->query\s*\(\s*[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  182. re.IGNORECASE
  183. ),
  184. "msg": "CodeIgniter SQL 注入:$this->db->query 拼接用户输入",
  185. },
  186. # 16. Laravel 特有:DB::select / DB::statement 拼接(非预处理方式)
  187. {
  188. "id": "LARAVEL_SQL",
  189. "regex": re.compile(
  190. r"DB::(?:select|statement|unprepared)\s*\(\s*[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  191. re.IGNORECASE
  192. ),
  193. "msg": "Laravel SQL 注入:DB::select/statement/unprepared 拼接用户输入",
  194. },
  195. # 17. Yii 特有:Yii::$app->db->createCommand 拼接
  196. {
  197. "id": "YII_SQL",
  198. "regex": re.compile(
  199. r"db->createCommand\s*\(\s*[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  200. re.IGNORECASE
  201. ),
  202. "msg": "Yii SQL 注入:createCommand 拼接用户输入",
  203. },
  204. # 18. Symfony 特有:$conn->executeQuery 拼接
  205. {
  206. "id": "SYMFONY_SQL",
  207. "regex": re.compile(
  208. r"conn->(?:executeQuery|executeUpdate|executeStatement)\s*\(\s*"
  209. r"[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  210. re.IGNORECASE
  211. ),
  212. "msg": "Symfony SQL 注入:executeQuery 拼接用户输入",
  213. },
  214. # 19. Typecho 特有:$db->query / $db->fetchRow 拼接
  215. {
  216. "id": "TYPECHO_SQL",
  217. "regex": re.compile(
  218. r"db->(?:query|fetchRow|fetchALL)\s*\(\s*[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  219. re.IGNORECASE
  220. ),
  221. "msg": "Typecho SQL 注入:$db->query/fetchRow 拼接用户输入",
  222. },
  223. # 20. Z-Blog 特有:$zbp->db->query 拼接
  224. {
  225. "id": "ZBLOG_SQL",
  226. "regex": re.compile(
  227. r"zbp->db->query\s*\(\s*[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  228. re.IGNORECASE
  229. ),
  230. "msg": "Z-Blog SQL 注入:$zbp->db->query 拼接用户输入",
  231. },
  232. # 21. emlog 特有:$db->query / $db->fetch_array 拼接
  233. {
  234. "id": "EMLOG_SQL",
  235. "regex": re.compile(
  236. r"\bdb->(?:query|fetch_array|fetch_row|num_rows)\s*\(\s*"
  237. r"[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  238. re.IGNORECASE
  239. ),
  240. "msg": "emlog SQL 注入:$db->query 拼接用户输入",
  241. },
  242. # 22. PDO 通用:$pdo->query / $pdo->exec 拼接(非预处理方式)
  243. {
  244. "id": "PDO_SQL",
  245. "regex": re.compile(
  246. r"pdo->(?:query|exec)\s*\(\s*[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  247. re.IGNORECASE
  248. ),
  249. "msg": "PDO SQL 注入:$pdo->query/exec 拼接用户输入(应使用 prepare)",
  250. },
  251. # 23. mysqli 通用:mysqli_query / $mysqli->query 拼接
  252. {
  253. "id": "MYSQLI_SQL",
  254. "regex": re.compile(
  255. r"(?:mysqli_query|mysqli->query)\s*\(\s*[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  256. re.IGNORECASE
  257. ),
  258. "msg": "mysqli SQL 注入:mysqli_query 拼接用户输入(应使用 bind_param)",
  259. },
  260. # 24. mysql 通用(已废弃):mysql_query 拼接
  261. {
  262. "id": "MYSQL_DEPRECATED_SQL",
  263. "regex": re.compile(
  264. r"mysql_query\s*\(\s*[\"'].*?\$_(?:GET|POST|REQUEST)\b",
  265. re.IGNORECASE
  266. ),
  267. "msg": "mysql_query SQL 注入:已废弃 API + 拼接用户输入(极高风险)",
  268. },
  269. # === 框架特有风险(非 SQL 注入)===
  270. # 25. 反序列化漏洞:unserialize + 用户输入(所有 PHP 框架通用)
  271. {
  272. "id": "DESERIALIZE",
  273. "regex": re.compile(
  274. r"unserialize\s*\(\s*(?:\$_(?:GET|POST|REQUEST|COOKIE)\b"
  275. r"|file_get_contents\s*\(\s*\$_)",
  276. re.IGNORECASE
  277. ),
  278. "msg": "反序列化漏洞:unserialize 用户输入,可能导致 RCE",
  279. },
  280. # 26. 模板注入 - ThinkPHP:display/assign/fetch + 用户输入
  281. {
  282. "id": "TP_TEMPLATE_INJECT",
  283. "regex": re.compile(
  284. r"(?:this->assign|this->display|this->fetch)\s*\([^)]{0,80}"
  285. r"\$_(?:GET|POST|REQUEST)\b",
  286. re.IGNORECASE
  287. ),
  288. "msg": "ThinkPHP 模板注入:assign/display/fetch 含用户输入",
  289. },
  290. # 27. 模板注入 - Twig/Symfony:render + 用户输入
  291. {
  292. "id": "TWIG_INJECT",
  293. "regex": re.compile(
  294. r"(?:render|renderView|Twig::render)\s*\([^)]{0,80}"
  295. r"\$_(?:GET|POST|REQUEST)\b",
  296. re.IGNORECASE
  297. ),
  298. "msg": "Twig 模板注入:render 含用户输入",
  299. },
  300. # 28. 模板注入 - Blade/Laravel:Blade::compileString + 用户输入
  301. {
  302. "id": "BLADE_INJECT",
  303. "regex": re.compile(
  304. r"Blade::compileString\s*\([^)]{0,80}\$_(?:GET|POST|REQUEST)\b",
  305. re.IGNORECASE
  306. ),
  307. "msg": "Laravel Blade 模板注入:compileString 含用户输入",
  308. },
  309. # 29. SSRF:file_get_contents/curl_exec + 用户输入 URL
  310. {
  311. "id": "SSRF",
  312. "regex": re.compile(
  313. r"(?:file_get_contents|curl_exec|fopen|readfile)\s*\(\s*"
  314. r"(?:\$_(?:GET|POST|REQUEST)\b"
  315. r"|\$[a-z_]\w*\s*\.\s*\$_(?:GET|POST|REQUEST)\b)",
  316. re.IGNORECASE
  317. ),
  318. "msg": "SSRF/任意文件读取:file_get_contents/curl_exec 含用户输入",
  319. },
  320. # 30. XXE:simplexml_load_string/DOMDocument + 用户输入
  321. {
  322. "id": "XXE",
  323. "regex": re.compile(
  324. r"(?:simplexml_load_string|DOMDocument|XMLReader|xml_parse)\s*\(\s*"
  325. r"(?:\$_(?:GET|POST|REQUEST)\b"
  326. r"|\$[a-z_]\w*\s*\.\s*\$_(?:GET|POST|REQUEST)\b)",
  327. re.IGNORECASE
  328. ),
  329. "msg": "XXE 漏洞:XML 解析器含用户输入,可能读取服务器文件",
  330. },
  331. # 31. 代码执行:preg_replace /e 修饰符(PHP < 7)
  332. {
  333. "id": "PREG_E_EXEC",
  334. "regex": re.compile(
  335. r"preg_replace\s*\([^)]*['\"][^'\"]*[a-z]?e[a-z]?['\"]",
  336. re.IGNORECASE
  337. ),
  338. "msg": "代码执行:preg_replace 使用 /e 修饰符(PHP<7 代码执行)",
  339. },
  340. # 32. 代码执行:create_function(PHP < 8,已废弃)
  341. {
  342. "id": "CREATE_FUNCTION",
  343. "regex": re.compile(
  344. r"create_function\s*\(",
  345. re.IGNORECASE
  346. ),
  347. "msg": "代码执行:create_function 已废弃(PHP 8+ 移除),存在注入风险",
  348. },
  349. # 33. ThinkPHP 特有:I() 函数未过滤直接入 SQL
  350. {
  351. "id": "TP_I_SQL",
  352. "regex": re.compile(
  353. r"(?:empire->query|db->query|Db::query)\s*\([^)]{0,80}"
  354. r"\bI\(['\"](?:get|post|request|param)\.",
  355. re.IGNORECASE
  356. ),
  357. "msg": "ThinkPHP SQL 注入:I() 函数结果直接入 SQL(需 where 条件绑定)",
  358. },
  359. # 34. Laravel 特有:Artisan::call + 用户输入(命令注入)
  360. {
  361. "id": "ARTISAN_INJECT",
  362. "regex": re.compile(
  363. r"Artisan::call\s*\([^)]{0,80}\$_(?:GET|POST|REQUEST)\b",
  364. re.IGNORECASE
  365. ),
  366. "msg": "Laravel 命令注入:Artisan::call 含用户输入",
  367. },
  368. # 35. CodeIgniter 特有:$this->load->view + 用户输入模板名
  369. {
  370. "id": "CI_VIEW_INJECT",
  371. "regex": re.compile(
  372. r"this->load->view\s*\(\s*\$_(?:GET|POST|REQUEST)\b",
  373. re.IGNORECASE
  374. ),
  375. "msg": "CodeIgniter 模板注入:load->view 含用户输入模板名",
  376. },
  377. # 36. Yii 特有:unserialize + 用户输入(Yii 反序列化漏洞高发)
  378. {
  379. "id": "YII_DESERIALIZE",
  380. "regex": re.compile(
  381. r"unserialize\s*\(\s*(?:\$request->(?:get|post|body)\b"
  382. r"|\$_(?:GET|POST|REQUEST)\b)",
  383. re.IGNORECASE
  384. ),
  385. "msg": "Yii 反序列化漏洞:unserialize 用户输入",
  386. },
  387. # 37. 文件写入 + 用户输入路径(任意文件写入)
  388. {
  389. "id": "FILE_WRITE",
  390. "regex": re.compile(
  391. r"(?:file_put_contents|fwrite|fputs)\s*\(\s*"
  392. r"(?:\$_(?:GET|POST|REQUEST)\b"
  393. r"|\$[a-z_]\w*\s*\.\s*\$_(?:GET|POST|REQUEST)\b)",
  394. re.IGNORECASE
  395. ),
  396. "msg": "任意文件写入:file_put_contents/fwrite 路径含用户输入",
  397. },
  398. ]
  399. # 安全函数正则(用于过滤误报)
  400. SAFE_RE = re.compile("|".join(SAFE_FUNC), re.IGNORECASE)
  401. def main():
  402. try:
  403. raw = sys.stdin.read()
  404. if not raw.strip():
  405. return 0
  406. data = json.loads(raw)
  407. except Exception:
  408. return 0
  409. tool_input = data.get("tool_input", {}) or {}
  410. path = (
  411. tool_input.get("filePath")
  412. or tool_input.get("file_path")
  413. or tool_input.get("path")
  414. or ""
  415. )
  416. if not path:
  417. return 0
  418. if not path.lower().endswith(TARGET_EXT):
  419. return 0
  420. try:
  421. with open(path, encoding="utf-8", errors="ignore") as f:
  422. lines = f.readlines()
  423. except OSError:
  424. return 0
  425. # 中间变量污点追踪:收集被赋值为用户输入的变量,用于跨行 LFI 检测
  426. # $page = $_GET['page']; include($page);
  427. taint_assign_re = re.compile(
  428. r"\$([A-Za-z_]\w*)\s*=\s*"
  429. r"(?:\$_(?:GET|POST|REQUEST|COOKIE)\b"
  430. r"|\$[A-Za-z_]\w*\s*\.\s*\$_(?:GET|POST|REQUEST|COOKIE)\b)",
  431. re.IGNORECASE,
  432. )
  433. tainted_vars = set()
  434. for line in lines:
  435. for m in taint_assign_re.finditer(line):
  436. tainted_vars.add(m.group(1))
  437. hits = []
  438. for i, line in enumerate(lines, 1):
  439. # 跳过注释行
  440. stripped = line.strip()
  441. if stripped.startswith("//") or stripped.startswith("#"):
  442. continue
  443. if stripped.startswith("/*") or stripped.startswith("*"):
  444. continue
  445. # 跳过包含安全函数的行
  446. if SAFE_RE.search(line):
  447. continue
  448. for pat in PATTERNS:
  449. if pat["regex"].search(line):
  450. hits.append((i, pat["id"], pat["msg"], stripped[:120]))
  451. # 中间变量 LFI:include/require 的参数是被用户输入赋值过的变量
  452. if tainted_vars:
  453. inter_include_re = re.compile(
  454. r"(?:include|require|include_once|require_once)\s*\(?\s*"
  455. r"\$(" + "|".join(re.escape(v) for v in sorted(tainted_vars, key=len, reverse=True)) + r")\b",
  456. re.IGNORECASE,
  457. )
  458. for i, line in enumerate(lines, 1):
  459. stripped = line.strip()
  460. if stripped.startswith("//") or stripped.startswith("#"):
  461. continue
  462. if stripped.startswith("/*") or stripped.startswith("*"):
  463. continue
  464. if SAFE_RE.search(line):
  465. continue
  466. if inter_include_re.search(line):
  467. hits.append((i, "FILE_INCLUDE",
  468. "文件包含漏洞:include/require 使用被用户输入赋值过的中间变量,可能导致 LFI/RFI",
  469. stripped[:120]))
  470. if not hits:
  471. return 0
  472. print("[CMS-RISK] 检测到 %d 处 CMS 高危风险 (%s):" % (len(hits), path))
  473. for line_no, risk_id, msg, code in hits[:20]:
  474. print(" L%d [%s] %s" % (line_no, risk_id, msg))
  475. print(" > %s" % code)
  476. if len(hits) > 20:
  477. print(" ... 还有 %d 处" % (len(hits) - 20))
  478. print("[CMS-RISK] 建议使用预处理语句/参数绑定/转义函数修复")
  479. return 1
  480. if __name__ == "__main__":
  481. sys.exit(main())