HTB Sink Walkthrough: HTTP Request Smuggling and AWS Privesc

Hack The Box Sink write-up: HTTP Request Smuggling, CVE-2019-18277, HAProxy, Gunicorn, Gitea, Ligolo-ng, AWS LocalStack, KMS, and Secrets Manager.
- Posted on 2026-09-08
- Reading time: 15 min
HTB Sink Walkthrough: HTTP Request Smuggling and Privilege Escalation #
In this walkthrough by HTB Sink, we see how to exploit an HTTP Request Smuggling vulnerability related to HAProxy and Gunicorn to gain the administrator session, log in to Gitea, and progress to root through AWS LocalStack, Secrets Manager, and KMS.
Sink is a machine rated Insane on HackTheBox—the highest difficulty on the platform. For this reason, it proved to be a great opportunity to really delve into HTTP Request Smuggling: not a CVE to be launched with an automated tool, but a mechanism that must be understood piece by piece, byte by byte, before being able to make it work reliably. This walkthrough follows exactly that path—including the errors and failed attempts along the way, because they were integral to understanding the bug.
Machine information #
| Countryside | Value |
|---|---|
| Platform | Hack The Box |
| Name | Zinc |
| OS | Linux |
| Difficulty | Insane |
| Focus | HTTP Request Smuggling |
| CVE | CVE-2019-18277 |
| Main services | HAProxy, Gunicorn, Flask, Gitea, LocalStack |
1. Initial reconnaissance #
Port scanning with mynmap (a custom wrapper for nmap, designed for CTFs: it automatically performs fast TCP discovery, service/OS detection, vulnerability checks, and UDP scans in sequence). A Gitea instance is running on port 3000 , but without credentials, there's not much to see right away—private repositories, little else accessible. We then move to port 5000 , where a web application is running.
A quick first check on the response headers:
curl -sI http://sink.htb:5000/ | grep -iE "server|via|x-served|x-cache|cf-ray|x-amz"Server: gunicorn/20.0.0
Via: haproxy
X-Served-By: c8361846ea2cTwo useful pieces of information right away: the backend is Gunicorn (a Python application server used to serve the Flask app), and the frontend is running HAProxy as a reverse proxy. Whenever you see this combination—a separate frontend and backend exchanging the same request—it's worth checking whether the two interpret the body length differently.
A quick search for known vulnerabilities in Gunicorn 20.0.0 turns up nothing usable—the results are for CVEs too recent to be plausible for the age of this machine. It's best to focus on HAProxy , which doesn't list its version number anywhere at first glance.
Trick to get her out in the open: by sending a deliberately truncated/malformed request to Burp Repeater (e.g. a GET /notes without full headers, cut in half), HAProxy fails to route it correctly to the backend and responds with an error page itself 400 Bad Request — and that error page, generated directly by HAProxy, always includes the header Server: haproxy X.Y.Z with the exact version. A clean way to fingerprint the front end without even touching the back end.
The app also exposes some useful features as a starting point for the attack: a notes system (/notes) and comments (/comment), both linked to the logged in user's session.
2. HTTP Request Smuggling #
Before launching any payload, it was necessary to truly understand why the bug existed, not just copy an exploit. The reasoning was reconstructed step by step. For a more general reference guide on HTTP Request Smuggling (CL.TE, TE.CL, cache poisoning, cookie theft), see also HTTP Request Smuggling: Desync, Cache Poisoning, and Cookie Theft on Hackita.
2.1 Content-Length vs Transfer-Encoding #
An HTTP/1.1 server can find out where the body of a request ends in two alternative ways:
Content-Length— declares the exact number of bytes in the body. Simple example: if I send the word “hackita” (7 letters), I writeContent-Length: 7, and the server reads exactly 7 bytes and stops there.Transfer-Encoding: chunked— the body arrives “in chunks” rather than all at once. Each chunk begins with a number that tells you how many bytes of data it contains. that specific piece, then the data arrives. You can send1followed by a single byte, then another1followed by another byte, and so on. The special piece0has no data after — it's the "I'm done, I won't send anything else" signal. This behavior (including the meaning of the chunk0) is defined in the official HTTP/1.1 specification, the RFC 7230.
RFC 7230 is, in a nutshell, the official document that defines how HTTP/1.1 should work — including the Content-Length and Transfer-Encoding rules we discuss here.
Why chunked exists, basically: Content-Length It only works well if you know how in advance How large is the data to be sent? But this isn't always the case—think of a live stream, a file generated on the fly, or a response the server is still composing as it sends it: in these cases, the server has no idea how many bytes it will send until it's finished. Chunking solves just this: it sends what it has ready, one piece at a time, without needing to know the total in advance—and the recipient only knows it's finished when the chunk arrives. 0.
If a request contains both headers, RFC 7230 requires that it wins always Transfer-Encoding, ignoring Content-LengthThis rule is the heart of everything: it's precisely because TE always wins over CL (when recognized) that, later on, Gunicorn ends up following TE while HAProxy follows CL—the same rule applied by two servers seeing two different versions of the same header. This is the correct and expected behavior—but it's also the starting point of the bug: if two different servers (front-end and back-end) don't apply this rule in the exact same way on the exact same request, a disagreement arises about where one message ends and the next begins.
2.2 CVE-2019-18277 and HAProxy #
Googling more specific information about HAProxy related to this type of CL/TE conflict, the article that describes the exact bug in question pops up: nathandavison.com/blog/haproxy-http-request-smuggling (CVE-2019-18277).
HAProxy, in vulnerable versions (including those used on Sink), correctly handles CL/TE conflict when the header Transfer-Encoding it's written clearly. But if you enter a control character — specifically \x0b (vertical tabulation) or \x0c (form feed) — immediately after the colon in the header, before the word chunked:
Transfer-Encoding:[\x0b]chunkedHAProxy he no longer recognizes this as a Transfer-Encoding valid. Therefore:
- Stop considering chunked encoding.
- Fold back on
Content-Lengthto decide where the request ends. - It does not remove the header
Transfer-Encodingbroken by the request it forwards to the backend — leaves it written as is, and passes it on.
This alone is not enough to create smuggling: even if the backend ignored that malformed header (correct behavior according to RFC 7230), the two servers would still agree (both would use Content-Length) and there would be no disagreement.
How to insert the byte \x0b in Burp. Type a non-printing character directly as \x0b This isn't practical in the Burp editor—many editors ignore or transform it. The way to generate and insert it correctly is:
echo "\x0b" | base64This produces a base64 string representing that byte. Copying that string and using the function Decode of Burp (Decoder tab, or by selecting the text in the editor and choosing “Base64 decode” from the context menu), you get the raw byte \x0b pasted directly in the right place of the request, between the two points of Transfer-Encoding: and the word chunked — bypassing the problem of writing it by hand.
2.3 Gunicorn's behavior #
Gunicorn (in vulnerable versions/configurations, such as the one behind Sink) does not refuse the header Transfer-Encoding malformed. Instead it “cleans” it internally (removes the control character) and accepts it as chunked valid.
In the case of Sink, HAProxy and Gunicorn end up interpreting the exact same HTTP request differently. Simply put: HAProxy sees the strange byte. \x0b, he doesn't understand it, but he doesn't delete it - he leaves it written and carries on with the request as it is, using him Content-Length to decide where to stop. Gunicorn rereads it from the beginning, he finds the same Transfer-Encoding (still present, not removed by HAProxy) and, instead of discarding it as it should, accepts it — and since between the two headers it always wins Transfer-Encoding When it's recognized, Gunicorn ends up using it. The result: two servers, the same request, two different reads—and it's precisely this discrepancy that allows a second hidden request to be inserted on the connection to the backend.
2.4 Connection reuse and keep-alive — the shared “pipe” #
HAProxy, for efficiency, does not open a new TCP connection to the backend for every single request — it keeps a few open, and reuses them to serve requests from multiple different users in sequence (keep-alive / connection pooling).
A simple way to think of it: think of a single pipe connecting HAProxy to Gunicorn, through which requests from many different users pass, one after another—not one pipe per person, but one shared by all. This is exactly what makes smuggling dangerous: if an attacker sends a request that leaves the backend "waiting" for more bytes to be considered complete, and immediately afterward—on the same pipe—a request from another real user passes, those extra bytes (request line, header, and cookie included) are interpreted as if they were a continuation of the attacker's request, rather than a separate request.
There is no control over which specific tube is used or who will use it again immediately afterwards — which is why the attack often has to be repeated several times before finding the right moment.
3. Vulnerability check #
3.1 Diagnostic test #
The first instinct was to build a request with a chunked body intentionally incomplete (without the terminating chunk) 0\r\n\r\n) it's a Content-Length short:
POST /notes HTTP/1.1
Host: <target>:5000
Content-Length: 4
Transfer-Encoding: chunked
Cookie: session=...
1
ZThis does indeed time out—but it doesn't prove anything , because an incomplete chunked body blocks any server that successfully reads the chunked body, vulnerable or not. It doesn't isolate the specific bug.
3.2 Comparison with/without \x0b #
To really isolate the bug you need to use the exact same chunked body (a single one-byte chunk: 1\r\nZ, without the terminating chunk 0\r\n\r\n final) and compare the behavior with and without the byte \x0b in the header:
- Without
\x0b: HAProxy reads correctlyTransfer-Encoding, ignore theContent-Lengthshort, forward the entire message → normal reply, no timeout. - With
\x0b: HAProxy falls back toContent-Length, cuts the request in half (just before the terminating chunk that the body should have), the backend (which reads TE) waits for a final chunk that will never arrive → true timeout (visible as a request “pending” for a long time or an explicit error, not just a perceived slowness).
Only the difference Between these two behaviors, test the bug cleanly. On Sink, having a direct point of contact (/notes, which shows the saved content), the diagnostic test becomes secondary: you can go directly to the real exploit and observe the concrete result.
An important warning: even the “clean” test has a practical limitation, which emerged during real-world testing. Endpoints such as /notes o /comment However, they do have some normal latency (writing to disk/database), which can look like a “suspicious” delay even when there is no CL/TE disagreement in progress. A wait of one or two seconds is perceived by eye, without an explicit Burp error (a real timeout, or a 504 Gateway Timeout returned by HAProxy itself), it is not a reliable confirmation —it's easy to mistake it for a false positive. The timing technique remains a probabilistic clue, not as definitive proof as actually seeing the captured data in plain text.
4. Exploit: Capture the admin session #
4.1 Payload structure #
The idea is to send a single HTTP request that HAProxy sees as a single, complete block, but that Gunicorn reads as two separate requests :
- A first request (e.g.
POST /oPOST /home), withContent-LengtheTransfer-Encodingconflicting (HAProxy bug). - A chunk
0which closes — from Gunicorn's point of view (reading TE) — this first request. - Soon after, the beginning of a hidden second HTTP request (
POST /notes), with aContent-Lengthintentionally larger of the content actually sent — so Gunicorn considers it incomplete and waits for more bytes to complete it.
POST / HTTP/1.1
Host: <target>:5000
Content-Length: <N>
Transfer-Encoding:[\x0b]chunked
Cookie: session=<sessione attaccante>
0
POST /notes HTTP/1.1
Host: <target>:5000
Content-Length: 290
Cookie: session=<sessione attaccante>
note=Because that 0 is critical. Summarized in a few lines, without mincing words:
- The first line of the request (
POST /) uses chunked. It0belongs to her: is the chunk that says “the body of this first request ends here”, nothing more. - Immediately after quello
0, Gunicorn starts reading from scratch again — and what he finds (POST /notes ...) interprets it as the beginning of a request completely new and separate, not as a continuation of the first. - This second request (
POST /notes) no longer uses chunked — use a normal oneContent-Length, which is deliberately declared higher (e.g., 290) than the actual value sent. Gunicorn therefore considers it incomplete and waits for the other bytes to arrive to "fill" it up to 290.
Without that 0, Gunicorn would never have a point at which to consider the first request closed — it would continue to read everything as one jumbled block, and POST /notes would never be recognized as a request in itself (the practical result, seen during testing, is a parsing error like Invalid Request Line). With the 0 In the right place, however, everything works: the first request is closed, the second request is open and waiting — exactly the behavior needed for the attack.
The sense of Content-Length: 290 in the hidden request. Here a higher number (290) is declared than what is actually sent (note=, a few bytes). Gunicorn reads those few bytes, sees that they are not enough to reach 290, and remains on hold —think, "There's still some stuff missing to complete this request." That missing bit is exactly the space that, a moment later, is filled by the next request arriving on the same shared connection: if it's a request from another real user, their bytes (header, including cookies) end up stuck there, until they reach the declared 290.
Another critical point, discovered with a concrete error during testing: il Content-Length of the first request must cover exactly all the bytes that follow the blank line, until the end of note= included — chunk 0, blank line, entire request POST /notes with its headers, blank line, note=An incorrect value (too short) cuts a hidden request header line in half, confusing Gunicorn on subsequent attempts on the same connection (errors like Invalid HTTP request line: 'Length: 290', a symptom of a cut that occurred right inside the word Content-Length).
Let Burp automatically recalculate the Content-Length of the first request (function “Update Content-Length” active) proved to be more reliable than calculating it by hand — while the Content-Length: 290 of the hidden request must be set manually, purposely higher than necessary.
4.2 The support point /notes #
Smuggling alone captures raw bytes on the connection—you still need a way to see them . Typical options are:
- An endpoint that save and show what is sent to him (here:
/notes) — the stolen request ends up saved as text, which can be read at a later time. - A “reflection gadget” — a page that mirrors in its HTTP response what is sent to it in the body, allowing you to see the result immediately.
- Response queue poisoning — a more advanced technique for directly receiving the response intended for the victim, when neither of the two above are available.
On Sink, /notes It's exactly what it's for: every note created is displayed in full in the logged-in user's web interface.
4.3 Connection pool and victim request #
- The attacker's malformed request is sent. HAProxy, fooled by the byte
\x0b, considers it a single complete request and forwards it to Gunicorn over the persistent (keep-alive) connection to the backend. - Gunicorn, reading in chunked mode, finds the chunk
0and consider the first request concluded. - Read more on the same connection: find the beginning of
POST /notesaContent-Length: 290declared but only a few bytes (note=) actually received — it therefore remains on hold of more bytes to consider this second request complete. - If, before the connection times out, a another real user (ideally a privileged account — on Sink, the administrator) sends a request that ends up routed over the same reused connection to the backend, Gunicorn interprets it as the continuation of the body of
POST /notes— not just the text, but the entire raw request: request line, header, and most importantly the session cookies. - The result is saved as a note in the attacker's account, until the missing bytes declared by the attacker are filled.
Content-Lengthprocedure.
Because you have no control over which connection in the pool gets “dirty” or who will reuse it immediately afterward, the attack often has to be repeated multiple times to be successful — it is a probabilistic mechanism, not 100% deterministic.
4.4 Session hijacking #
After repeating the malformed request a few times, one of the notes created appeared containing the beginning of a real request generated by the administrator:
GET /notes/delete/1234 HTTP/1.1
Host: <target>:5000
User-Agent: Mozilla/5.0 ...
Cookie: session=eyJlbWFpbCI6ImFkbWluQHNpbmsuaHRiIn0.XXXXXX.XXXXXXXXXXXXXXXXXXXXXXXXXXXX
X-Forwa...The cookie session contains, decoded, the email admin@sink.htb — the administrator session, stolen via request smuggling. By replacing your session cookie with this one in the browser (or in Burp), you gain administrator access to the application.
5. From admin to marcus via Gitea #
5.1 Git history #
With the admin session, you can log in to the Gitea instance (port 3000) as a user with elevated privileges. Exploring the available repositories, one in particular— Key_Management —contains, in its commit history, an SSH private key that was added and then subsequently removed in a “cleanup” commit (visible by comparing the removed lines in red with the added lines in green in the commit diff).
5.2 Recovering the SSH key #
The key belongs to the user marcus (He's the author of the removal commit.) Copying the contents of the old revision (visible in the commit's diff, the red part) to a local file:
vim id_rsa_marcus
# incollare il contenuto della chiave privata, salvare
chmod 600 id_rsa_marcus
ssh -i id_rsa_marcus marcus@sink.htbShell obtained as marcus — and with it, user.txt.
The same repository also contains a file ec2.php which references, among other things, a ARN of a KMS key (arn:aws:kms:eu:...:key/...) — first clue about the AWS/LocalStack infrastructure used internally by the machine, which will come in handy later.
6. Pivoting with Ligolo-ng #
6.1 Routing problem #
To reach internal services of the machine (in particular, a local AWS endpoint bound only to 127.0.0.1), a tunnel was necessary. With Ligolo-ng:
- On the proxy (Kali): starting the listener and loading the TUN interface.
- On the agent (Sink, run as
marcus): connection to the listening operator. - Starting the tunnel session (
start).
Error encountered and cause: adding a route to the real IP of the target machine (ip route add <IP-di-Sink>/32 dev ligolo), the tunnel repeatedly crashed immediately after starting (Agent dropped(Continuous reconnections). The reason: that IP is the same one used by the control connection between the agent and the proxy. Routing that traffic through the tunnel itself creates a loop—the control connection's traffic ends up redirected into the tunnel, which relies on that same connection to exist.
Solution : Routes should only be added for new networks , actually behind the pivot (hosts not directly reachable) — never for the IP of the machine hosting the agent itself.
6.2 Accessing localhost via 240.0.0.1 #
To reach a service bound only on 127.0.0.1 on the target machine itself (not on a different host behind it), Ligolo-ng offers a dedicated “magic” address:
sudo ip route add 240.0.0.1/32 dev ligoloQuestioning 240.0.0.1 instead of the real IP, traffic is automatically redirected to 127.0.0.1 from the agent's point of view — allowing you to reach internal doors without creating the loop seen above.
Practical test:
nc -zv 240.0.0.1 4566
# 4566 open — il servizio interno risponde attraverso il tunnel7. AWS LocalStack Enumeration #
The machine had the AWS CLI installed and, listening only on localhost, an instance of LocalStack (a local AWS simulation, used here for testing/development — not the real AWS on the internet).
Configuring CLI to point to local endpoint (via tunnel):
alias aws='aws --endpoint-url=http://240.0.0.1:4566'
aws configure # richiede comunque una region, es. us-east-1, e le chiavi trovate nei repo GiteaTrying out the basic commands (aws sts get-caller-identity, aws iam list-users, aws organizations list-accounts) all returned errors — not because of a lack of permissions, but because those services were not active at all on this LocalStack instance. Only two services were actually working: KMS e Secret Manager.
For those new to AWS, a quick reference: account = the complete container of users/permissions/resources; region = the physical data center where the resources live; IAM = manages users and roles (a role is a temporary identity, obtained via STS ); Secrets Manager = container for credentials from external services; KMS = manages encryption keys (encryption/decryption or digital signature).
7.1 Secrets Manager #
aws secretsmanager list-secrets --region us-east-1Three secrets available: Sink Panel, Jenkins Login, Jira SupportReading the value of each:
aws secretsmanager get-secret-value --secret-id "Sink Panel" --region us-east-1
aws secretsmanager get-secret-value --secret-id "Jenkins Login" --region us-east-1
aws secretsmanager get-secret-value --secret-id "Jira Support" --region us-east-1Clear text credentials retrieved for three different users (username + password), including the user's own david — password derived from the secret Jira Support.
7.2 Recovering David's Credentials #
Da marcus, user change:
su davidIn the home of david, inside Projects/Prod_Deployment/, a file servers.enc — binary data, not readable text, clearly encrypted.
7.3 KMS #
aws kms list-keys --region us-east-1Eleven keys listed (ID/ARN only, no cryptographic material directly exposed—by design). Querying the policy and details for each:
for keyid in $(cat lista_key_id); do
echo "--- $keyid ---"
aws kms describe-key --key-id "$keyid" --region us-east-1
doneMost share the same permissive default policy ("Action": "kms:*" granted to the root of the account). Among the keys, one in particular — 804125db-bdf1-465a-a058-07fc87c0fad0 — it turns out enabled, of type RSA_4096, designed explicitly for encryption/decryption operations (unlike other keys in the set, which are instead oriented towards digital signatures).
7.4 Decryption of servers.enc #
aws kms decrypt \
--ciphertext-blob fileb://servers.enc \
--key-id 804125db-bdf1-465a-a058-07fc87c0fad0 \
--region us-east-1 \
--encryption-algorithm RSAES_OAEP_SHA_256The output contains one field Plaintext in base64. Decoding it:
echo "<Plaintext>" | base64 -dThe result is not directly readable text — the first few bytes (H4sI in base64, corresponding to 1f 8b 08 (in hexadecimal) are the typical signature of an archive gzipBut that alone is not enough gunzip: what's inside is actually a tar Compressed — when unzipping, you see the typical tar file header (filename, permissions, timestamp) before the actual contents. The clean way to extract everything in one step:
echo "<Plaintext>" | base64 -d | tar -xzO7.5 Recovering root credentials #
The extracted tar contains servers.yml, containing the credentials of an administrative user of the service (host vault.sink.htb), then used to get to root on the car.
In-depth analysis — AWS pentesting tools. Not used directly on this machine, but part of the offensive landscape on AWS: Pacu (active exploitation of IAM privilege escalation), ScoutSuite and Prowler (misconfiguration auditing/mapping), PMapper (AWS's "BloodHound," an escalation path graph), cloud_enum (discovering public buckets/resources by company name). Complete actionable guides: AWS Pentesting: IAM, S3, EC2, IMDS, and Privilege Escalation and AWS Privilege Escalation on Hackita.
8. Root #
With the credentials recovered from the decrypted file, login as root (via su root or direct SSH), and reading root.txt to close the machine.
9. Route summary #
Recon (HAProxy + Gunicorn dietro Flask, Gitea esposto)
↓
Studio del bug CVE-2019-18277 (CL/TE mismatch, byte \x0b)
↓
Costruzione ed esecuzione dell'exploit di smuggling
(richiesta con CL/TE in conflitto + chunk 0 + POST /notes nascosta)
↓
Cattura della sessione admin (tramite riuso della connessione keep-alive)
↓
Accesso a Gitea come admin → chiave SSH di marcus nella cronologia commit
↓
Shell come marcus (user.txt)
↓
Tunneling con Ligolo-ng (attenzione al loop di routing, uso di 240.0.0.1)
↓
Enumerazione LocalStack: solo KMS e Secrets Manager attivi
↓
Credenziali di david da Secrets Manager → su david
↓
File servers.enc cifrato → chiave KMS RSA corretta → decrypt → estrazione tar
↓
Credenziali nel file estratto (servers.yml) → root → root.txt10. Vulnerabilities and techniques used #
| Technology | Usage |
|---|---|
| HTTP Request Smuggling | Parsing desync between HAProxy and Gunicorn |
| CVE-2019-18277 | Smuggling trigger (byte \x0b (in Transfer-Encoding) |
| Hijacking session | Admin session theft via connection reuse |
| Gitea Git history | Recovering Marcus's SSH Key |
| Ligolo-ng | Pivot to the internal LocalStack service |
| AWS Secrets Manager | David's Credential Recovery |
| AWS KMS | Decryption of servers.enc |
| LocalStack | Listing local AWS services |
11. What to learn from HTB Sink #
- The request smuggling conflict arises always from a disagreement between two servers on the same request: one follows Content-Length, the other Transfer-Encoding (or both TEs, but one of the two is tricked into not recognizing it).
- A seemingly harmless control character (
\x0b,\x0c) inserted at a precise point in a header can be enough to break the parsing of just one side of the chain, without the other side noticing. - The front-end bug alone is not enough: you also need a complementary bug on the backend (which accepts what it should reject according to RFC 7230).
- The reused persistent connection between the front-end and the backend is what turns a simple parsing error into a vector for stealing other users' data.
- Verifying the vulnerability requires a “clean” test that isolates only the bug variable (with/without the character that triggers it) — a timeout obtained with an already malformed payload proves nothing, and even the clean test remains a probabilistic indication, not an absolute certainty.
- Having a point of reference that displays the result (a saved and viewable text field) makes the entire process vastly easier to verify than a purely “blind” attack.
FAQ #
What is HTB Sink? Sink is a Linux machine from Hack The Box, rated Insane, that stages a real infrastructure with a reverse proxy (HAProxy), a Python application server (Gunicorn), a Gitea instance, and a simulated AWS environment with LocalStack.
What vulnerability is being exploited in HTB Sink?
The main path exploits HTTP Request Smuggling related to CVE-2019-18277: a hidden control byte in the header Transfer-Encoding causes HAProxy and Gunicorn to interpret the same request differently.
How do you get an admin session on Sink? Through request smuggling: a hidden request is left "waiting" on the shared connection between HAProxy and Gunicorn, and the subsequent request from another user—ideally the administrator—is captured and saved as a note.
How do you switch from admin to Marcus? By logging into Gitea with a stolen admin session and retrieving a private SSH key from a repository's commit history.
How do you get root? Via Secrets Manager (david's credentials) and KMS (decryption of a configuration file containing root credentials), on a LocalStack instance reached via a Ligolo-ng tunnel.








