6月25号,福建省高考成绩新鲜出炉。
作为2016届福建省的一名高考生,我自然是非常在意成绩的。支付宝等公司推出了一些“高考成绩预约”服务,但是老实说我还不是很信任它们;它们的时效因为用户数量多所以也往往不足。到高考放榜当天,考试查询服务器肯定会被挤爆。为了早点知道自己的考试成绩,也为了挤上小霸王服务器,于是我打算由计算机定时抓取,结果,踩坑踩了半天。
本文涉及以下方面:验证码识别、RSA算法exponent、ScriptControl的使用。
验证码识别
打开查询系统,看到验证码。首先要做验证码识别。于是,拖回100个验证码。
可以看到的是,这种验证码破解非常简单。它符合以下特征:
字符位置固定
字符旋转角度固定
字体固定
无粘连、噪音干扰小
背景和字体区别度大
不得不说,这是一个拿来识别验证码的比较好的入门教材,也正适合我来进行学习。
我的处理方案是:(最简单的)二值化 -> (最简单的)去噪 -> (最简单的)切割 -> (最简单的)转字符串 -> (最简单的)查字模库 -> 出结果
技术选型
原来打算用Nodejs / Python,不过它们的图形库要安装的依赖太多,在Windows下安装麻烦(我都不敢相信,装GTK是要做什么)。于是选择了C#。使用jTessBoxEditor来训练tesseract效果很不理想,而且反而让我发现了这俩玩意的一堆麻烦BUG(似乎是依赖库的锅)。那就干脆手写好了。
预处理
二值化
这里有一张验证码。
观察其特征:背景颜色为淡色,验证码字符在其上以明显区别于背景色的颜色显示。这样的验证码通过二值化算法进行处理是实现起来比较方便。二值化,是把图片处理成只有两种颜色的处理方式。比如这张图,我们期望把背景抠成白色,但是验证码字符全部黑色且清晰可见。这样,降低了计算的复杂度,不丢失重要的【验证码】信息,非常方便后续操作。
图像二值化算法有很多种,适用于不同的用途:
当然,这张图只需要灰度化 + 阈值处理即可。先将其灰度化。
灰度化的目的是,把背景颜色变淡,验证码字体颜色变深,从而排除深浅色的干扰,保证后续把图片转为仅黑白两色时判断无误。因为我们使用的二值化算法实在是太简易了,这种预处理也正好可用。我们可以直接对比这两张验证码没有经过灰度化时,被下一步处理的样子:
未灰度化
灰度化
话说回来,一个好的二值化算法不会因为图片被弄成这样就丢失重要信息的。不过这也无所谓了,我识别的就是这种简单的验证码。
/// <summary>
/// Binarizate the specified original bitmap.
/// </summary>
/// <param name="originalBitmap">The original bitmap object.</param>
/// <returns></returns>
public static Bitmap Binarizate(Bitmap originalBitmap)
{
var newBitmap = Clone(originalBitmap);
for (int i = 0; i < newBitmap.Width; i++)
{
for (int j = 0; j < newBitmap.Height; j++)
{
var color = newBitmap.GetPixel(i, j);
/// <see cref="http://www.scantips.com/lumin.html"/>
/// <see cref="http://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color"/>
var gray = (int)(color.R * 0.3 + color.G * 0.59 + color.B * 0.11);
var newColor = Color.FromArgb(gray, gray, gray); // 此像素点灰度化
var bppColor = newColor.B < 127 ? Color.Black : Color.White; // 判断灰度化后的像素点的蓝色是否达到一定值,是就白色,否就黑色。
newBitmap.SetPixel(i, j, bppColor);
}
}
return newBitmap;
}
去噪
二值化后,我们能看到它仍然有不少噪点,那就进行去噪处理。
再分析一下这个验证码。除了几个大点外,剩下的都是1px宽的线和1像素大小的点。那么,最简单的思路就是判断旁边某个像素点有几个点的颜色是黑色的,就可以了。
随便从不知道哪个角落来抄了一段代码……
/// <summary>
/// Removes the noise.
/// </summary>
/// <param name="dgGrayValue">The dg gray value.</param>
/// <param name="MaxNearPoints">The maximum near points.</param>
/// <returns></returns>
public static Bitmap RemoveNoise(Bitmap originalBitmap, int dgGrayValue = 128, int MaxNearPoints = 3)
{
int nearDots = 0;
var newBitmap = Clone(originalBitmap);
for (int i = 0; i < newBitmap.Width; i++)
for (int j = 0; j < newBitmap.Height; j++)
{
var piexl = newBitmap.GetPixel(i, j);
if (piexl.R < dgGrayValue)
{
nearDots = 0;
//判断周围8个点是否全为空
if (i == 0 || i == newBitmap.Width - 1 || j == 0 || j == newBitmap.Height - 1) //边框全去掉
{
newBitmap.SetPixel(i, j, Color.FromArgb(255, 255, 255));
}
else
{
if (newBitmap.GetPixel(i - 1, j - 1).R < dgGrayValue) nearDots++;
if (newBitmap.GetPixel(i, j - 1).R < dgGrayValue) nearDots++;
if (newBitmap.GetPixel(i + 1, j - 1).R < dgGrayValue) nearDots++;
if (newBitmap.GetPixel(i - 1, j).R < dgGrayValue) nearDots++;
if (newBitmap.GetPixel(i + 1, j).R < dgGrayValue) nearDots++;
if (newBitmap.GetPixel(i - 1, j + 1).R < dgGrayValue) nearDots++;
if (newBitmap.GetPixel(i, j + 1).R < dgGrayValue) nearDots++;
if (newBitmap.GetPixel(i + 1, j + 1).R < dgGrayValue) nearDots++;
}
if (nearDots < MaxNearPoints)
newBitmap.SetPixel(i, j, Color.FromArgb(255, 255, 255)); //去掉单点 && 粗细小3邻边点
}
else //背景
newBitmap.SetPixel(i, j, Color.FromArgb(255, 255, 255));
}
return newBitmap;
}
效果如图:
切割
原来呢,这里应该用各种【识别粘连】【边缘检测】等算法来处理的。但是!但是!
直接用Photoshop的切片工具看。没错,这6个码是平均摆在6个位置的中点然后随便旋转一个角度形成的。这样直接平均切割不就好了吗……随便找了一个图像平均切割算法,切成以下6个图形:
第6张图片的8稍有问题,不必在意。本来应该使用某种旋转算法把它旋转回正确的方向的,不过无所谓了。后面可以通过建立字模库来处理这种问题。
转字符串
前面提到,我们已经把这张图变成了【只有黑色和白色的】【只含有1个数字的】六张图片了。为了便于和字模库比对,我们需要把它转为字符串。毕竟,Bitmap没有IndexOf和Like。
具体操作?黑色为1,白色为0,组合输出。
/// <summary>
/// Converts Bitmap to String
/// </summary>
/// <param name="bitmap">bitmap</param>
/// <returns></returns>
public static string BitmapToString(Bitmap bitmap)
{
var stringBuilder = new StringBuilder();
for (int i = 0; i < bitmap.Width; i++)
{
for (int j = 0; j < bitmap.Height; j++)
{
var color = bitmap.GetPixel(i, j);
stringBuilder.Append(color.R < 127 ? "1" : "0");
}
}
return stringBuilder.ToString();
}
把六张图悉数转为字符串,即可准备比对。
建立字模库
事实上,这个验证码的旋转角度等是固定的。直接建立字模库,不需要任何数学知识,明显会方便很多。
首先把那100张验证码全部下载下来并按照前面的部分切割成各张小图,并保存。然后人工识别,手动归类。如以下图形都代表着“3”。
把每张图片归类后,计算它们对应的字符串。
public static void InitStore(string storePath)
{
for (var i = 0; i <= 9; i++)
{
studiedStore[i] = new List<string>();
Directory.GetFiles($"{storePath}\\{i}").ToList().ForEach(str => {
studiedStore[i].Add(BitmapToString(new Bitmap(str)));
});
}
}
把计算出来的值存到什么地方,字模库即建立成功。比如我是在resx里建立的。
字符比对
当我们查询的时候,随便找一个字符比对算法循环查询就好了。最简单的算法就是逐字符比较计算不同字符数量,取最少不同字符的那一项所代表的值为结果即可。
成品
https://github.com/zsxtoys/FjeeaRefresher/blob/master/FjeeaRefresher/ValidationCodeParser.cs
坑
这码最后两位数字不知道什么情况下会改变,不做任何处理的情况下的建库对这种识别率不高,然而这种东西一变就是几个小时,必须手动加内容。
密码加密
这玩意的密码在发送前也在前端进行了加密,相关代码:
<script language="JavaScript" src="/UEPORTLET/scripts/secret/BigInt.js"></script>
<script language="JavaScript" src="/UEPORTLET/scripts/secret/Barrett.js"></script>
<script language="JavaScript" src="/UEPORTLET/scripts/secret/RSA.js"></script>
<script language="JavaScript" src="/UEPORTLET/scripts/secret/base64.js"></script>
<script language="JavaScript">
<!--
setMaxDigits(130);
//加密方法
function do_encrypt(domEle) {
var key = new RSAKeyPair("10001","","8d52ee0f601c87ddf3d9541b5312c8f4ec09315e8e2fd730cac1b7a6df268555a0380a7626eeadac38160bf47253a6b7046f02e325c51823aa2c9bb789405f7387504c5ecb2bd8d28079837f20481e8f698e508114b265a850317d43ff2d9cf5ca66e27b2884218c272600a2695867cbcfb9b4786e8b1355351129f169fe030d");
key.radix=Number("16");
var pwdVl = domEle.value;
if(pwdVl.length > 30){
pwdVl = pwdVl.substring(0,30);
}
var res = hex2b64(encryptedString(key,pwdVl));
domEle.value = res;
}
//-->
</script>
先看RSA部分。RSA的exponent部分通常值都是65537,转成base64后是AQAB……What the fuck?这怎么转?
引用文章:http://www.cnblogs.com/midea0978/archive/2007/05/22/755826.html
现在问题又来了,.NET的RSA加密无论如何结果都不对,怎么办?
能注意到,这个网站是能在IE6下跑的。那就说明,只要不用window下浏览器特色API,就可以直接拿给其它js引擎跑。为了这种事情,上Edge库是有点“杀鸡焉用牛刀”。不过,Windows系统内有内置JScript脚本引擎,并且有多种调用方式,包括Windows Script Host等。这里,我们可以用ScriptControl来调用。
事实上,我们只要调用一个COM对象就好了。比如说,这样:
Type ScriptType = Type.GetTypeFromProgID("ScriptControl");
object ScriptControl = Activator.CreateInstance(ScriptType);
ScriptType.InvokeMember("Language", BindingFlags.SetProperty, null, ScriptControl, new object[] { "JScript" });
ScriptType.InvokeMember("AddCode", BindingFlags.InvokeMethod, null, ScriptControl, new object[] { FjeeaResource.Properties.Resources.RSAJavaScript });
string ret = ScriptType.InvokeMember("Run", System.Reflection.BindingFlags.InvokeMethod, null, ScriptControl, new object[] { "do_encrypt", Password }).ToString();
return ret;
成品:
https://github.com/zsxtoys/FjeeaRefresher/tree/master/FjeeaRefresher
结局
成绩在6月23日下午四点猝不及防地就出来了,而且我查成绩的时候服务器一点也不挤。
扔到GitHub去吧:https://github.com/zsxtoys/FjeeaRefresher
所以这个程序有卵用啊?
参考资料
Tesseract Open Source OCR Engine (main repository)
https://github.com/tesseract-ocr/tesseract
jTessBoxEditor
http://vietocr.sourceforge.net/training.html
System.Drawing Namespace
https://msdn.microsoft.com/en-us/library/system.drawing(v=vs.110).aspx
Merging two tiff image using c#.net - Stack Overflow
http://stackoverflow.com/questions/6548771/merging-two-tiff-image-using-c-net
image - Formula to determine brightness of RGB color - Stack Overflow
http://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color
机器自动识别验证码的原理是怎么样的?
https://www.zhihu.com/question/22479139
常见验证码的弱点与验证码识别
http://drops.wooyun.org/tips/141
破解北邮研究生教务系统登陆验证码
http://huangsy.me/2014/05/15/validcode.html
用于验证码图片识别的类
http://www.cnblogs.com/yuanbao/archive/2007/09/25/905322.html
验证码识别实践1:自己动手C#实现
http://blog.csdn.net/stevenkylelee/article/details/8263890
图像处理——灰度化、二值化、膨胀算法、腐蚀算法以及开运算和闭运算
http://blog.csdn.net/hellousb2010/article/details/37939809
C#简单数字验证码解析
http://www.cnblogs.com/ivanyb/archive/2011/11/25/2262964.html