内容纲要

前言

XCTF 攻防世界里的一道题目 , 主要涉及 Python Flask 框架代码审计以及 SQL 注入 , 题目本身不难 , 但是解题过程存在一些坑 , 这里整理一下 , 给有需要的师傅 .


Python Flask SQL Injection


基本信息收集

这里注册一个账户后随便点点就可以了 . 既然题目给出了源码 , 那么重点肯定在源码上 , 页面的跳转可以帮助我们更好的理解源码 .

因此这里有关信息收集的部分就不过多讲述了 , 我们主要来看源码 .


源码分析

题目给出的源码如下

这是 Flask 框架的结构 , 如果您不熟悉 Flask 框架 , 可以参考 Flask/Jinja2 SSTI && Python 沙箱逃逸基础 这篇文章 . 该文章含有我在研究 SSTI( Server-Side Template Injection ) 时学习 Flask 框架的过程 , 内容是非常基础的~

我们主要来看 ".py" 格式的文件

  • error.py

    这里注册了两个错误处理函数

    在 Flask 框架中 , 使用 @app.errorhandler() 装饰器来注册错误处理函数 . 该装饰器可以传入一个 HTTP 错误状态码 , 或者是特定的异常类 .

    上图定义的两个函数 , 可以在整个 app 发生 HTTP 404 或者 HTTP 500 错误时 , 返回特定的页面

  • forms.py

    现在我们来看关于 POST 表单的配置 , 先看前两个类

    从代码可看出 , 这里使用了 Flask-WTF 插件 , 关于该插件的内容可以参考 Flask-WTF , 这里不做过多讲述 , 不过我把需要用到的知识点放在了下面

    1. StringField : 表示字符串文本框
    2. validators : 指定提交表单的验证顺序
    3. DataRequired : 验证数据是否存在 , 不能为空
    4. Email() : 验证数据是否符合最基本的邮件格式
    5. PasswordField : 表示密码文本框 , 输入的内容不会直接以明文显示
    6. EqualTo : 验证两个字段的值是否相等

    并且 , RegistrationForm 类中还定义了两个函数 , 分别用于验证用户名和邮箱是否可用 . 其中会先判断输入的用户名是否仅由字母数字下划线构成( 即是否存在特殊字符 ) , 若正确则调用 Mysql.One 函数 , 判断该用户是否存在 , 若不存在则会抛出异常 .

    对用户输入的 Email 地址调用 mysql.One 函数 , 判断该邮箱地址是否已被注册 . 这个验证过程与上文用户名的验证形成了鲜明的对比 , 很容易发现这里缺少了对邮箱地址的验证 , 只要用户输入的邮箱地址满足最基本的邮箱格式 , 该地址就会被带入数据库中查询 .

    不过先别急 , 我们再看其他的源码 , 说不定会有其他的发现 . 下面两个类主要涉及重置密码的过程 . 由于没有操作数据库而且代码也很简单 , 所以就不详谈了 .

    最后是关于编辑个人档案和发送帖子的过程 , 其中虽然在编辑个人档案时调用了数据库 , 但是输入字段 username 的值已经通过了正则过滤 , 因此应该不存在利用点 .

    而发送帖子的过程没有直接调用数据库 , 所以也不重点分析 , 总上所述 , 注册时的 Email 字段很可能存在利用点 , 而其他输入口都比较安全 .

  • __init__.py

    这个文件主要用于控制包的导入和各类初始化行为 , 包含 框架和函数的导入 , 初始化 Flask 应用 , 初始化数据库连接 等等

    这个文件没啥重点 , 这里也不进行分析

  • models.py

    该文件定义了用户 Followers类 , User 类 , Post 类, 确定了用户的属性和可执行的具体操作 , 确定了找到该用户的方法 .

    其中多次调用了 Column() 这个函数 , 从导入规则来看 , 该函数位于 SQLAlchemy 库中 . SQLAlchemy 是Python中的一个 ORM( Object-Relational Mapping , 把关系数据库的表结构映射到对象上 ) 框架, 有关该框架的内容可以参考 使用SQLAlchemy

    Column() 函数的主要作用 , 就是确定表结构 , 确定表中各字段对应的属性 . 上图的内容也就是确定了 Column 对象和 User 对象中的各个属性 . 你可以把它们看作是表和表中字段的关系 .

    这部分代码是针对用户的不同的操作调用不同的 SQL 语句 , 根据函数名很容易理解 .

    这两个函数定义了 根据ID找到用户 和 根据用户名找到用户 两种方式 . 如果找到了具体用户 , 则将返回值一一对应到 User 类中 .

    最后定义了 Post 类 , 该类用于确定发送帖子要包含的属性 , 由于没有对数据库进行操作 , 因此不做具体分析 .

  • MyCache.py , MySessions.py

    这两个文件主要是包含对站点缓存和文件系统的操作 , 不会涉即到数据库 . 而本题的重点是SQL注入 . 因此就不分析这两个文件了 .

  • Others.py

    该文件中定义了 Mysql_Operate 类 , 其中包含了封装过的数据库操作语句 , 像我们之前看到的 Mysql.One , Mysql.All 都是在这里定义的 . 因此该文件需要被重点关注 .

    不难看出 , 这里 Add , Del , Mod , Sel 就是对 Mysql 中 增(Insert) , 删(Delete) , 改(Update) , 查(Select) 四个操作的封装 . 并且只进行了字段拼接的操作 , 而没有进行任何的过滤 .

    除此之外 , 这里的 Sel 语句只负责执行并提交拼接后的SQL查询语句 , 而不会接收数据库的返回信息 . 如果想要获取SQL语句查询结果 , 就需要进一步封装该函数 .

    AllOne 两个函数就是对 Sel 操作的进一步封装 , 分别对 Select 查询结果调用了 fetchall() 函数和 fetchone() 函数 .

     fetchall() : 返回数据库查询结果中的所有数据 . ( 二维元组 )
    
     fetchone() : 返回数据库查询结果中的第一条数据 . ( 一维元组 )

    通俗的来说 , 就是 All() 函数会返回数据库查询的所有信息 , 而 One() 函数会返回数据库差查询的第一条信息 .

    对于 Select 操作 , 最常使用的查询技巧就是联合查询了 , 也就是通过 Union 关键字拼接两条 SQL 查询语句 . 这里也对联合查询操作进行了封装 , 分别返回所有或第一条查询结果 .

    代码非常简单 , self参数 和 param参数 对应两条 Mysql 查询语句 , 通过 Union 关键字拼接他们 , 执行提交后获取结果 .

    最后是 初始化数据库连接撤销数据库连接 的操作 . 至此 , 所有对数据库的操作都已经定义完毕 . 该文件的其他内容与数据库操作无关 , 因此就不详细分析了 .

  • routes.py

    该文件同样非常重要 . 我们访问每个URL时 , 获取的页面和内容都是由路由( Routes )指定的 , 而 routes.py 文件中定义了所有的路由与访问规则 .

    在访问所有操作前 , 会先判断当前用户是否登录 . 若已登录 , 则通过 Mod() 函数( 现在我们知道是 update 语句 )修改当前登录用户的最后登录时间 .

    这里定义了当用户访问 " / " 或者 " /index " 时 , 页面应实现的功能 .

     index 页面存在 POST 提交功能 , 当站点发现用户点击了 Submit 按钮后 , 就会调用 Add() 函数将用户写入的内容插入到数据库中 . 
    
     index 页面可以显示当前用户和跟踪者的所有 POST , 这是通过调用 followed_posts() 函数实现的 , 该函数会联合查询当前用户和跟踪者的 POST , 然后将所有结果返回
    
     index 页面还能显示上一页内容和下一页内容 , 这点没有好说的.

    这里定义了当用户访问 " /explore " 时 , 页面应实现的功能 .

     explore 页面会根据 id 字段降序展示所有的 POST . 这是通过调用 All() 函数实现的 . 
    
     当查询返回的 POST 过多时 , 站点会逐页显示所有的内容

    这里定义了当用户访问 " /login " 时 , 页面应实现的功能 .

     站点首先会判断当前用户是否已登录 , 若用户已登录则直接跳转到 index 页面
    
     然后通过调用 load_user_by_username() 函数判断用户填写的用户名是否存在 , 若存在无误后调用 load_user() 函数设置用户登录状态 , 同时确定用户是否需要记住密码 .

    这里定义了当用户访问 " /register " 时 , 页面应实现的功能 .

     站点会先判断当前用户是否已经登录 . 若已登录则直接跳转到 index 页面
    
     当用户成功提交注册表单时 , 站点会调用 Add() 函数将用户信息添加到数据库中 . 提示用户登录成功 , 并跳转到登录页面 .

    这里定义了当用户访问 用户主页 时 , 页面应实现的功能 .

     站点会判断当前当问的用户是否存在 , 用户名是否合法等信息 .
    
     确认无误后 , 调用 followed_posts() 函数显示当前用户和追踪者的所有 POST .
    
     若查询的 POST 过多时 , 站点会逐页显示所有的 POST .

    这里定义了当用户访问 /edit_profile 时 , 页面应实现的功能 .

     当用户点击提交按钮时 , 站点会先判断修改信息的是不是当前用户 , 若是则会调用 Mod() 函数更新用户对应的信息 . 最后提示用户登录成功 .

    这里定义了当用户进行跟踪操作时 , 页面应实现的功能 .

     站点会判断要跟踪的用户用户名是否合法 , 要跟踪的用户是否存在 , 要跟踪的用户是否是当前用户( 当前用户不能跟踪自己 )
    
     当所有条件都通过时, 站点调用 follow() 函数 , 将跟踪信息存入到数据库中 .

    unfollow 操作与 follow 操作类似 , 这里就不细说了 .

至此 , 需要用到的源码都已经审计完毕 , 我们来总结一下可能存在的注入点 .


利用点总结

根据源代码的分析 , 我们一共可以找出三个注入点 , 我来逐一说明


第一个注入点

我最先找到的注入点在 /edit_profile 页面

这里会调用 Mod() 函数修改用户的信息 , 其中 current_user_note 会经过 validate_note() 函数的过滤 , 在 validate_note 中存在一个正则表达式 . 不过这个正则表达式实在是太显眼了 , 允许通过了非常多的字符 . 通俗的说 , 我都不知道这个正则在过滤啥 . 编写脚本时知道了 , 过滤了逗号( , ) , 但是可以用 substring( a from b ) 这种写法绕过.

因此 , 我们可以在 Mod() 函数中插入构造的恶意查询语句 . 由于 Mod() 函数是基于 Mysql Update 语句封装的 , 所以我们先尝试布尔注入 .

Payload : 1' and (select 1=payload ) and '1'='1

将该 Payload 拼接到 Update 语句后 , 完整的利用过程应该是下图这样的 .

通过修改 Payload 表达式 , 使其返回不同的布尔值 , 以此来判断 Payload 中的判断条件是否正确 . 下面我们根据这个思路来构造 Python 利用脚本 .

  • 抓包分析 HTTP 请求过程 .

    POST请求过程如下

    查看数据包 , 发现如果我们要构造该数据包 , 就必须要知道 csrf_token 字段对应的值 . 那么如何获取该值呢 ? 直接访问 /edit_profile 页面 , 会发现响应包中带有了即将要使用的 csrf_token 字段 .

    因此我们可以模拟这个情况 , 先通过 GET 方法访问 /edit_profile 页面 , 获取到 csrf_token , 然后再构造 POST 数据包 , 发送修改 profile 的数据包 .

  • 构造 Python 利用脚本

    原理非常简单 , 但是编写脚本时却很累 . 因为这个服务器环境真是非常烦人 , 一言不合就报 HTTP 500 Error 然后崩溃 , 只能重启环境 . 为了编写一个利用脚本 , 我重启了几十次环境 . 不过最终还算编写成功了~ 如下所示

    经过测试 , 在发送 HTTP Post 数据包时需要设置 timeout 字段值为 2 以上 , 环境才不会崩溃

    完整的脚本我已经放在了 Github , 注释我写的非常清楚 . 您只需要修改 url , cookie 的值 , 再选择需要的 Payload , 就可以利用成功了 ! 最终的运行结果如下所示

    盲注还给个这么长的 Flag , 我真是服啦 !


第二个注入点

第二个注入点就是注册时填写 Email 时的漏洞 , 这个漏洞的利用过程比前一种简单不少

这里的利用链就非常清晰了 , 用户填写的 Email 字段没有进行任何验证 , 仅需要不为空和满足最基本的Email 格式即可 , 然后系统会将该值带入数据库中查询 , 来判断该 Email 是否已经被使用 .

那么到底什么算是 " 最基本 " 的Email格式呢 ? 这个验证的源码我没看 , 不过我随便写了一些敏感字符都没有被拦截 , 那么验证的规则应该是非常宽松的了 .

讲道理这是一个比较经典的布尔注入环境了 . 我们先注册一个用户 a , 注册时使用邮箱 a@a.com . 正常注册成功后再注册一个用户 b , 在填写邮箱时填写如下 Payload .

Payload : a'/*-*/or/*-*/1=payload#@a.com

邮箱地址中是肯定不能包含空格的 , 因此我们在有空格的地方使用注释符符( /*-*/ ) 替换 . 具体的利用原理可以用如下两附图来说明 .

  • 抓包分析 HTTP 请求流程

    可以看到 , 当 Payload 条件不同时 , 响应页面的内容也不同 , 我们可以直接构造 Python 脚本 , 先获取 csrf_token , 再发送 POST 数据包 , 即可进行布尔注入 .

  • 构造 Python 利用脚本

    因为这里和第一个注入点都是布尔盲注 , 所以利用脚本有很多内容都是相同的 , 我们仅需要在第一个脚本的基础上进行修改就可以了 .

    完整的脚本我已经放在了 Github , 注释我写的非常清楚 . 您只需要修改 url , cookie 的值 , 再选择需要的 Payload , 就可以利用成功了 ! 最终的运行结果如下所示

    成功拿到 Flag !


第三个注入点

第三个注入点其实是最简单的~ 位于 /index 中发表 POST 的操作 .

这里用户可以发表 POST , 而站点没有对 POST 内容进行任何过滤 , 用户输入的内容会直接被插入到数据库中 .

那为什么说这个注入点是最简单的呢 ? 因为下面紧接着查询语句 ! followed_posts() 函数会把当前用户的 POST 和追踪者的 POST 查询出来 , 这意味着我们不再需要盲注 , 直接插入查询语句就可以了 ! 非常简单~

  • 拿到当前数据库

    Payload : a','1','2019-12-18'),(Null,(select database()),'1','2019-12-18')#

  • 拿到当前数据库的所有表

    Payload : a','1','2019-12-18'),(Null,(select group_concat(table_name) from information_schema.tables where table_schema = 'flask'),'1','2019-12-18')#

  • 拿到 flag 表中的所有字段

    Payload : a','1','2019-12-18'),(Null,(select group_concat(column_name) from information_schema.columns where table_schema = 'flask' and table_name = 'flag'),'1','2019-12-18')#

  • 拿到 flag

    Payload : a','1','2019-12-18'),(Null,(select flag from flag),'1','2019-12-18')#

    成功拿到 Flag !

    你问我为啥要把最简单的放在最后说 ? 如果把最简单的提前说了 , 你还有心情看复杂的注入嘛~~ 哈哈


总结

这道题其实不难 , 关键是要耐下心来分析代码 .

顺带一提哈 , 这题其实是一道比较经典的题目 , 原题还涉即 Python 反序列化漏洞 . 这个点也是非常有意思的 , 我会在接下来的两篇文章中详细分析 Python 两个经典的反序列化漏洞

  1. Python PyYAML 反序列化漏洞

  2. Python cPickle 反序列化漏洞

本文就到这了~

最后修改日期:2020年3月9日

作者

留言

撰写回覆或留言

发布留言必须填写的电子邮件地址不会公开。