先说我上周遇到的一件事。同事让AI生成了一套用户中心接口,本地跑通、测试环境跑通,周五下班前直接合并上主干。周六晚上群里开始有人反馈提现记录异常,排查到最后,问题出在一个按用户名查订单的接口上——AI把用户名用字符串拼进了SQL。测试环境数据少,没触发异常;生产环境数据一多,偶尔拼出语法不正确的SQL,异常数据就这么被翻了出来。
这种案例我最近见得越来越多。AI写代码本身不是问题,问题在于很多人默认“生成代码能跑就等于能上线”。事实上,AI生成的代码在“语法正确”和“生产安全”之间,经常隔着好几道坎:不校验输入边界、不做权限校验、不相信调用者、不处理异常路径。你功能测两遍没问题,不代表恶意输入来了也能稳。
这篇要聊的,就是我总结的AI生成代码里最容易藏起来的5类安全漏洞。每一类,我都会给出典型示例代码、为什么它是漏洞、以及我在项目里实际采用的修复方案。不管你是后端、全栈、测试还是安全,只要在用AI写代码,这份清单都值得你丢进收藏夹。
1. 为什么AI生成的代码“能跑”却未必扛打
先说个反直觉的事实:AI写代码不是“用经验写”,它是在“续写最像代码的文本”。这个本质决定了它的安全短板。
1.1 AI的训练数据决定了它擅长模仿而不是防守
大语言模型的训练素材很大一部分来自开源代码、技术博客、问答社区。这些素材里的代码,本来就有相当比例是多年以前的写法:密码写在配置里、SQL用字符串拼接、正则从某个论坛复制过来。模型在训练阶段并不会判断“这个写法对不对”,它只会统计“哪种写法最常见”。于是那些历史悠久但谈不上安全的写法,反而成了模型眼里的标准答案。
这就是为什么你让AI写一段数据库连接代码,它很可能直接给出用户名密码常量;让它写查询接口,很可能毫不客气地用f-string拼SQL。它不是在故意埋雷,它是真的学到的就是这些。想让AI生成安全代码,你需要先接受这个前提:它对外部输入的本能是“信任”,而不是“怀疑”。
1.2 提示词越省事,AI越会替你做出危险默认值
第二个原因是模型没有“追问”能力。你丢一句“给我写一个文件下载接口”,AI会默认文件是合法的,默认调用者是可信的,默认目录结构是正确的。你提示词里没说的话,它就按最常见的补法补上。它不可能像经验丰富的开发一样反问:这个接口需要鉴权吗?文件名可不可以由调用方传?允许下载目录之外的路径吗?
打个比方:你让实习生去仓库拿个东西,老员工会问“拿哪一箱”“要不要登记”“不准去二楼”,AI实习生只会打开离门最近的那箱直接拿走。这个差异放到线上,就成了安全漏洞。
1.3 一组让我印象深刻的实测现象
我拿几款主流的AI代码工具做过一轮测试,需求都很常规:一个文件下载接口、一个用户注册接口、一个图片上传接口。结果高度一致:
- 文件下载接口:几乎都接受
?file=xxx这类由请求参数直接指定的文件名,不做路径限制;有的甚至不做登录校验。 - 用户注册接口:有多个版本直接明文保存密码,完全没提哈希。
- 图片上传接口:多数缺少扩展名白名单,有的只靠Content-Type判断。
这三个接口都是生产环境最常见、也最容易被扫描器盯上的东西。平时跑功能没一点问题,黑盒扫描一来全得改。所以我一直强调:AI生成的代码只能当作初稿,在真正上线之前,至少要做一轮带着“攻击者视角”的审查。
2. 漏洞一:密钥和连接参数写死在源码里
第一个漏洞是最常见、也最容易让人忽略的:密钥、数据库连接参数、第三方API Token直接以常量形式出现在源码里。
2.1 典型场景
你让AI写一个数据库连接或第三方API调用,它生成的代码大概率长这样:
import psycopg2 conn = psycopg2.connect( host="10.0.0.10", port=5432, user="admin", password="P@ssw0rd_2024", dbname="orders" )这种代码本地一跑就通,数据库也连上了,看起来其乐融融。问题在于:这段代码会进Git仓库,仓库会整个团队可见,说不定还会推到远程。只要仓库权限稍有不慎,或者代码库被导入扫描工具溯源,数据库密码就相当于公开在了所有能读代码的人面前。我遇到过不止一次,测试环境密码用一个万能密码,生产也复用同一个,改都没改。
2.2 为什么写死密钥是最容易爆的雷
密钥这种静态信息有个特点:改动一次很难彻底。Git有历史记录,就算你现在把密码改成环境变量并提交,老版本依然在历史里躺着。任何一次仓库泄露事故,都会把几个月甚至几年历次提交里的密钥一起带出去。
此外,密钥写死在代码里,还意味着你的部署完全依赖这份代码。任何拿到代码的人都有完整凭证,连人肉翻数据库都省了。
2.3 修复方案
先把密钥从代码里摘出去,改成从环境变量读取:
import os import psycopg2 conn = psycopg2.connect( host=os.environ["DB_HOST"], port=int(os.environ.get("DB_PORT", "5432")), user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"], dbname=os.environ["DB_NAME"] )改完代码还差两步。第一,检查你的Git历史,建议用gitleaks扫描仓库,看看密钥是否已经在历史提交中出现过:
gitleaks detect --source . -v第二,如果发现历史里已经暴露过,不要只改代码,必须真正去云平台或数据库里把密码轮换掉。只改代码不轮换,等于改了门锁钥匙还留在门口地垫底下。如果历史已经污染,可以用git filter-repo这类工具做历史清洗,但请注意:清洗解决的是“未来别在历史里找到它”,已经泄露过的密钥依然要轮换。
企业项目可以再接一层KMS或配置中心,把密钥从代码仓库彻底剥离,再配合细粒度权限和审计,这才是长期方案。
3. 漏洞二:正则嵌套量词拖垮服务的ReDoS问题
第二个漏洞知道的人更少,而且一旦被触发,效果相当吓人。
3.1 一次压测引发的卡死
之前一个小工具上线前做压力测试,QPS只有200,服务也顶得住。无意中有测试同学发了一串超长字符串到域名参数,单核CPU直接跑满,请求很久不返回,最后把服务卡得奄奄一息。查来查去,问题出在一段AI生成的“域名校验”正则上:
import re DOMAIN_RE = re.compile(r"^(([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,})+$") def is_valid_domain(domain: str) -> bool: return bool(DOMAIN_RE.match(domain))3.2 回溯为什么会指数级增长
这段正则的问题在于嵌套量词。正则里最外层是(...)+,里面又有个(...)?\.结构,还配了{0,61}。正常域名一两百个字符,怎么匹配都秒回。可一旦输入是一串不带点号的超长字符,引擎就得不断尝试各种切分方式,排列组合会指数级增长。这个攻击途径有个名字叫ReDoS,也就是正则表达式拒绝服务攻击。你甚至不需要多少流量,一个精心构造的请求就能让CPU飙满。
这类问题99%的人发现不了,真不是因为大家水平差——功能测试根本不会发这种字符串。跑两遍都正常,谁会怀疑一个校验函数呢。
3.3 修复:先限长,再简化,最后加超时
我现在的标准做法有三步。
第一步,长度限制。既然是域名,超过253个字符直接判非法,这是从RFC标准里抄的硬上限。绝大多数攻击载荷在一个函数开头就被拦掉:
if len(domain) > 253: return False第二步,简化正则。去掉最外面的嵌套量词,一个域名本来就该是标签的序列,不需要外层+包住整段结构:
DOMAIN_RE = re.compile( r"^(?=.{1,253}$)" r"([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*" r"[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$" )第三步,超时保护。如果你的正则无论如何都很难完全简化,或者你使用的正则库五花八门,那就给正则匹配设置超时。Python内置的re模块不支持timeout参数,但第三方regex模块支持;或者你能接受开线程/子进程的代价,就在外层加执行超时。核心原则是:绝不允许一个字符串让匹配逻辑无限占住CPU。
另外建议把校验函数和业务逻辑分开,方便单测里把超长字符串、畸形字符串都塞进去跑一遍。
4. 漏洞三:字符串拼接SQL,AI最爱的“经典款”
第三个漏洞,是AI最容易复刻的那种老病。
4.1 一个看起来完全正常的接口
你让AI用原生SQL写查询接口,它大概率输出下面这句:
import psycopg2 def search_orders(customer_name: str): sql = f"SELECT * FROM orders WHERE customer_name = '{customer_name}'" cur = conn.cursor() cur.execute(sql)看着没什么问题,字符串拼接可读性还很高。但customer_name只要传一个单引号,SQL结构就被破坏。比如传abc' OR '1'='1,整张表的数据都能被查出来。如果后端执行的是带事务的更新语句,后果更严重。
4.2 为什么老问题一直没绝迹
因为AI的训练素材里,“教学式拼接SQL”的代码太多了。十年前论坛上的答案、开源项目初期的提交,全是这么写。模型输出的正是它见过最多次数的写法。还有人会反驳“我用ORM,不存在这个问题”,但一旦涉及原生SQL、复杂报表或性能优化,AI的默认行为依然是拼接。
更隐蔽的是排序字段、表名、字段名这类元信息——即使你用ORM,ORDER BY后面的字段如果被拼接进来,一样会出问题。很多自带“高级排序功能”的后台接口,就是被这种拼接姿势打开的洞。
4.3 修复方案与白名单思路
参数化查询是最直接的答案:
def search_orders(customer_name: str): sql = "SELECT * FROM orders WHERE customer_name = %s" cur.execute(sql, (customer_name,))在psycopg2里用%s占位,在MySQLdb里也是%s,在sqlite3里是?,在Java的JDBC里也是?,在Go的database/sql里同样。核心思路都一样:用户输入只作为参数传递,永远不能参与SQL语句本身的解析。
对于ORDER BY这种动态字段,做白名单校验:
ALLOWED_COLUMNS = {"customer_name", "order_id", "created_at"} if sort_column not in ALLOWED_COLUMNS: raise ValueError("invalid sort column")最后提醒一点:SQL注入不是老话重提。每年安全报告里注入类漏洞仍然稳居前列,原因很简单——新代码一直在被生成出来,而生成这些新代码的AI,学的还是老套路。
5. 漏洞四:路径穿越,一个下载接口把服务器文件拖出去
第四个漏洞,几乎每个做文件下载的接口都中过。
5.1 典型代码
AI生成一个附件下载接口,最常见的版本是这个:
import os from flask import Flask, send_file, request app = Flask(__name__) UPLOAD_DIR = "/data/uploads" @app.route("/download") def download(): filename = request.args.get("file") return send_file(os.path.join(UPLOAD_DIR, filename))5.2 攻击演示
本地测试时你传的都是正常文件名,比如report.pdf,os.path.join得到/data/uploads/report.pdf,一切正常。但攻击者传一个../../etc/passwd呢?os.path.join会把../../原样拼进去,路径变成/data/uploads/../../etc/passwd,操作系统再解析一下就相当于/etc/passwd。如果服务进程权限足够,服务器上的系统文件、应用配置、甚至数据库备份都能被下载。
这就是路径穿越,也叫任意文件读取。线上服务一旦被扫出这种洞,通常直接定高危。
5.3 修复方案
先说最直观的修复:取真实路径后做前缀校验。
@app.route("/download") def download(): file_id = request.args.get("file") base_dir = os.path.realpath(UPLOAD_DIR) target = os.path.realpath(os.path.join(base_dir, file_id)) if not target.startswith(base_dir + os.sep): return "not allowed", 403 return send_file(target)这里有两个细节必须注意。第一,base_dir一定要先realpath,否则UPLOAD_DIR本身如果带符号链接就容易被绕过判断。第二,startswith必须带base_dir + os.sep,避免/data/uploads-这类前缀被构造出来骗过去。Windows部署的话,还要统一大小写后再做前缀比较,否则一样有绕过空间。
这种做法能挡住大部分路径穿越,但我要说的是:能挡,不等于优雅。
5.4 更彻底的做法
比较安全的方案是,数据库里保存的不是原始文件名,而是一个UUID随机文件名。用户请求时传的是文件ID,后端根据ID查出真实存储名,文件名本身不参与路径拼接。这样攻击者根本不可能通过文件名构造路径。
更进一步,文件下载本来也不该让应用服务器直接读磁盘。放到对象存储,生成预签名URL,让用户直接走存储桶下载,应用层只负责权限判断和URL签发。这样既隔离了服务器文件系统,又留下了下载审计。
如果业务里要解压用户上传的压缩包,同样要小心:压缩包里的文件名也能带../,解压前要么做路径校验,要么用安全的解压库。历史上有过不少因为解压路径没校验导致覆盖服务器文件的漏洞,这类问题还会在AI生成代码里继续出现,因为AI完全不懂“负向输入”的存在。
6. 漏洞五:并发读写同一份状态,流量一上来就翻车
第五个漏洞不依赖外部输入,但它照样能毁掉线上服务。
6.1 典型实现
AI生成的统计PV、API限流、库存扣减逻辑里,走全局变量或普通计数器的写法太多见了:
hits = 0 def track_hit(): global hits hits += 1 return hits单线程本地跑,这个函数永远是正确的。但线上服务并发几百上千,hits += 1这条语句在机器指令层面是“读出来、加一、写回去”三步。两个请求同时读到同一个旧值,各自加一再写回,最终只增加1次。计数少了是小事,如果这是库存、是提现次数限制,损失就实打实了。
6.2 这和“安全漏洞”有什么关系
很多人看到并发丢数据,第一反应是“业务BUG,不算安全”。但换个角度看:如果这是一个登录失败次数限制器,攻击者可以通过并发请求绕过限制,无限爆破;如果这是一个优惠券领取计数器,并发就能让发放数量超限;如果这是API限流器,绕过限流就意味着扫描攻击有了充足弹药。把并发正确性问题当成安全漏洞级别处理,一点不夸张。
更关键的是,这类问题极难在日常功能测试里暴露,因为本地开发环境几乎没有并发。流量一上去才开始出问题,线上排查成本立刻翻倍。
6.3 修复方案
并发场景的修复没有银弹,只有三个方向。
单机多线程场景,用线程锁:
import threading _lock = threading.Lock() hits = 0 def track_hit(): global hits with _lock: hits += 1 return hits这只能解决单进程内的竞争。服务一旦启动多个实例,锁在各进程之间互不认识,又要翻车。
跨实例场景,用原子操作。最常用的是Redis的INCR:
import redis from datetime import datetime r = redis.Redis(host="localhost", port=6379, db=0) def track_hit(): return r.incr("api:hits:20250101")INCR在Redis内部是单指令原子执行,多个实例并发调用也不会丢数。如果要做限流,可以结合expire:
def check_rate_limit(user_id: str, limit: int = 100) -> bool: key = f"ratelimit:{user_id}:{datetime.now():%Y%m%d%H}" current = r.incr(key) if current == 1: r.expire(key, 3600) return current <= limit这段代码在极端并发下,两个线程同时走到current == 1时会重复expire,但对最终限流效果没有实质影响;更严格的场景可以用Lua脚本把incr和expire合并成原子操作。
对于库存、余额这类强一致数据,应该在数据库层用UPDATE ... SET stock = stock - 1 WHERE stock > 0这样的原子减,并检查受影响行数,而不是读出来、减掉再写回。
6.4 异步场景的坑
如果你在用FastAPI这类异步框架,还要注意:线程锁在asyncio里不能直接套同步业务,阻塞会把整个事件循环卡住。异步代码里修计数器,优先走Redis等外部原子服务,或者用专门的异步锁原语。AI生成异步代码时,特别喜欢直接把threading.Lock塞进去,这个坑我见过不止一次。
7. 给AI生成的代码加一道“上线安检”
说完五个漏洞,来一份可落地的“上线安检”方案。我不会让你手工逐行看代码——虽然最终必须有一个人做这件事——但能把大部分坑自动化截住。
7.1 上线前自查清单
| 检查维度 | 具体检查项 | 常见工具/方法 |
|---|---|---|
| 敏感信息 | 代码和Git历史里是否有密码、Token、私钥 | gitleaks、git filter-repo |
| 注入漏洞 | SQL、命令、模板引擎是否有字符串拼接 | Semgrep、Bandit、CodeQL |
| 路径控制 | 用户输入是否直接进入文件路径、压缩包解压 | 人工审查 + Semgrep规则 |
| 输入校验 | 长度、字符集、边界值、超长字符串 | 边界测试、模糊测试 |
| 认证与授权 | 接口是否只校验登录、未校验数据归属 | 人工审查 |
| 会话管理 | Token是否存了明文、Cookie是否有安全属性 | 人工审查 |
| 依赖安全 | 第三方依赖是否有已知CVE | pip-audit、npm audit、Trivy |
| 日志与调试 | 是否在日志里打印密码、Token、个人信息 | 人工审查 + grep |
7.2 最小流程
我现在给AI代码过安检,用的是一套极简命令组合。以Python项目为例:
pip install bandit semgrep gitleaks pip-audit bandit -r . gitleaks detect --source . -v semgrep scan --config p/owasp-top-ten pip-auditBandit扫Python代码里的常见危险函数;gitleaks专门找密钥;Semgrep用规则扫描OWASP十大风险;pip-audit查依赖漏洞。四条命令跑完,至少能截住一半常见问题。如果你是Java项目,可以用SpotBugs加依赖扫描;前端项目用npm audit;容器化部署再叠一层Trivy扫镜像。
另外,如果你用的是自托管的研发管理平台,记得关注它公布的漏洞通告和升级提醒。这类系统一旦出问题,往往就是全局性的,优先级必须拉到最高。
但我要把话说清楚:工具只能证明“部分已知模式不存在”,不能证明代码安全。真正负责的还是人。我见过一个项目静态扫描全绿,却被一个“A用户传B用户的订单ID”的越权漏洞带走数据的。这种业务逻辑漏洞,工具很难发现,必须靠人。
7.3 让AI在写代码之前就遵守安全约束的提示词模板
与其等代码生成后再修,不如让AI在一开始就按安全模板生成。我在项目里反复用一套提示词,效果很好:
请实现一个用户注册接口,要求如下: 1. 密码不能明文存储,使用 bcrypt 或 argon2 做哈希加盐; 2. 所有数据库操作必须使用参数化查询,禁止字符串拼接; 3. 所有外部输入先做长度与白名单校验,再做业务处理; 4. 密钥、Token、数据库连接参数一律从环境变量读取,禁止硬编码; 5. 涉及文件路径、下载、上传时,文件名不得由请求参数直接决定; 6. 先写出接口的输入、输出、异常分支与安全边界,再写实现。这套提示词的价值在于把安全需求前置。AI在生成代码时会把你的约束当作上下文,输出的初稿质量高很多,后续修复成本从“全部重构”降级为“局部微调”。如果你在多个AI工具之间协作,也可以让一个模型生成实现、另一个模型专门审查前一个模型产物的输入边界和注入风险。这相当于给AI代码雇了一个AI审计员。
我还有个习惯:生成完代码后,直接追加一句“请列出这段代码里,在攻击者视角下可能存在安全问题的所有位置,并给出修复建议”。这句话经常能问出我自己都没注意的边缘情况,值得一试。
最后交代一句我的习惯。现在我用AI写代码,第一步不是让它直接写实现,而是先让它列出这个接口的所有输入、输出、异常分支和它认为的安全边界,再让它按这个设计写代码。写完代码之后,我还会问一遍:“这段代码里,有哪些输入在没有假设合法的情况下会导致安全问题?”就这一句话,经常能从AI的回答里发现我自己都没注意到的边界态。AI写代码这件事,我不但不反对,反而天天用,但我现在比从前更清楚一件事:AI输出的是语法正确的代码,上线前的那道安全护栏,始终得由人来搭。