爬虫笔记之邮箱混淆
一、为什么需要邮箱混淆
先来解释一下什么是邮箱混淆,邮箱混淆就是对页面上的邮箱进行处理,使用JS加密、HTML隐藏元素干扰、图片显示等方式增加爬虫获取的难度。很多人都有过这种体验,当在网络上留下自己的邮箱之后,过不了多久这个邮箱就会收到一堆乱七八糟的垃圾邮件,都是一些广告、诈骗信息等。这是因为每时每刻都有爬虫在扫描互联网上的邮箱对其推送垃圾信息,应对这种情况,一般会有两种应对策略,一种就是尽量不在网络上留下自己的邮箱,这种办法属于鸵鸟政策,因为害怕所以就逃避不去面对这个问题,这个解决方案不大好。另一种办法就是虽然留下邮箱,但是刻意去增加爬虫获取信息的难度同时尽量不去增加人获取信息的难度,即尽量让人阅读友好但程序解析困难。本篇文章主要围绕第二种方式阐述增加爬虫获取邮箱难度的几种方法。
虽然本文是在讲邮箱混淆,但这个问题其实可以抽象为短文本如何反爬,即如何保护用户的重要信息字段不被爬虫获取,这些字段也可以是手机号、QQ号、居住地等等。
二、如何增加爬虫获取信息难度
2.1 留邮箱时破坏格式
对于增加爬虫获取邮箱的难度,大致可分为两类,一种是留下邮箱的人在留的过程中对其格式破坏增加爬虫识别难度,比如我的邮箱是foo@bar.com,那么我留下邮箱的时候就会留下比如foo#bar.com,这种是比较流行的格式,人一看就知道是个邮箱地址,但是就是太过于简单人一看就知道是邮箱地址,爬虫也能,对于这种格式的,爬虫只需要多加一个模式匹配即可兼容。那好吧,现在为了增加爬虫识别的难度,我写的变态一点,写成这种方式:
foo 艾特 bar 点 com
这种方式文本噪音没有固定模式,是增加了爬虫的解析难度,但是也增加人获取信息的难度。不过此类方式或许还是有用武之地,因为看到一些发广告信息的人留联系邮箱时为了逃避垃圾邮箱过滤,会使用这种方式混淆让过滤系统认为自己不是邮箱。
好吧,看来用留文本的方式无论如何是不能完美的搞定爬虫了,那么我留一个图片好了,我的邮箱还是foo@bar.com,这次我留下邮箱不放文本了,我把它从记事本打出来然后截个图我放图片:
嗯,上面的字体整整齐齐,人识别的难度很低,同样机器识别的难度也很低,如果爬虫刻意针对此类图片扫描一下内容的话是可以完美识别的,这样做也没多大用,如果图片不加混淆的话基本等同于使用文本的方式对抗爬虫,可如果增加噪点、干扰线、扭曲字体等进行混淆的话,同样增加爬虫识别难度的同时也增加了人获取信息的难度,因为图片不能复制,如果再加了混淆,一不小心就可能打错一位,这种方式算是很不友好了。
2.2 平台负责守卫邮箱安全
邮箱总是要显示在某个平台上的,比如我留在百度贴吧的邮箱,百度贴吧就要负责对其进行保护,不让爬虫等进行识别到。亦或者某个网站用户注册时留的联系邮箱,在查看个人信息时能够看到,那么这个平台就必须要负责此邮箱的混淆,不然如果某个人遍历所有用户个人信息得到邮箱挨个推送垃圾信息用户投诉平台带来负面影响怎么办,所以在设计产品的时候也要考虑到这些因素,不过现在的产品都比较注重个人隐私,基本都不把邮箱信息公开显示了,比如有些平台会做成好友可见之类的,然而没卵用,只要能够让除了自己以外的人看到,都会有办法获取到,只不过手段可能有点无耻,有点突破底线。就拿信息好友可见举个例子,如果有个大胸美女头像的人加你好友,你点进去一看她的历史记录是个文艺女青年,正是你喜欢的菜,你会不会拒绝她的好友请求呢?同理只需要多搞一些马甲,马甲多样化,覆盖大多数人的爱好就可以了,此种方式还是能够获取到一部分邮箱的,当然要平台足够大才值得搞,用户都没几个的还是算了。呃,好像跑题了,下一部分会具体的讲平台保护用户邮箱的几种方式。
三、平台邮箱混淆的方式
下文会介绍一些邮箱混淆的方法,同时针对每种方法做一些简单的实现,出于简便考虑实验使用的WEB环境为Spark Web Framework。
3.1 使用HTML+CSS混淆
3.1.1 添加不可见元素
在邮箱的各个字符之间穿插不可见元素,浏览器渲染出来的只有可见字符,而爬虫不会去渲染样式,很有可能就连隐藏元素的内容也一起算作邮箱地址的一部分而解析到错误的邮箱地址。
下面是一个小例子,比如在服务器返回数据的时候对邮箱处理在字符之间随机插入一些隐藏的HTML元素:
package cc11001100.crawler; import spark.Spark; import java.util.Random; /** * @author CC11001100 */ public class EmailProtectionHideElementDemo { private static String emailProtection(String email) { StringBuilder result = new StringBuilder(); for (int i = 0; i < email.length(); i++) { result.append(email.charAt(i)); if (Math.random() < 0.5) { result.append(genHideHtmlElement(email)); } } return result.toString(); } private static String genHideHtmlElement(String rawEmail) { StringBuilder mixContent = new StringBuilder(); Random random = new Random(); for (int i = 0, end = Math.min(rawEmail.length(), 3); i < end; i++) { mixContent.append(rawEmail.charAt(random.nextInt(rawEmail.length()))); } return "<span style='display:none;'>" + mixContent.toString() + "</span>"; } public static void main(String[] args) { Spark.get("/show_email", (req, resp) -> "<p>" + emailProtection("foo@bar.com") + "</p>"); } }
前端页面的显示效果,用户在浏览器中看到的是正常的,但是爬虫解析的话很有可能就解析到错误的:
顺带讨论下对于使用HTML混淆网上比较流行的说法使用HTML注释进行混淆,将上面的例子修改一下,由隐藏元素改为使用注释进行混淆:
package cc11001100.crawler; import spark.Spark; import java.util.Random; /** * @author CC11001100 */ public class EmailProtectionCommentDemo { private static String emailProtection(String email) { StringBuilder result = new StringBuilder(); for (int i = 0; i < email.length(); i++) { result.append(email.charAt(i)); if (Math.random() < 0.5) { result.append(genHideHtmlElement(email)); } } return result.toString(); } private static String genHideHtmlElement(String rawEmail) { StringBuilder mixContent = new StringBuilder(); Random random = new Random(); for (int i = 0, end = Math.min(rawEmail.length(), 3); i < end; i++) { mixContent.append(rawEmail.charAt(random.nextInt(rawEmail.length()))); } // 混淆元素使用注释 return "<!-- " + mixContent.toString() + " -->"; } public static void main(String[] args) { Spark.get("/show_email", (req, resp) -> "<p>" + emailProtection("foo@bar.com") + "</p>"); } }
然后启动WEB应用,编写代码使用Jsoup爬取一下:
package cc11001100.crawler; import org.jsoup.Jsoup; import org.jsoup.nodes.Element; import java.io.IOException; import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * @author CC11001100 */ public class EmailCrawler { private static List<String> extract(String url) { try { return Jsoup.parse(new URL(url), 1000).select("p") .stream() .flatMap(element -> EmailCrawler.extractEmail(element).stream()) .collect(Collectors.toList()); } catch (IOException e) { e.printStackTrace(); } return Collections.emptyList(); } private static List<String> extractEmail(Element contentElt) { Pattern pattern = Pattern.compile("[A-Za-z0-9\\u4e00-\\u9fa5]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+"); Matcher matcher = pattern.matcher(contentElt.text()); List<String> result = new ArrayList<>(); while (matcher.find()) { result.add(matcher.group()); } return result; } public static void main(String[] args) { String url = "http://localhost:4567/show_email"; extract(url).forEach(System.out::println); } }
发现没有进行任何额外处理就取出了正确的邮箱地址,JSoup解析DOM的框架取元素的text时会忽略HTML注释中的内容,下面是JSoup中text()的实现:
/** * Gets the combined text of this element and all its children. * <p> * For example, given HTML {@code <p>Hello <b>there</b> now!</p>}, {@code p.text()} returns {@code "Hello there now!"} * * @return unencoded text, or empty string if none. * @see #ownText() * @see #textNodes() */ public String text() { final StringBuilder accum = new StringBuilder(); new NodeTraversor(new NodeVisitor() { public void head(Node node, int depth) { // 对于节点类型只取文本节点 if (node instanceof TextNode) { TextNode textNode = (TextNode) node; appendNormalisedText(accum, textNode); } else if (node instanceof Element) { // 普通标签都是作为Element解析的,但是注释类型有自己单独的Comment类表示,Comment和Element都继承Node,所以text()时注释类型Comment会被忽略 Element element = (Element) node; if (accum.length() > 0 && (element.isBlock() || element.tag.getName().equals("br")) && !TextNode.lastCharIsWhitespace(accum)) accum.append(" "); } } public void tail(Node node, int depth) { } }).traverse(this); return accum.toString().trim(); }
不过对于扫描邮箱的爬虫一般都是不会去解析DOM的,这太浪费资源了,大部分都是对页面内容进行简单的正则匹配,对于正则扫描,使用HTML注释混淆的方法还是有效的。
3.1.2 使用CSS改变文本方向
后台返回邮箱的时候进行字符串反转,前端显示的时候对邮箱使用CSS样式再反转回来,用户看到的就是正确的邮箱地址,而爬虫一般是不会渲染CSS的,所以爬虫爬取到的就是被反转之后的错误邮箱地址。
但是这种方式有个致命的缺点就是虽然用户看到的是正序的,但是如果选中复制的话复制到的内容仍然是逆序的,这也是CSS类反爬的典型缺点,所以并不是太推荐此种方式。
下面是一个小例子:
package cc11001100.crawler; import spark.Spark; /** * @author CC11001100 */ public class EmailProtectionCssReverseDemo { private static String emailProtection(String email) { return "<span style='unicode-bidi:bidi-override; direction:rtl;'>" + reverse(email) + "</span>"; } private static String reverse(String s) { StringBuilder result = new StringBuilder(s.length()); for (int i = s.length() - 1; i >= 0; i--) { result.append(s.charAt(i)); } return result.toString(); } public static void main(String[] args) { Spark.get("/show_email", (req, resp) -> "<p>" + emailProtection("foo@bar.com") + "</p>"); } }
显示效果:
3.2 使用JS加密
JS混淆就是在服务器端对数据进行加密,然后在客户端浏览器渲染的时候使用JS进行解密。使用JS进行混淆的方法就有很多了,可以自定义一套加密解密规则,不过这里需要遵守的原则就是尽量让加密解密规则不具有通用性并且难于理解(解密JS混淆是必须的),不具有通用性针对一个站点要重新编写代码,考虑到成本爬虫方面很可能会放弃,不放弃的话混淆代码也很难懂,爬虫方面看不懂也很有可能会放弃,这也只是理想情况下,JS解密毕竟将解密逻辑放在了客户端,铁了心要破解的话死磕硬刚总是能够破解的。
对于JS加密因为方式太多了不再自己写例子而是看一个现成的例子。
JS邮箱混淆例子: CDN开启邮箱保护
百度CDN有一个功能叫做邮箱混淆, 当开启了此功能之后页面中的邮箱就会被替换为这种形式:
<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="e38d8991819b9b999ba3908a8d82cd808c8e">[email protected]</a>
这种如果直接解析的话只会得到一个[email protected],解析不到邮箱地址,邮箱地址是在页面加载完成后通过JS渲染出来的。开启了百度CDN的网站都会有一个路径:
/cdn-cgi/scripts/f2bf09f8/cloudflare-static/email-decode.min.js
这个JS负责将链接解析为可读的形式显示在页面上,看下email-decode.min.js的内容:
!function () { "use strict"; function e(e) { try { if ("undefined" == typeof console) return; "error" in console ? console.error(e) : console.log(e) } catch (e) { } } function t(e) { return i.innerHTML = '<a href="' + e.replace(/"/g, """) + '"></a>', i.childNodes[0].getAttribute("href") || "" } function r(e, t) { var r = e.substr(t, 2); return parseInt(r, 16) } function n(n, c) { for (var o = "", a = r(n, c), i = c + 2; i < n.length; i += 2) { var u = r(n, i) ^ a; o += String.fromCharCode(u) } try { o = decodeURIComponent(escape(o)) } catch (l) { e(l) } return t(o) } var c = "/cdn-cgi/l/email-protection#", o = ".__cf_email__", a = "data-cfemail", i = document.createElement("div"); !function () { for (var t = document.getElementsByTagName("a"), r = 0; r < t.length; r++) try { var o = t[r], a = o.href.indexOf(c); a > -1 && (o.href = "mailto:" + n(o.href, a + c.length)) } catch (i) { e(i) } }(), function () { for (var t = document.querySelectorAll(o), r = 0; r < t.length; r++) try { var c = t[r], i = c.parentNode, u = c.getAttribute(a); if (u) { var l = n(u, 0), f = document.createTextNode(l); i.replaceChild(f, c) } } catch (d) { e(d) } }(), function () { var e = document.currentScript || document.scripts[document.scripts.length - 1]; e.parentNode.removeChild(e) }() }();
下面是本人对email-decode.min.js的一个阅读分析,修改了部分代码以提高可读性:
!function () { "use strict"; /** * 错误信息输出到控制台 * * @param e */ function logError(e) { try { if ("undefined" == typeof console) return; "error" in console ? console.error(e) : console.log(e) } catch (e) { } } function wrapper(decodeEmailString) { parent.innerHTML = '<a href="' + decodeEmailString.replace(/"/g, """) + '"></a>'; return parent.childNodes[0].getAttribute("href") || "" } function twoCharToIntByFrom(s, from) { var intString = s.substr(from, 2); return parseInt(intString, 16) } /** * 邮箱解码 * * @param emailProtectionHref * @param fromIndex * @returns {*} */ function decode(emailProtectionHref, fromIndex) { var decodeEmail = ""; var key = twoCharToIntByFrom(emailProtectionHref, fromIndex); for (i = fromIndex + 2; i < emailProtectionHref.length; i += 2) { var nextChar = twoCharToIntByFrom(emailProtectionHref, i) ^ key; decodeEmail += String.fromCharCode(nextChar) } try { decodeEmail = decodeURIComponent(escape(decodeEmail)) } catch (e) { logError(e) } return wrapper(decodeEmail) } var emailProtectionHref = "/cdn-cgi/l/email-protection#"; var encodeEmailClassFlag = ".__cf_email__"; var encodeEmailAttrName = "data-cfemail"; var parent = document.createElement("div"); /** * 解密mailto形式的,比如 mailto:foo@bar.com */ !function () { for (var links = document.getElementsByTagName("a"), i = 0; i < links.length; i++) { try { var currentLinks = links[i]; var emailProtectionHrefIndex = currentLinks.href.indexOf(emailProtectionHref); if (emailProtectionHrefIndex > -1) { currentLinks.href = "mailto:" + decode(currentLinks.href, emailProtectionHrefIndex + emailProtectionHref.length) } } catch (e) { logError(e) } } }(); /** * 解密文本形式的,比如foo@bar.com */ !function () { var emailLinks = document.querySelectorAll(encodeEmailClassFlag); for (i = 0; i < emailLinks.length; i++) { try { var currentLink = emailLinks[i]; var parent = currentLink.parentNode; var encodeEmail = currentLink.getAttribute(encodeEmailAttrName); if (encodeEmail) { var decodeEmail = decode(encodeEmail, 0); var decodeEmailTextNode = document.createTextNode(decodeEmail); parent.replaceChild(decodeEmailTextNode, currentLink) } } catch (d) { logError(d) } } }(); /** * 解密完毕,将自己从页面中移除掉 */ !function () { var emailDecodeScript = document.currentScript || document.scripts[document.scripts.length - 1]; emailDecodeScript.parentNode.removeChild(emailDecodeScript) }(); }();
针对上面的解密逻辑,可写出破解代码:
package cc11001100.crawler; /** * @author CC11001100 */ public class BaiDuCDNEmailProtectionCracker { private static String decodeEmail(String encodeEmailString) { StringBuilder result = new StringBuilder(encodeEmailString.length() / 2 - 1); int key = charToInt(encodeEmailString, 0); for (int i = 2; i < encodeEmailString.length(); i += 2) { char c = (char) (charToInt(encodeEmailString, i) ^ key); result.append(c); } return result.toString(); } private static int charToInt(String s, int from) { return Integer.parseInt(s.substring(from, from + 2), 16); } public static void main(String[] args) { System.out.println(decodeEmail("e38d8991819b9b999ba3908a8d82cd808c8e")); } }
另外值得一提的是百度和CloudFlare有合作关系,看这个链接
/cdn-cgi/scripts/f2bf09f8/cloudflare-static/email-decode.min.js
很有可能是CloudFlare将技术共享给了百度CDN。
3.3 使用图片显示邮箱
使用图片显示邮箱这种方式其实相当于把问题转化为了字符型验证码识别问题。对于字符型验证码如果不加干扰线、扭曲之类的很容易就能识别出来,如果加了的话人识别的难度又会被增加,而且这种方式最致命的就是没办法复制,所以这种方式不推荐,这里也不再进行详细阐述。
四、 总结
啰里啰嗦了这么多,下面总结一下。
相关资料:
1. Nine ways to obfuscate e-mail addresses compared
2. 发布邮件地址时用「#」「at」等替代「@」有助于反垃圾邮件吗?
.