29.10.2025 15:45
How typosquatting tricked me (a bit)
Typosquatting [1] is a popular method using similarly looking names to draw people into malicious content – such as phishing websites or fake software packages. It leverages our “brain optimization” that matches what we see with what we already know – even if it’s not exactly the same. I haven’t installed any shady software, but it’s still a good example how easily our brain could be used against us by utilizing our biases.
The story started a few days ago, when I was looking into alerts generated by my automated analysis of packages uploaded to the Python Package Index (PyPI). I don’t have much time recently, so I don’t review all of them – but this one immediately caught my attention:

The name is obvious typosquatting (in this case my human-typosquatting-detector still worked well), so I had to look inside. The alert had actually been triggered by a rather generic rule – I’ve even considered lowering its severity recently. However, in the files tree something immediately looked wrong:

Files listing in the asynhttp package
The __pycache__ directory is a place where Python stores the source code compiled to the bytecode format, in the form of files with the PYC extension, to speedup subsequent executions. This is usually used just locally, and rather useless when included in a package – the Python interpreter will most probably treat them as outdated and recompile the code anyway. Yet, it happens from time to time that they are accidentally included in published packages.
But what do we see here? A PYC file that has been identified as a ZIP archive which contains a few Python source code files – this is not how it works. Looking at the code, the flagged https.py was immediately confirmed to be suspicious. I quickly found out that it implements some encryption and modifies the files of another package, implementing the Happy Eyeballs protocol [2]:

A cut from the https.py, where files belonging to another package are removed
Now that I knew enough to confirm it was malicious, I also found the entry point where this code was loaded. In a class definition in the web_routedef.py, the ZIP archive was loaded in as a Python module (this is actually supported by the Python standard library, yet rarely used in 3rd party packages). The package itself is a copy of aiohttp [3], a popular HTTP implementation.
![]()

Listings from modified web_routedef.py. In line 22 the function to import modules from ZIP archives is imported and renamed, and then used in line 193 to load the malicious code
I was in a rush, so I didn’t dig deeper – this was enough for the report. The package was quickly removed from PyPI. Soon afterwards, it came back under different name, a process that was repeated a few times. This is quite typical, Threat Actors often try uploading almost the same package after the removal of the previous one. I can imagine a few reasons why, but changing the package name definitely won’t help it stay undetected next time. Here, they also experimented with different loaders and places to hide the malicious code. For example, in aiohttp-ssl and aiohttp-openssl, the archive was disguised as a certificate and loaded during executing a method:

Listing from the aiohttp-ssl and aiohttp-openssl packages – the archive is now named server.crt and the malicious code is in SSLv2.py

In the line 198 of file web_routedef.py, the module from ZIP archive is loaded, which automatically triggers the malicious code
And in the newest incarnation, httpserver-cache, the archive has been renamed to look like a file with the types definition, and the loading code has been separated between two files: in typedefs.py the archive is added to the list of paths where Python looks for modules to import, and the importing is triggered again in the web_routedef.py:

File listing from the httpserver-cache, the ZIP archive is renamed as _websocket.typed and the malicious code is now in the falcon.py

In line 61 of typedefs.py, the ZIP archive is added to the paths where Python looks for modules

In line 29 of web_routedef.py, the malicious module is imported. As the typedefs was imported earlier, Python will now find falcon module in the ZIP archive
Where is the trick?
Have you already noticed a trick that I’ve overlooked in a rush? While looking for the loading code in the last package, I compared its code with the original aiohttp. Besides the already mentioned loading methods, it also reveals that Happy Eyeballs lost the “s”...

Differences in the connector.py file between the original aiohttp and malicious clone httpserver-cache
This was the first trick. I opened the aiohappyeyeball to see that it was also uploaded a few days ago and is a copy of the legitimate aiohappyeyeballs [4]. This package did not trigger any alert in my system, but looking at the file listing I again saw a suspicious PYC file, that this time wasn’t recognized as any file type. There was also nothing readable inside – just some binary data.

Listing of files in the aiohappyeyeball package
Quick diffing with the original package revealed interesting looking lines:

I did not recognize __directory__ nor __pycached__ as any known variables, but it looked like a loading method I didn’t know yet – so I started digging into what is in the staggerd.pyc. I have ruled out real Python bytecode, and suspected a native extension module [10] – however, they should export some methods. At this point I even started asking for help. Until I thought about looking at the code from the ZIP-archives once again and realized that they managed to trick me once more.
How does it work?
There is no magic way in the aiohappyeyeball to load the binary file. It acts just as storage, and the staggerd.pyc is an encrypted blob. The decryption is implemented in other packages. The encryption algorithm is implemented without using any dependencies outside the standard library.
The really interesting part is in the mar function. Every encryption needs some kind of key to perform operations. In this case, to avoid embedding the key in the file, a smart method was used: the key is a filename of one of modules. Which module? Which file? We don’t know – the code keeps only a hash of the key. However, it iterates over all available modules and checks which one has a file name matching the hash (see lines 140-147). While the method is clever, it’s not hard to retrieve the key by checking just modules from the standard library. The key has been clearly chosen to work on every Python installation.

First part of the mar function. The encryption key is server.py
Once the key has been found, the code uses it to decrypt one of previously declared variables (line 150). The decrypted value is staggerd, what matches the file name of the PYC file from aiohappyeyeball. Lines 151-154 adds the file extension, .pyd on Windows and .so otherwise. These extensions are used for compiled native Python extension modules. The constructed name will later be used to store the final decrypted extension module.
Line 155 decrypts another previously defined variable, which is a one-element list. We get here the value “staggerd.pyc” – this is exactly the file stored in aiohappyeyeball. In lines 156-158 we see a tracks covering mechanism: if the decrypted extension module file exists, it will be removed, and the code will also attempt to delete the ZIP archive it’s loaded from. We can see the use of the strange __directory__ variable from the aiohappyeyeball: it’s a helper for the location of the malicious module on Windows.

If the native module does not exist yet, the final part of the code is executed. First, it reads the content of the staggerd.pyc and removes the file itself. Interestingly, the code is ready to reconstruct the encrypted payload from parts in multiple files, but only one file was used here.
After that, the decrypted data are saved in one of two location, depending on the system (line 171 or 176). As already said, the data is a compiled Python extension module. Finally, the standard library is used to load the module – with a slightly different methods used on Windows and Linux.

Finally, the mar function is called immediately upon importing the malicious code. The last thing worth mentioning is that the aiohappyeyeball was uploaded in a few versions compiled for different operating systems, leveraging standard Python package managers to deliver the right version of the payload.
And all this for... nothing?
At least that's what it looks like. I decrypted the payload, and executed it in a sandbox [5] [6]. And... nothing happened. Both Linux and Windows versions are loaded correctly, but they do not export any function. As my binary reverse engineering skills ends on “strings” command, I didn’t find anything more clearly suspicious inside. If you speak binary and want to play with it, both Windows and Linux versions are now on VirusTotal [7] [8].
The End
So far, the campaign used 5 packages [9], all of them utilizing some kind of typosquatting name. They all have already been removed or quarantined, and thus, are no longer installable. This is one of more interesting campaigns of the last time, so as the recap:
- the encrypted payload was hidden in the
aiohappyeyeball, installed as a dependency - the loader code was hidden in a ZIP archive disguised as something else and secretly loaded
- the encryption was implemented using only the standard library
- the key is not kept in the plain text, but delivered from a file name of some standard module
- the final stage is a compiled extension module
- it clones popular packages and uses typosquatting with similar names.
This is also an excellent example that shows that even if you have trained your eyes on dozens of typosquatted names and seen all modifications of requests, you can still be tricked by simple but smartly placed, typosquatting. Unfortunately, I have to leave the final question – what was it for? – open. I suspect the whole thing might have been just a test, especially because the decryption mechanism supports a split payload, but didn't utilize the capability. But who knows – maybe they have tricked me again?
Packages in the campaign
- aiohappyeyeball
- aiohttp-openssl
- aiohttp-ssl
- asynhttp
- httpserver-cache
References
- https://en.wikipedia.org/wiki/Typosquatting
- https://en.wikipedia.org/wiki/Happy_Eyeballs
- https://pypi.org/project/aiohttp/
- https://pypi.org/project/aiohappyeyeballs/
- https://tria.ge/251027-18sgmaat8e
- https://tria.ge/251028-w1z5aaaq9x/static1
- https://www.virustotal.com/gui/file/69d8c6ca2f4d9343f421ecdbe38a63eb6dc1197657e5bd46bd322da202870329/detection
- https://www.virustotal.com/gui/file/1d04fc08da9f8cf3e9f93319b40af9ba2d2a61a7234d60de2b2fe2f22f835439/detection
- https://bad-packages.kam193.eu/pypi/campaign/2025-10-asynhttp/
- https://docs.python.org/3/extending/extending.html