Motivation

One day, my IQ Gateway stopped working.

The IQ Gateway is a device from Enphase designed to collect solar panel production data, and optionally consumption data, and then report it to the cloud. It also provides per-panel data from each microinverter.

Since I was busy, I didn’t get to check on it immediately. When I eventually took a look, I noticed the device on the network, but the local API was non-functional. I also noticed that the device showed as offline in the mobile app. When I looked at the device, I saw all four lights flashing red.

I tried a reboot, but that didn’t help.

I contacted Enphase support, which eventually led to an RMA to replace the old unit.

It was challenging to gain installer-level access, but I eventually got it. This was necessary to let me swap out the Gateway myself without needing to call support. I also was interested in understanding why this failure occurred, and thought this level of access might help.

After gaining access, I requested a reboot on my own system. I didn’t think it would do anything, but the system started reporting data the next morning. However, all past data during the time that the gateway wasn’t reporting to the cloud was lost.

I set up some uptime monitoring, and while the device worked, it would randomly go down for an hour or two, usually at night. As the device deteriorated, it started dropping out during the daytime, as well as exhibiting other odd behaviors. This degradation pushed me to finally replace the failing IQ Gateway with the RMA unit, which fixed all the stability issues I had.

However, I wanted to understand the root cause so that my new unit doesn’t fail in the same way.

To investigate, I followed the instructions in the comments of this blog post to gain root access.

I first began by attempting to dump the currently-installed firmware. I had previously obtained encrypted firmware images from the installer toolkit mobile app, so I was in search of the “eecrypt” binary mentioned by another researcher to decrypt them. I eventually got what I needed by piping the files through tar over netcat with a speed limit to avoid overloading the hardware.

I also look at the Embedded Multi-Media Card (EMMC) health and quickly discover it is past its lifespan because of excessive writes. In fact, it had 8,000 P/E cycles on a 4GB EMMC, which equates to roughly 32 Terabytes Written (TBW).

I first start looking into replacing the EMMC chip, but I notice that there isn’t any higher-endurance EMMC chip that would fit. I then realize the device has a USB port. I could use this as storage and bind mount the files with heavy writes.

Finding the Vulnerabilities

I didn’t want to reinstall the RMA unit just so I could root it, so I decide to search for security vulnerabilities. I feel that it is very likely for this device to have a vulnerability that gives me root, since IoT devices have notoriously bad security.

My RMA unit had shipped with an older firmware with known vulnerabilities, which I initially was aiming to discover. It was more difficult to do this than I had expected, which led me to analyzing the latest firmware instead.

Running the “eecrypt” binary under QEMU, I was able to decrypt the latest firmware images I had extracted from the installer app earlier.

Because of the large number of components in the firmware, I decide to use Claude Opus 4.6 through Github Copilot to search for vulnerabilities for me. I ask it to create a report of potential vulnerabilities for me to review.

One of the first things that the agent noticed, which it regarded as critical, was a command injection vulnerability in the authentication handler.

The vulnerable code is as follows:

-- Calculate the SHA-512 digest of the decoded API key
local digest_cmd = "echo -n '" .. api_key .. "' | openssl dgst -sha512 | awk '{ print $NF }'"
local calculated_digest = io.popen(digest_cmd):read("*a")

This code is vulnerable because it takes an api key that originates from user input and directly places it into a shell command. Because of this behavior, crafted input from an unauthenticated user can be used to execute arbitrary commands.

Since the web server runs as the “nobody” user and the authentication handler runs inside of it, everything that runs as a child process also runs as the same user. As a result, this command injection only allows you to execute commands as the “nobody” user, while the mongrel2 web server runs ruby as root for the backend.

I ask Opus to explain to me how different parts of the firmware work, such as the firmware updates, SSH configuration, and other parts of the firmware I’m interested in. This helps me to build clearer picture of how everything goes together. Using Opus, I also reverse engineer some binaries on the device, including one that seems to be a customer support tunnel and has some connection to an OpenVPN server.

After successful reverse engineering, I decrypt the encrypted OpenVPN config and connect to the VPN. Thanks to the analysis from earlier, I am able to load up an internal firmware update endpoint. I was surprised to discover unencrypted firmware across many different versions, likely used by customer support to manually push firmware to devices.

I soon realize the vendor has multiple ways of pushing firmware to devices, and this is just one of them.

Since my initial attempts at finding the older vulnerabilities had been unfruitful, and the RCE (Remote Code Execution) could let me escalate privileges somehow, I decide to update my production device to the newest firmware. Since I now have the copies of all the firmware versions downloaded, this is okay, since I understand what I just installed.

Since I have installer access, I can simply use the standard update facilities and install a software update to the latest available version on my production device from the internal update server. Surprisingly, older versions did not have this unauthenticated RCE, which means it was introduced in newer versions.

Escalating Privileges

After the upgrade, I use the RCE to get a shell as “nobody” and attempt to escalate privileges. I begin with attempting to use the Dirty Cow exploit, but I eventually realize the device has a patched kernel.

I notice that the “nobody” user cannot write to most files, but I also recall that the backend runs as root.

I go back to the drawing board and look at the other vulnerabilities that Opus had found.

I remembered that there was this SSH config to disable formulaic passwords over SSH. The firmware does this by simply adding an additional file that is included in the SSH config whose sole purpose is to disable password authentication. This is a generated password with the formula mentioned in this blog post.

The file is located at /opt/emu/cfg/sshd_config and it has the following contents:

# Application sshd config/override
# /etc/ssh/sshd_config in rootfs includes this file.
# This config takes precedence over /etc/ssh/sshd_config settings.
# This is currently used for disabling formulaic credentials (FC)
# for different app builds. This may be removed in the future when
# we have a hard transition to FC disabled.
PasswordAuthentication no

The comments here are noteworthy because they acknowledge the reason for this. Checking the Pluggable Authentication Module (PAM) configuration reveals that the custom PAM module is used only for SSH. This means that this makes that module unreachable, but if you can somehow re-enable password-authentication, the formulaic credentials come back. This means the root cause of CVE-2020-25754 was not properly fixed.

The device also had strict firewall rules, only allowing the ports for the web interface to the connected network. The device does not allow SSH and other ports except through the customer support tunnel.

Customer support has the capability to command the gateway to connect to the VPN tunnel, and then they can SSH into the device or log in to the web interface. They use SSH keys configured with their PKI infrastructure.

Opus found a path traversal vulnerability in the Ruby backend which allowed for deleting arbitrary files.

The API endpoints are part of a diagnostic web interface intended for installers to diagnose noise issues with the Powerline Communication (PLC) that the gateway uses to communicate with the microinverters,

The backend code actually prevents path traversal for writing files, but it does not prevent it for deleting files. Since the ruby backend runs as root, this means with this API endpoint you can delete any arbitrary file.

The vulnerable code looks like this:

when "Delete Selected Files"
  temp = 0
  all_params('selected_files').each do |file|
    begin
      File.unlink("/var/log/capture/#{file}")
    rescue Errno::ENOENT
      # nothing we can do
    end
    temp += 1
  end
  statusStr += "<br>Number of captured files deleted: #{temp}"

Notice how the user-provided file path is simply appended onto the base path.

At first that seemed useless to me. But after trying to find ways escalate privileges, I realized that with arbitrary file deletion I could do two things. I could delete the iptables init script and then I could delete the extra SSH config to disable authentication.

This would allow SSH through the password generated via serial number. Additionally, it would disable the iptables firewall, allowing connectivity to SSH.

Even though the API endpoints are normally limited to accounts with installer-level access, I can bypass this. This is because the authentication is handled entirely by the nginx-based OpenResty webserver. With the RCE, I can talk to the underlying backend without any authentication.

As a result, an unauthenticated attacker can exploit both vulnerabilities to perform these actions.

However, these changes require a reboot to take effect. While I was testing this exploit, I manually did a reboot by flipping the circuit breaker, but I wanted a better way.

I soon discovered an unauthenticated reboot used for customer support purposes.

This reboot is designed to reboot the device and disable most of its normal functions. It appears to be designed to help customer support debug customer issues.

This reboot is perfect, because allows the previous file deletions to take effect, which enables access to SSH with password authentication.

The code responsible for the reboot is below.

# Process the request to force Envoy Fail
#
if (@pg_parms.cm.params.has_key?('failenable'))
  if(File.exists?('/var/run/emu/emumon.pgid'))

    # Only do this command if the monitor is running, otherwise no point
    # Indicate to the monitor that we are ready for failure
    system("rm -f /var/log/sysreboot.log")
    system( "echo \"Startup\" >> /var/log/sysreboot.log" )
    system( "echo \"Startup\" >> /var/log/sysreboot.log" )
    system( "echo \"Startup\" >> /var/log/sysreboot.log" )
    system( "echo \"Startup\" >> /var/log/sysreboot.log" )

    # Indicate in the Envoy Monitor log that this is a manual fail
    tN = Time.new
    timeStamp =
      "[#{tN.strftime('%Y/%m/%d %H:%M:%S')}.%06d" % tN.tv_usec + "]"
    cmd = <<-HERE
      echo \"#{timeStamp} Admin Page Failure Requested\" >> /var/log/emu/emumon.log
      HERE
    system( cmd )

    @local_messages << "envoy rebooting to failure state for debug"

    # Now Indicate to the Monitor that we have a hardware failure
    #
    system( "reboot" )

Notice how the comments state that this also puts the device into a failure state. This is why some services do not start up after a reboot initiated this way. However, SSH still remains active, as it is intended to be available for customer support to recover the system. Additionally, a reboot will bring the device back to normal.

If an IQ Gateway is exposed to the internet and is vulnerable, an attacker can complete the full chain to root without anything else. The same applies if an attacker is on the same network as an IQ Gateway where it may not be directly exposed to the internet.

This was great for my use case, but I was just amazed at how insanely simple this chain was. I thought that it would be something more complicated to gain these privileges.

SD Card Bind Mounts

I was shocked that something so simple could get me root access. I start thinking about disclosure, but I also wanted to focus on reducing writes on this new RMA unit and make sure it’ll last longer first, as that’s how I went down this rabbit hole in the first place.

I buy a USB to microSD adapter so that I can plug it into the gateway. I work with GitHub Copilot to write a script that will setup bind mounts to the device, and I setup some instrumentation to see which files are written the most to inform my decision on which files to bind mount.

However, I ran into strange issues where after the device mounts it would disappear soon after boot and then come back later. I later discovered it is a hardware issue that this platform has, and the BeagleBone Black with similar hardware has a similar issue. It has something to do with the “Babble Interrupt”,

In any case, I try to think of how to work around the issue. I first try remounting after the usb device disappears, but it causes issues because things still try writing to the old mount. I also worry it’s caused by the adapter, but multiple adapters have the same issues.

I finally realize that I can’t fix this problem and I would have to look at this problem from another perspective.

While setting up the bind mounts, I notice that Enphase has recently moved some things to tmpfs only in newer versions of the firmware. I want to move many more of the databases and those store important data that shouldn’t be lost on reboot, so moving to tmpfs alone wouldn’t be sufficient.

After thinking about it some more, I realize that I can simply bind mount to a tmpfs on boot before the services start. I can then sync the file from the sd card to the tmpfs or fall back to the EMMC otherwise. I can do this because the device is stable enough during boot to read from before it drops out. After the device stabilizes itself, the USB usually stops dropping out, although this is not required for the approach to work.

I can then have a script to handle the automatic remounting needed to handle the device disappearing and coming back. When the device is available, I can sync the files from tmpfs every five minutes to the microSD.

With 30k P/E cycles on a 32GB microSD card, this kingston industrial card can easily take 30000 cycles * 32GB = ~960 TBW, which means I can basically write like the gateway was writing to the EMMC to the microSD and it would not fail as quickly.

I sync every 5 minutes because that’s good enough retention and it actually reduces the writes to the point that the EMMC and microSD should last longer than 25 years, likely longer than the rest of the hardware.

I also have the script sync the files with the EMMC on a daily basis, which allows the EMMC to not be too far out of sync while drastically reducing the writes. I went through a lot of experimentation but I finally settle to a point where I got the writes to a very low baseline after determining what files to include. I will share my script at the end of this post in the hope that it will be useful to others.

Firewall Rules

I was horrified by how insecure this device is. While I’ve heard that IoT devices are insecure, it’s another thing to see how bad it is firsthand, Because of this, I decide to isolate the device in its own VLAN and apply strict firewall rules.

My goals was to block OTA updates to prevent my setup from breaking. Enphase has documentation on how to configure their devices behind a restrictive firewall, but that documentation is incomplete, and following it exactly would allow OTA updates.

I used the following firewall rules to ensure this, and I will explain my reasoning for what I allowed and didn’t allow afterwards. I didn’t give the device IPv6 connectivity since it didn’t seem to use it in its normal operation.

NameSourceDestinationProtocolPortAction
Home Assistant to Envoyanyanyaccept
Envoy: Enlighten ReportingIP address list for reports-prod.enphaseenergy.comtcp80,443accept
Envoy: Enlighten JWTIP address list for entrez.enphaseenergy.comtcp80,443accept
Envoy: Enphase MQTTanytcp8883accept
Envoy: Block all Other Outboundanyanyanydrop

I allow the Home Assistant device to connect to the envoy because it collects useful data through the local API which is nice to have. I permit enlighten reporting to work so that the device can normally report to the cloud. This is important for Solar Renewable Energy Credits (SRECs) as well as to have the app be functional. For Home Assistant to authenticate to the device, I allow the necessary traffic rather than disabling the authentication altogether.

The devices uses MQTT to show the live status in the mobile app, and it is nice to have that be functional, so that port is allowed through. All other communications to the internet and other VLANs are blocked. Established and related connections are allowed so that everything works as expected.

I did not allow Network Time Protocol (NTP) because the firmware version I am on doesn’t use this protocol for time. I didn’t allow home.enphaseenergy.com because that’s the host used for the internal VPN for Enphase to SSH into devices and one of the ways to push firmware updates. Additionally, I didn’t allow the update CDNs because I do not want the device to update itself.

At this point, I felt much more confident in the solution I had working and that it would continue to work without issues. I wanted to ensure that when I disclose the bug and it gets patched that my stuff still worked fine.

The Disclosure

After that, I begin with preparing to report to Zero Day Initiative (ZDI), because Enphase doesn’t appear to have a bug bounty based on what I could find.

Initially, I work on creating a proof of concept (PoC) as well as a writeup for the vulnerability to submit to ZDI. However, while I’m working on getting things ready to submit, ZDI stops accepting reports for IoT devices.

This was disappointing, but I was determined to responsibly disclose this serious vulnerability.

As a result, I began by making requests to MITRE for CVEs. I initially delayed reporting to CERT Coordination Center (CERT/CC) because I thought it was affected by the partial government shutdown at the time. Additionally, I sent an email to the Enphase security contact informing them of the vulnerability, including the PoC and a video showing exploitation on Sunday.

I got no reply from Enphase on Monday, and given their past behavior with ignoring vulnerability reports, I decide to look into reporting to CERT/CC. This time, I realize that they are unaffected by the partial government shutdown and send a report to them on the same day.

The following day, I send a second email to Enphase and finally receive a response from the head of security. They soon quickly mention that the issue is already fixed, even though I later discovered that wasn’t the case. Additionally, CERT/CC accepts my report for coordination.

What followed was Enphase making this process more difficult than it needed to be, so I will summarize important details rather than explaining everything.

I soon learned that Enphase had a private HackerOne program, but it required never publishing the vulnerability, even after release. This would be incompatible both with my CERT/CC coordinated disclosure as well as my hopes to write this blog post, so I decline.

I also found out that the email filter blocked the Python PoC script, so I had to send it via a Google Drive link. By Friday of the same week, Enphase attempts to push an OTA update to my production device without permission and it fails as a result of the firewall rules. Enphase claims my device is broken and they will send a technician to fix it. I respectfully decline.

At this point I looked at device logs and grabbed the firmware pushed to the device and analyzed it. I didn’t want Enphase to know this, because I couldn’t trust them based on the questionable behavior I had been observing. I knew the fixes were incomplete so I indirectly hinted towards this, but there was a lot of back and forth because they didn’t realize that I had the firmware. Enphase did not want to provide firmware for verification, considering it a “trade secret”.

Enphase finally creates a new firmware version later on, but then does not attempt to push it to my device and does not provide it to me for verification. They offer to send a test device, but I decline. The firmware they pushed to my production device was actually QA firmware, but they never admitted this.

Enphase also repeatedly pressured me to join a meeting, but I did not trust them and therefore never joined one.

Throughout the process with Enphase, I keep a log of the status in the vulnerability report so that CERT/CC is kept up-to-date. Eventually, they open a case and invite Enphase. At this point, conversation has stalled for a while because of Enphase’s inability to provide firmware for review.

Enphase immediately begins by attempting to throw me under the bus, but they finally answer some questions I didn’t get answers to over email. They provide unrealistic dates for deploying the fixes widely. In fact, judging by internet-exposed devices, they were unable to deploy updates in the timeframe they provided to CERT/CC.

Enphase also publicly posted an advisory, but largely ignored my feedback. As a result, the advisory includes only one of the vulnerabilities used in the attack chain and downplays the severity of the vulnerability.

CERT/CC also appears to drop the ball, not issuing a CVE even though MITRE has been taking a while. As a result, Enphase publishes the vulnerability advisory by the 45-day CERT/CC deadline. However, there were no CVEs issued.

CERT/CC recently has started responding months later, so they may finally issue a CVE. However they were proposing only a single CVE for the command injection, rather than other vulnerabilities used in the attack chain. This is likely because it was based off of the Enphase advisory.

As of writing this post, no CVEs have been issued for any of the vulnerabilities discovered here. I will try to update this post if that changes.

Enphase also sent me an email after the CERT/CC disclosure date to ask if I was interested in being a pentester for new products. I ask for more details and Enphase asks for my address to send me an NDA, which I respectfully decline.

The Patch Rollout

As Enphase had not provided the firmware to me in an acceptable form for validation, I hadn’t been able to validate the patches. Enphase later released the firmware through the installer app many months later, but I haven’t gotten around to checking it out yet.

While I was waiting for the vendor to respond in the online CERT/CC VINCE portal, I remembered that I could find IQ Gateways on Shodan. With a simple search for enphase-envoy, I could see all the internet-exposed gateways.

I could then add a version number onto the query to see how many devices are on a specific version.

While most users will have the gateway behind a router and not directly exposed to the internet, I was surprised to see over a thousand device exposed. This is out of the millions of installations that Enphase advertises on their website.

I knew at least 400 devices were on a version vulnerable to the whole chain. However, I believe that the path traversal and unauthenticated reboot affects all versions of the IQ Gateway firmware.

The reason a smaller subset of devices are affected by the full chain is because the command injection was introduced in newer firmware versions and not all devices had updated to that version at the time.

Using Shodan, I was able to track the rollout of firmware, even though Enphase hadn’t given me much information. While I could only see internet-exposed devices, it still was helpful. This is because the internet-exposed devices act as a random sample of the full population of the Gateways.

Using this data, I discovered the update pace was insufficient to meet the vendor’s own statements that all devices would be patched by the disclosure date (May 21st, 2026). This analysis of the rollout is based on the devices vulnerable to the full chain. The vendor failed to acknowledge that older firmware version branches were also affected by at least some of these vulnerabilities, so these firmware versions are likely still unpatched.

Since I had access to the VPN, I discovered the Apache HTTP server used to host the upgrade scripts became unusable from load when the rollout surged. The Apache HTTP Server version is more than 10 years old, but it was still a surprise to see that they never bothered upgrading things to get it a little bit faster. As a result, Enphase appears unable to push out the security fixes within a reasonable timeframe due to infrastructure failures. The actual firmware files for this patch version are not on the HTTP server accessible over the VPN, but the update process involves downloading a single ruby script from a single server, and enough devices doing it at the same time would overload it when the vendor attempted to accelerate the rollout.

As of this writing, there are still devices vulnerable to the full chain exposed to the internet, although far fewer than were there before patches were rolled out.

During the communications through the VINCE portal with Enphase, I suggested that they notify customers who have their devices exposed to the internet to try to get those devices put behind some type of firewall for security reasons. However, the vendor appears to have ignored this suggestion entirely, failing to even acknowledge it.

Download Links

I am sharing the POC here for the sake of the public benefit, even if it could allow malicious actors to use it in the wild. I believe the public interest outweighs the risks.
Here is a link to the POC

I also have a video of demo of the POC running in case you want to see what that looks like.
Here is a link to a demo of the POC

Additionally, I’m sharing the script I created to move things to tmpfs and sync to a USB device every 5 minutes. Keep in mind that it was designed for the specific firmware version I was on (8.3.5171) and may need adjustment for other versions.
Here is a link to the shell script

Published Advisories

Enphase Advisory: ENSA-2026-1