|
| 1 | +--- |
| 2 | +title: "PyPI Phishing Attack: Incident Report" |
| 3 | +description: Follow-up on the recent phishing attack targeting PyPI users. |
| 4 | +authors: |
| 5 | + - miketheman |
| 6 | +date: 2025-07-31 |
| 7 | +tags: |
| 8 | + - security |
| 9 | + - transparency |
| 10 | +links: |
| 11 | + - posts/2025-07-28-pypi-phishing-attack.md |
| 12 | +--- |
| 13 | + |
| 14 | +# Incident Report: Phishing Attack |
| 15 | + |
| 16 | +Over the past few days, a phishing attack targeting PyPI users via email was uncovered. |
| 17 | +Our [initial report](2025-07-28-pypi-phishing-attack.md) was posted to raise awareness of the attack, |
| 18 | +and to provide some initial details on the attack vector. |
| 19 | + |
| 20 | +Social media posts linking to the initial report have been shared widely, |
| 21 | +PyPI itself has not been breached with this attack. |
| 22 | + |
| 23 | +## Summary |
| 24 | + |
| 25 | +* 4 user accounts were successfully phished, now either disabled or credentials rotated |
| 26 | +* 2 API Tokens were generated by the attackers, which have since been revoked |
| 27 | +* 2 releases of the `num2words` project were uploaded by the attacker, which have since been removed |
| 28 | +* The phishing domain has been taken down |
| 29 | + |
| 30 | +<!-- more --> |
| 31 | + |
| 32 | +This appears to be similar in nature to a recent incident involving `npm` packages, |
| 33 | +where attackers successfully phished end-user credentials, |
| 34 | +and then compromised popular packages to distribute malware via npmjs.com. |
| 35 | +Read a [report on this attack here](https://www.bleepingcomputer.com/news/security/popular-npm-linter-packages-hijacked-via-phishing-to-drop-malware/). |
| 36 | + |
| 37 | +To briefly recap the attack pattern, |
| 38 | +the attackers established a domain name, SSL certificate, |
| 39 | +and server running in a Virtual Private Server (VPS) in the cloud, |
| 40 | +which would transparently proxy requests to PyPI.org. |
| 41 | +This could be termed a "forward proxy", many CDN providers operate their services this way. |
| 42 | +We cannot know for certain, but it appears that the attackers sent emails |
| 43 | +to addresses found in package metadata, which is publicly available due to |
| 44 | +users putting them in their `setup.py` or `pyproject.toml` files. |
| 45 | +The emails contained links to the phishing domain, |
| 46 | +asking them to verify their email address. |
| 47 | +When users clicked the link, they were directed to the phishing domain, |
| 48 | +which presented the PyPI.org website, |
| 49 | +but with a different URL in the browser address bar. |
| 50 | + |
| 51 | +Normal traffic flow: |
| 52 | + |
| 53 | +```mermaid |
| 54 | +flowchart LR |
| 55 | + A[Client Browser] -->|Request| B[PyPI.org] |
| 56 | + B -->|Response| A |
| 57 | +``` |
| 58 | + |
| 59 | +Phishing attack flow: |
| 60 | + |
| 61 | +```mermaid |
| 62 | +flowchart LR |
| 63 | + A[Client Browser] -->|Click Email Link| B[Phishing Domain] |
| 64 | + B -->|Proxy Request| C[PyPI.org] |
| 65 | + C -->|Response| B |
| 66 | + B -->|Display Content| A |
| 67 | +``` |
| 68 | + |
| 69 | +This may be termed ["adversary in the middle" (AiTM) attack](https://capec.mitre.org/data/definitions/94.html), |
| 70 | +however the difference here is that the attacker is not intercepting traffic between the user and PyPI.org, |
| 71 | +rather is acting as a forward proxy, |
| 72 | +which is a common practice for content delivery networks (CDNs) and the like. |
| 73 | + |
| 74 | +Since a browser's address bar showed `hxxps://pypj.org/...`, |
| 75 | +and the website content of legitimate `pypi.org`, |
| 76 | +users might have missed the small difference between a lowercase `i` and `j`, |
| 77 | +and tricked into thinking they were on the official PyPI website, |
| 78 | +and submitted their credentials to the phishing domain. |
| 79 | + |
| 80 | +The phisher could then capture the credentials, |
| 81 | +and use them to log in to the real PyPI.org website, |
| 82 | +potentially compromising the user's account and any packages they maintain. |
| 83 | + |
| 84 | +Having a form of two-factor authentication (2FA) enabled on the account |
| 85 | +generally prevents phishing attacks like this from being successful, |
| 86 | +as the attacker would need access to the second factor (e.g. a TOTP code) |
| 87 | +to complete the login process. |
| 88 | + |
| 89 | +However, since the attacker was in between the user and PyPI.org, |
| 90 | +they could have captured the second factor as well, and could have used the TOTP code |
| 91 | +within a short time interval, or potentially even captured the session cookies |
| 92 | +provided during the response to the login request, |
| 93 | +and thus bypassed the need for 2FA for a short time. |
| 94 | + |
| 95 | +If the user had enrolled a [Security Device](https://pypi.org/help/#utfkey) |
| 96 | +for PyPI second factor authentication, the attacker would not have been able to use the second factor, |
| 97 | +as the WebAuthn protocol requires the user to physically interact with a hardware security key, |
| 98 | +or use a browser-based implementation, which would not be possible |
| 99 | +if the user was not on the legitimate PyPI.org website ([Relying Party Identifier](https://www.w3.org/TR/webauthn-2/#relying-party)). |
| 100 | + |
| 101 | +Also, there are a significant amount of relatively inactive users who pre-date 2FA on PyPI, |
| 102 | +and may not have 2FA enabled on their accounts. |
| 103 | +If a user in this category fell for the phishing attack, |
| 104 | +they would still need to complete email address verification, and 2FA enrollment. |
| 105 | +This process generates an email from PyPI to the user, |
| 106 | +which links back to the legitimate PyPI.org address. |
| 107 | +These user accounts were not as vulnerable to this specific attack. |
| 108 | + |
| 109 | +## Timeline of events |
| 110 | +Times are in both Eastern Daylight Time (EDT) and UTC. |
| 111 | +Some events may be omitted for brevity. |
| 112 | + |
| 113 | +_Keep in mind: in the USA, the weekend is Saturday and Sunday._ |
| 114 | + |
| 115 | +### 2025-07-26 Saturday |
| 116 | +- 13:14 EDT (17:14 UTC): A user emails PyPI Security about a suspicious phishing email they received, |
| 117 | + which appears to be from PyPI.org, but with a different domain name. |
| 118 | +- 14:01 EDT (18:01 UTC): A volunteer PyPI Admin posts a message about this phishing attack in the PyPI Admins chat. |
| 119 | +- 18:37 EDT (22:37 UTC): A community member posts about their experience on [Python Forums](https://discuss.python.org/t/pypi-org-phishing-attack/100267) |
| 120 | +- 19:18 EDT (23:18 UTC): On-call PyPI Admin sees the message and escalates to another volunteer PyPI Admin for assistance, |
| 121 | + while submitting abuse complaints to the domain registrar NameSilo (report #1) and content delivery network (CDN) provider Cloudflare (report #2). |
| 122 | +- 19:38 EDT (23:38 UTC): A volunteer PyPI Admin responds, and begins investigation of the phishing attack. |
| 123 | +- 20:07 EDT (00:07 UTC): The volunteer PyPI Admin shares findings and some actions taken in chat, and has to leave for personal reasons. |
| 124 | + |
| 125 | +### 2025-07-27 Sunday |
| 126 | +- 06:34 EDT (10:34 UTC): On-call PyPI Admin updates that the registrar has indicated they should submit a different form, which they do. |
| 127 | + They also confirm that the [PSF Trademarks Working Group](https://www.python.org/psf/trademarks/) are also working on this from a trademark perspective to notify the CDN and registrar of the abuse. |
| 128 | + |
| 129 | +### 2025-07-28 Monday |
| 130 | +- 08:57 EDT (12:57 UTC): PyPI Admin staff sees the comments in chat, begins investigation follow up |
| 131 | +- 09:20 EDT (13:20 UTC): Available PyPI Admins (volunteer and staff) & other PSF parties meet to determine next steps. |
| 132 | +- 10:22 EDT (14:22 UTC): PyPI Admins post an [initial report](2025-07-28-pypi-phishing-attack.md) to the PyPI blog, and share it on social media. |
| 133 | +- 10:44 EDT (14:44 UTC): PyPI Admins post a notice to [`pypi-announce` mailing list](https://mail.python.org/mailman3/lists/pypi-announce.python.org/), |
| 134 | + and a similar is posted to the more general [`security-announce` mailing list](https://mail.python.org/mailman3/lists/security-announce.python.org/). |
| 135 | +- 11:37 EDT (15:37 UTC): A [new feature is added to PyPI](https://github.com/pypi/warehouse/pull/18427) to detect and warn users about phishing domains, |
| 136 | + which is deployed to production. |
| 137 | +- 11:59 EDT (15:59 UTC): A threat hunter posts on Twitter about a finding in `num2words` 0.5.15 |
| 138 | +- 12:01 EDT (16:01 UTC): Another PyPI Admin submits another abuse report to Cloudflare (report #3), with more context and details - requests, headers, IP address, and more. |
| 139 | +- 12:05 EDT (16:05 UTC): Cloudflare response to initial abuse complaint from Saturday (report #2) as "invalid". |
| 140 | +- 12:18 EDT (16:18 UTC): PyPI Admins observe that Cloudflare placed a "Suspected Phishing" warning when visiting the phishing domain, |
| 141 | + reducing the probability for users to fall for the attack, despite declining the abuse reports. |
| 142 | +- 12:20 EDT (16:20 UTC): NameSilo places the phishing domain under administrative `ClientHold` status. |
| 143 | +- 12:23 EDT (16:23 UTC): Cloudflare responds to second abuse report (report #3) as "invalid". |
| 144 | +- 12:38 EDT (16:38 UTC): `num2words` project owner responds on Twitter confirming removal of 0.5.15 |
| 145 | +- 12:54 EDT (16:54 UTC): `num2words` project owner responds on Twitter confirming removal of 0.5.16 as well as suspicious API Token |
| 146 | + |
| 147 | +After the domain registrar NameSilo placed the domain name under `clientHold` status, |
| 148 | +new DNS resolutions failed, as there are no name servers. |
| 149 | +Host records for a client to resolve the domain name to an IP address will then fail. |
| 150 | +As DNS caches expired, users no longer were able to resolve the domain name, |
| 151 | +and thus unable to access the phishing site. |
| 152 | + |
| 153 | +Incident is considered resolved, PyPI Admins continue to monitor the situation, |
| 154 | +and analysis of the attack continues. |
| 155 | + |
| 156 | +## Impact Analysis |
| 157 | + |
| 158 | +The phishing attack was targeted at PyPI users, |
| 159 | +and since we were able to obtain at least one confirmed IP address of the hosting server, |
| 160 | +we have found 4 such successful phishing attacks, and have taken action on them. |
| 161 | + |
| 162 | +A single account appears to have further activity beyond credentials being compromised, |
| 163 | +and the attacker uploaded releases to PyPI.org via a newly-provisioned API Token. |
| 164 | +`num2words` versions 0.5.15 and 0.5.16 were uploaded to PyPI.org, |
| 165 | +which included malware, and removed within hours of being uploaded by the owner of the account. |
| 166 | + |
| 167 | +The impacted user [posted about the activity on Twitter](https://nitter.tiekoetter.com/SFLinux/status/1949906299308953827): |
| 168 | + |
| 169 | +> Thanks for the heads up! There was a phishing attack on pypi this morning. The compromised version of num2words v 0.5.15 have been removed. |
| 170 | +
|
| 171 | +and later: |
| 172 | + |
| 173 | +> I found a weird token in our pypi account, probably the attacker had created it. I removed the token and deleted the malicious version again (0.5.16). I also created new backup TOTP codes for MFA. Will keep an eye on it but hopefully they won't be able to reupload the malware. |
| 174 | +
|
| 175 | +An advisory has been published [PYSEC-2025-72](https://osv.dev/vulnerability/PYSEC-2025-72) |
| 176 | +to help end user and tooling to detect the malicious versions. |
| 177 | + |
| 178 | +## Takeaways |
| 179 | + |
| 180 | +### Anti-impersonation Email Prevention |
| 181 | + |
| 182 | +PyPI has settings for [Sender Policy Framework (SPF)](https://postmarkapp.com/glossary/sender-policy-framework), |
| 183 | +[DomainKeys Identified Mail (DKIM)](https://dkim.org/), |
| 184 | +and [Domain-based Message Authentication, Reporting & Conformance (DMARC)](https://dmarc.org/) |
| 185 | +to help prevent phishing attacks by verifying the authenticity of emails sent from PyPI.org. |
| 186 | + |
| 187 | +However, each mail server that receives an email from PyPI.org is responsible for checking these values, |
| 188 | +and taking action based on the results of those checks. |
| 189 | +If the receiving mail server does not check these headers, or does not take action based on the |
| 190 | +results, then the email may still be delivered to the user's inbox. |
| 191 | +In this case, it appears that the phishing email was delivered to some users' inboxes, |
| 192 | +despite the presence of these headers. |
| 193 | + |
| 194 | +In other cases, the receiving mail server may have checked the headers, |
| 195 | +and presented the user with a "Looks like Spam" warning, |
| 196 | +leaving it up to the user to decide whether to trust the email or not. |
| 197 | +This is a common practice, as many email providers do not block emails outright, |
| 198 | +but instead mark them as spam or suspicious, allowing users to make the final decision. |
| 199 | + |
| 200 | +Ultimately, it is up to the user to be vigilant and cautious when clicking links in emails, |
| 201 | +especially when the email is unexpected or from an unknown sender. |
| 202 | + |
| 203 | +### Abuse Reporting & Responses |
| 204 | + |
| 205 | +We were surprised that our reports to Cloudflare were rejected due to them being unable to confirm phishing, |
| 206 | +which lead to a longer-than-expected period of the phishing domain being online. |
| 207 | +We don't have additional visibility into why these reports were initially considered invalid |
| 208 | +by Cloudflare or why they did eventually result in a takedown, |
| 209 | +but we will review and determine if future reports could be made more actionable. |
| 210 | + |
| 211 | +### Alternate spelling domain names |
| 212 | + |
| 213 | +The PSF is exploring the transfer of domain names used in this attack |
| 214 | +and other similar domains to have them registered to the PSF, |
| 215 | +and prevent future abuse. |
| 216 | +This effort has significant costs associated with it, so the outcome is not guaranteed. |
| 217 | + |
| 218 | +If successful, this would allow the PSF to take control of the domain names, |
| 219 | +and possibly redirect them to either a warning page, |
| 220 | +or to the official PyPI.org website, |
| 221 | +preventing future phishing attacks using these domain names. |
| 222 | + |
| 223 | +## Call to action |
| 224 | + |
| 225 | +Some things you can do to help prevent this kind of attack in the future: |
| 226 | + |
| 227 | +* If you have a dormant PyPI account, consider removing it if you do not need it. |
| 228 | + This will help reduce the number of potential targets for attackers. |
| 229 | +* If you have an older PyPI account, and have not logged in since 2FA has been required, |
| 230 | + consider logging in and enabling 2FA on your account. |
| 231 | + This will help protect your account from future phishing attacks. |
| 232 | +* Use WebAuthn via browser or hardware security keys for 2FA on your PyPI account. |
| 233 | + This will help protect your account from phishing attacks, |
| 234 | + as the attacker would need access to the second factor to complete the login process. |
| 235 | + |
| 236 | +Security is a never-ending process, and we are always looking for ways to improve our security posture. |
| 237 | +If you have specific ideas or suggestions for improving PyPI security, |
| 238 | +and are willing to help implement them, consider checking our [Issue Tracker](https://github.com/pypi/warehouse/issues) |
| 239 | +or if you have something more sensitive, you can email [[email protected]](mailto:[email protected]). |
| 240 | + |
| 241 | +This work would not be possible without generous donations, |
| 242 | +please consider [supporting the PSF](https://www.python.org/psf/) |
| 243 | +to ensure this kind of work can continue to serve the worldwide Python community. |
| 244 | + |
| 245 | +## Indicators of Compromise |
| 246 | + |
| 247 | +Analysis exposed some indicators of compromise (IoCs) that may be useful for detecting this attack, |
| 248 | +and similar attacks in the future. |
| 249 | + |
| 250 | +- Domain name: `pypj.org` |
| 251 | +- IP address: `45.9.148.108` - the phishing email sending server |
| 252 | +- IP address: `45.9.148.85` - the phishing domain hosting server |
| 253 | +- Domain name: `modirosa.com` - used by attackers to establish accounts on PyPI.org |
| 254 | +- Domain name: `necub.com` - used by attackers to establish accounts on PyPI.org |
| 255 | +- PyPI Package: `num2words` - versions 0.5.15 and 0.5.16 |
| 256 | + |
| 257 | +### Related CAPECs |
| 258 | +- [CAPEC-94](https://capec.mitre.org/data/definitions/94.html) - Adversary in the Middle (AiTM) Attack |
| 259 | +- [CAPEC-98](https://capec.mitre.org/data/definitions/98.html) - Phishing |
0 commit comments