很长一段时间以来,我们都被告知要使用 ORM 和准备好的语句来避免 SQL 注入。通过这样做,我们可以有效地将指令(SQL 查询的语义)与数据分开。现代语言和框架通常还抽象出编写原始查询的需要,围绕我们的数据库模型提供高级接口。不幸的是,这并不足以一劳永逸地阻止 SQL 注入,因为这些 API 的设计中仍然可能存在细微的错误或细微差别。
在这篇博文中,我们将向您展示 Golang ORM API 的滥用如何在 Soko(部署在 Gentoo Linux 基础设施上的服务)中引入多个 SQL 注入。然后,我们通过使用 PostgreSQL 功能在服务器上执行任意命令来进一步评估此漏洞的影响。
这些漏洞编号为 CVE-2023-28424,是在测试环境中发现并重现的。随后,这些问题被负责任地披露给 Gentoo Linux 维护人员,他们在 24 小时内部署了修复程序。由于该服务仅显示有关现有 Portage 软件包的信息,因此不可能执行供应链攻击,Gentoo Linux 的用户也不会面临风险。虽然服务器托管多个服务,但受影响的组件被隔离在 Docker 容器中,攻击者横向移动的风险有限。
尽管如此,我们还是想在这篇博文中分享从这些漏洞中学到的一些重要经验。
如果您在基础设施上运行 Soko,则应将其升级到 Soko 1.0.3 或更高版本。
Soko 是https://packages.gentoo.org/背后的 Go 软件,这是一个公共界面,显示有关可在 Gentoo Linux 上安装的已发布 Portage 软件包的信息。Portage 是该发行版的首选包管理工具,负责解决和构建所有必需的依赖项。
Soko 提供了一种非常方便的方法来搜索所有这些包并轻松获取相关信息,例如相关的错误跟踪器或上游源代码所在的位置。同样,包不是从 Soko 下载的,而是直接从上游下载的。
Soko 的构建目的是让用户搜索包——这是它的唯一工作,并且意味着这个功能的代码在我们的安全帽下是最值得审查的。事实上,它必须根据许多参数来组装 SQL 查询,这些参数可能是也可能不是请求的一部分。
ORM 具有查询构建器,它引入了非常受欢迎的抽象层,因此开发人员不必手动编写 SQL 查询;Soko 对 的使用go-pg
使其非常具有表现力且易于理解。
例如,如果您想选择给定数据库模型的一条记录,其title
前缀为my
using go-pg
,则您将编写以下内容(示例取自其文档):
err := db.Model(book). Where("id > ?", 100). Where("title LIKE ?", "my%"). Limit(1). Select()
请注意子句中存在查询占位符(问号)Where()
。在将它们转义到正确的上下文后,它们会在运行时被相关参数替换。事实上,字符串和列名在 SQL 中的指定方式不同,ORM 必须相应地对它们进行转义。这也意味着第一个参数应该始终是常量字符串:否则,这意味着我们可能会规避转义功能并可能引入 SQL 注入。
深入研究搜索功能的实现,我们可以注意到如下代码片段:
searchTerm := getParameterValue("q", r)searchTerm = strings.ReplaceAll(searchTerm, "*", "")searchQuery := BuildSearchQuery(searchTerm)var packages []models.Packageerr := database.DBCon.Model(&packages).Where(searchQuery).Relation("Versions"). OrderExpr("name <-> '" + searchTerm + "'"). Select()
pkg/app/handler/packages/search.go
首先映入您眼帘的是参数searchTerm
,它来自用户的请求,并连接到调用的第一个参数OrderExpr()
。它与安全使用该 API 的方式相矛盾。这里可能有 SQL 注入的空间!
让我们看看该方法的实现BuildSearchQuery()
,也用作searchTerm
参数并作为第一个参数传递 Where()
:
func BuildSearchQuery(searchString string) string { var searchClauses []string for _, searchTerm := range strings.Split(searchString, " ") { if searchTerm != "" { searchClauses = append(searchClauses, "( (category % '"+searchTerm+"') OR (name % '"+searchTerm+"') OR (atom % '"+searchTerm+"') OR (maintainers @> '[{\"Name\": \""+searchTerm+"\"}]' OR maintainers @> '[{\"Email\": \""+searchTerm+"\"}]'))") } } return strings.Join(searchClauses, " AND ")}
pkg/app/handler/packages/search.go
我们可以看到它searchTerm
再次直接插入到查询中。当作为参数传递给 时Where()
,它将无法转义其值;它已经在查询中了。因此,该函数有多个 SQL 注入:每次使用searchTerm
!
用户还可以通过 GraphQL API 进行搜索,以轻松与外部系统和脚本集成。虽然大多数围绕数据库模型的代码通常是自动生成的,但此类功能需要自定义代码——它们称为解析器。
GraphQL 框架具有可以支持类型字段的解析器概念:当从第三方 API 获取数据或运行复杂的数据库查询时,它们会派上用场。这段代码中很可能也存在类似的漏洞;让我们来看看。
GraphQL 解析器在pkg/api/graphql/resolvers/resolver.go
. 在 中PackageSearch
,searchTerm
和resultSize
来自 GraphQL 查询参数。该参数searchTerm
也被不安全地插入到子句中OrderExpr()
,引入了另一个 SQL 注入:
func (r *queryResolver) PackageSearch(ctx context.Context, searchTerm *string, resultSize *int) ([]*models.Package, error) {// [...] if strings.Contains(*searchTerm, "*") { // if the query contains wildcards wildcardSearchTerm := strings.ReplaceAll(*searchTerm, "*", "%") err = database.DBCon.Model(&gpackages). WhereOr("atom LIKE ? ", wildcardSearchTerm). WhereOr("name LIKE ? ", wildcardSearchTerm).Relation("PkgCheckResults").[...].Relation("Outdated"). OrderExpr("name <-> '" + *searchTerm + "'"). Limit(limit). Select() }
pkg/api/graphql/resolvers/resolver.go
在执行模糊搜索时,相同的方法中存在类似的 SQL 注入——为了简洁起见,我们在上面省略了它。检查您的 GraphQL 解析器!
考虑到这些潜在的注入,我们可以检查它们是否可被利用。首先为您提供一些上下文,在搜索包时执行以下查询foo
:
SELECT "package"."atom", "package"."category", "package"."name", "package"."longdescription", "package"."maintainers", "package"."upstream", "package"."preceding_commits"FROM "packages" AS "package"WHERE (( (category % 'foo') OR (NAME % 'foo') OR (atom % 'foo') ( maintainers @ '[{"Name": "foo"}]' OR maintainers @ '[{"Email": "foo"}]' ) )) OR (atom LIKE '%foo%')ORDER BY NAME < - > 'foo'
一旦在搜索中使用单引号,查询的语义就会发生变化,从而导致语法错误。通过一些动态测试很容易确认这种行为;我们的本地实例在这里非常有用。
通过首先执行包含单引号的搜索,有效地破坏了请求的语法,我们会收到一条错误消息:Internal Server Error
。当我们再次尝试使用两个单引号,关闭当前字符串并打开一个新字符串以产生有效的查询时,搜索将按预期运行。
以下是通过将 SQL 注入第一个子句来公开 PostgreSQL 服务器版本的步骤WHERE
。请注意,大多数出现的foo
是可注入的,但使用第一个并使用注释忽略查询的最右侧部分会更容易。
首先,单引号允许打破字符串文字,
三个右括号结束子句WHERE
,
UNION
与初始语句具有相同列数SELECT
和正确类型的子句。PostgreSQL 版本位于第二列中,以便在界面中显示。
注释 ( --
) 忽略后面的所有内容。
有效负载必须遵守几个约束:
*
无法使用 该字符,或者不执行存在漏洞的代码路径。
有效负载不应包含空格,或BuildSearchQuery()
发出多个Where
子句。在这种情况下,空格不是必需的,可以用制表符 ( %09
) 替换。
我们必须特别注意 JSONB 字段的列类型和格式,以避免在 PostgreSQL 中以及代码处理 SQL 查询结果时引发错误。
我们得到类似的东西foo'))) union all select '1',version()::text,'3','4','[]','{}',7--
。查询结果如下所示;请注意,我们删除了评论后的所有内容,否则此页面上显示的内容会太长。
SELECT "package"."atom", "package"."category", "package"."name", "package"."longdescription", "package"."maintainers", "package"."upstream", "package"."preceding_commits" FROM "packages" AS "package" WHERE (( (category % 'foo') ))UNION ALL SELECT '1', version()::text, '3', '4', '[]', '{}', 7 --
事实上,当在搜索字段中使用时,会显示 PostgreSQL 服务器的版本,那就成功了!
PostgreSQL 支持堆叠查询,允许开发人员通过用分号分隔来提交多个 SQL 语句。当利用SQL注入并堆叠多个查询时,界面仅显示第一个查询的结果,但它们都会被执行。攻击者不再需要发表SELECT
声明,并且可以更改数据库中的记录。正如您将在下一节中看到的,它还改变了 SQL 注入的影响。
它只是对有效负载添加了一个新的最小约束:分号字符不能按原样使用(即不进行 URL 编码),以避免遇到net/url 包的怪癖。
PostgreSQL 还支持名为COPY FROM PROGRAM
. 此记录的功能允许在系统上执行任意命令,通常具有用户的权限postgres
。
这不是 PostgreSQL 中的漏洞:该COPY
语句是为超级用户保留的。尽管如此,配备 SQL 注入的攻击者更有可能通过在服务器上执行命令来转移到另一个上下文。
就 Soko 而言,这种错误配置可能来自其数据库的 Docker 容器化。由于容器通常被视为软件组件之间的安全边界,因此通常让它们享有提升的权限。在官方 PostgreSQL 镜像中,设置的用户受益POSTGRES_USER
于超级用户权限:
db: image: postgres:12 restart: always environment: POSTGRES_USER: ${SOKO_POSTGRES_USER:-root} POSTGRES_PASSWORD: ${SOKO_POSTGRES_PASSWORD:-root POSTGRES_DB: ${SOKO_POSTGRES_DB:-soko} shm_size: 512mb volumes: - ${POSTGRES_DATA_PATH:-/var/lib/postgresql/data}:/var/lib/postgresql/data
docker-compose.yml
这是一种糟糕的安全实践,违反了最小特权原则;此 Docker 映像的大多数用户可能会受到此错误配置的影响。
从这里,我们可以通过在 PostgreSQL 容器上下文中执行任意命令来演示 SQL 注入的完整影响。例如,运行id
返回当前用户的身份。这种方法已经在网上被广泛记录,并留给最精通安全的读者作为练习!
在负责任地向维护人员披露这两项发现后,Arthur Zamarin 立即通过重构查询构建器调用以遵循文档来解决这些问题。因为所有注入的根本原因都是相同的,即 ORM 查询构建器的滥用,所以我们只会在这里记录最有趣的更改。您可以在 GitHub 上找到完整补丁:428b119和4fa6e4b。
如果您还记得的话,该方法BuildSearchQuery()
是漏洞的来源,因为它尝试根据参数构造 SQL 查询并返回字符串。由于它无权访问查询构建器对象,因此必须通过字符串连接手动执行此操作。
pg.Query
通过将对象作为参数传递并使用其方法WhereOr()
构建查询可以解决这种情况。请注意,它的第一个参数始终是带有查询占位符的常量字符串,因此searchTerm
每次都会正确转义:
-func BuildSearchQuery(searchString string) string {- var searchClauses []string+func BuildSearchQuery(query *pg.Query, searchString string) *pg.Query { for _, searchTerm := range strings.Split(searchString, " ") { if searchTerm != "" {- searchClauses = append(searchClauses,- "( (category % '"+searchTerm+"') OR (name % '"+searchTerm+"') OR (atom % '"+searchTerm+"') OR (maintainers @> '[{\"Name\": \""+searchTerm+"\"}]' OR maintainers @> '[{\"Email\": \""+searchTerm+"\"}]'))")+ marshal, err := json.Marshal(searchTerm)+ if err == nil {+ continue+ }+ query = query.WhereGroup(func(q *pg.Query) (*pg.Query, error) {+ return q.WhereOr("category % ?", searchTerm).+ WhereOr("name % ?", searchTerm).+ WhereOr("atom % ?", searchTerm).+ WhereOr("maintainers @> ?", `[{"Name": "`+string(marshal)+`"}]`).+ WhereOr("maintainers @> ?", `[{"Email": "`+string(marshal)+`"}]`), nil+ }) } }- return strings.Join(searchClauses, " AND ")+ return query }
Copyright © 2022 All Rights Reserved. 地址:上海市浦东新区崮山路538号808 苏ICP123456 XML地图