我的校招之路

我在2021届秋招拿到了腾讯、美团、网易和字节跳动的研发岗offer。最终选择了腾讯微信事业群,三方已签。最近有很多人问我就业和实习的事情,所以我决定写下这篇文章,记录我从第一次面试到签署三方的经历,解答大家的一些问题,也算记录一下这段高速成长的时光。

规划

凡事豫则立,不豫则废。

1
——《礼记·中庸》

在我看来,我秋招的胜利来源于三个方面:超前的规划、谨慎的选择、不懈的努力。在这里我把超前的规划单独拿出来讲,因为我觉得它是以上三者中作用最大的。

从初中开始我就对计算机很感兴趣,高中有了自己的智能手机之后便常上网,对互联网有了些理解,虽然当时并不清楚具体的职位,但我决定以后要在互联网行业打拼。

选学校的时候我的第一选择是深圳,因为那里互联网最发达,但无奈深圳并没有适合我的学校,所以就来到了北京。

image-20201204183323170

大一的时候我给自己定下目标:本科毕业进大厂。

为了实现这个目标,我在大二和大三通过自学、做项目等方式努力提升自己的技术水平,这里必须要感谢一群好伙伴,我们一起奋斗的时光是大学最宝贵的经历之一,没有你们也就没有现在的我。

IMG_2683

点滴校园2019连续4天通宵开发

IMG_2684

我的北方策划会议

IMG_2685

口袋发票夺得华为杯第一名

时间过得很快,一转眼就到了大三下,我需要对秋招作出具体的规划了,那个时候我了解几种开发岗位的区别,但我对后端、前端和客户端有相同的兴趣,不好做出选择,所以我决定先找实习试一试,然后再决定。

总体战略是这样的:通过不同公司不同岗位的多次实习来提升自己并确定未来几年的工作方向,然后利用实习经历在秋招时拿下心仪的offer。

一些科普

在讲述我的实习经历之前,我先来讲一些大家可能希望知道的内容。

特殊名词

  • Offer

    指录用意向书,企业向应聘者发放,代表应聘者通过了所有考核,企业抛出了橄榄枝。

    校招offer通常分三个大档:白菜、sp、ssp。

  • HR

    Human Resource的缩写,企业内的人力资源管理员工,和整个招聘流程有很大关系。

  • OC

    是Offer Call的缩写,在将要发放正式Offer之前HR一般会打个电话确认。

  • HC

    Head Count的缩写,招聘的名额。

  • 三方

    高校毕业生就业协议书,校招特有的协议,由学校发放,企业和学生填写,起约束作用。

实习种类及区别

我就以腾讯为例来阐述几种实习的区别,其他公司可能会有些差别,但总体上应该差不多。

  • 日常实习(劳务实习)

    这种岗位是全年招聘的,而且几乎没有限制,只要是在校生就可以参加。

    日常实习的工作内容通常比较简单,容易上手,交给学生做正好,从企业的角度来看节约了人力成本。

    日常实习生几乎没有转正机会,福利待遇和其他员工比起来少很多,薪资也很低,如果租房子的话肯定要倒贴。

  • 暑期实习(校招实习)

    暑期实习项目在腾讯是由集团在暑假前统一启动,其目的是为了提前抢占当年秋招的人才,所以这个项目只招聘次年毕业的学生,也就是通常情况下的大三和研二。

    暑期实习的工作内容会更有挑战性,能达到筛选人才的目的。

    暑期实习生的薪资比起日常实习生要高不少,在腾讯异地的学生还有租房补贴,公司内的福利待遇也基本和正式员工一致。

    公司会在一个统一的时间段对当年的暑期实习生进行留用考核,合格的学生会提前收到校招offer,毫不夸张的说,成功进入暑期实习就是拿了一半正式offer。

  • 入职前实习

    拿了校招offer但因为还没有毕业所以不能正式入职的学生提前到公司实习。

    除了工资还是实习工资其他待遇和正式员工完全一致。


日常实习我觉得适合大三下之前去,我没有去一方面是觉得自己能力不够,另一方面转专业过来要比其他同学多上几门课,没什么时间。一般专业大二之前应该都是挺忙的,而日常实习最低的要求也是一周3个工作日,所以不太适合大多数人。大三如果课少并且确定毕业工作可以去一下,但一定不要把大三下暑假拿去日常实习,那时一定要去暑期实习。

暑期实习是兵家必争之地,在腾讯,每年校招有50%的HC给了暑期实习转正的同学。就算转正失败了,也不影响参加校招,也就是多一次机会,而且有了暑期实习的经历去面别的公司也更容易过。

关于岗位

简要介绍一下几种技术岗位。

  • 后端

    开发的程序一般运行在服务器上,通过前台为用户提供服务。

  • 前端

    • 广义的前端

      开发和用户直接接触的程序,例如:小程序、网页、App。也叫“大前端”。

    • 狭义的前端

      使用Web技术开发和用户直接接触的程序,例如:网页、小程序。

  • 客户端

    指开发各种平台App的工程师,包括:PC、Mac、iOS、Android。

    客户端也根据平台分好多种。

  • 测试开发

    开发服务于企业内部研发人员和测试人员的测试中台,属于研发的一种。

  • 算法

    研究并训练模型以完成产品中的某些核心能力,与研发不同,门槛和工资往往比研发高。

  • 测试

    负责测试研发产出的产品,与研发和算法都不同,需要掌握的技能也不同,门槛和工资相对较低。

简历

当时我花了很大精力制作我的简历,但后来发现是白费功夫,因为大公司都各有各的招聘系统,我们主要是在公司的招聘系统里完善信息,简历只是一个附件,据我了解很多面试官都不会看附件简历中的内容。

image-20201204210427677

所以后来我的秋招简历就做得很简单。

关于邮箱

曾经有不少人问我在联系方式用qq邮箱是不是会让HR觉得low,我一开始也是这样以为的,所以在上面的简历中我用了一个icloud邮箱,我还花了很多时间调教邮箱App,但其实邮箱作为一个联系方式应该以快速稳定为主,而icloud邮箱就让我差点错失了滴滴的offer。从国内环境来看,qq邮箱和网易邮箱都是不错的选择,甚至很多公司(非腾讯)的招聘网站内建议使用qq邮箱。

找实习

大三上寒假的时候我就在积极准备面试,面试的内容无非三部分:基础知识、项目经历和手撕代码(算法)。

我也是按照常规的办法针对性练习,基础知识靠刷面经,项目都是我自己做的所以就想想有哪些点可以展开讲,手撕代码也就是算法这方面不是我的强项,但面试不会考太难的题目,我就在leetcode上刷《剑指offer》,感觉挺有效的。

给大家看一下我那段时间的日程,这还只是一部分,还有些不重要的没有写。

image-20201204205608434

本来想按时间线来说一说的,写了一半发现实在太乱了,那我就分公司说吧。

美团

image-20201204205842617

美团暑期实习招聘启动最早,我第一个投的就是美团,岗位是前端,因为我研究美团的招聘简章的时候发现会flutter有加分,这项技术我之前正好接触过,所以果断选择前端。

投递之后很快就收到了笔试通知,技术岗笔试就是在线编程,一般会有很多轮,你可以随意选择一轮考,但只能考一次,笔试之后才会到面试环节。

我为了求稳,选择了距离我2场的笔试,这样我有充足的准备时间。结果并没有什么卵用,一共5道题,我只做出来1道半,还是太菜啊。

正当我以为美团挂了的时候,面试邀约来了,美团一共两轮技术面,没有HR面,两轮面试体验非常棒,和面试官沟通非常愉快,面试完成之后我觉得自己稳了,因为面试官的问题我基本都能答出来,我也能感觉到面试官对我很满意。

但我等了一周又一周也没有等来OC,直到我在微信的面试冲到最后一轮总监面,美团HR的电话才打过来,但那时我的一只脚已经踏进了WXG(微信事业群)的大门,再加上美团的OC在二面结束后长达29天,中间没有任何消息,所以我拖延了一下收到腾讯的OC之后就拒绝了。

给我发offer的是美团到店事业部酒店业务,是美团的top业务,我们的缘分还没有结束。

字节跳动

image-20201204214420690

我一直很关注字节跳动的动向,我认为它是一家年轻而富有活力的公司,适合我这样喜欢打拼的人。

由于字节跳动后端有很多golang技术栈的东西,我正好比较喜欢go所以就投了飞书后端,字节HR的效率非常的高,上午投的简历,中午就电话约面试时间,可惜我一面就失败了。

当时我的状态很差,面试的时候一直肚子痛,面试官也只是机械的问一个又一个问题,没有什么交流,感觉非常像所谓的压力面,我一直觉得我的一个很大的优点就是有强大的心态,但此时也是紧张了,第二天就收到了拒信。

滴滴

image-20201205190317440

投滴滴之前我已经经历了很多次面试,但是手里一个offer都没有,我的自信有了一丝动摇,滴滴今年没有统一的暑期实习,我在滴滴招聘官网投递的简历也迟迟没有人看。在牛客刷面经的时候碰巧看到滴滴在招golang的实习,我就抱着试一试的心态投了一下,结果立刻就有人联系我约面试时间。

滴滴的面试流程很快,2天2面,面试官问的问题也相对简单,面完我就觉得稳了,很快我就得知我被录用了,但当时我觉得美团有戏,所以拖延了4天,最后还是没有等到,所以我选择入职滴滴。

面试之后我才知道我入职之后的岗位是测试开发,并不是后台,但是牛客上的招聘简章写的是“开发工程师”,这也算是被小坑了一下,哈哈。关于滴滴实习的经历后面再说。

腾讯

image-20201205193704540

我最终通过了腾讯的实习生留用考核并接受了校招offer,在这之前我总共经历了10轮面试!

我很早就开始投腾讯,那时还没有开放暑期实习,我就投了PCG客户端开发,我在腾讯经历了人生第一次正式的面试,那次面试至今记忆犹新,因为疫情,面试官也是在家中工作,面试过程是通过腾讯会议进行的,面试持续了将近两个小时,这么长时间下来非常累,我一度认为互联网公司的面试都这么长时间,但后面的面试大部分都在半小时左右。

腾讯的招聘流程是最标准的,简历统一去招聘官网投递,然后各部门的面试官浏览简历,看到合适的人选就将这份简历锁定,然后可以对这份简历发起面试,这之后别的面试官就不能再拉取这份简历了。

腾讯视频

第一个锁定我的是腾讯视频,我从一面面试官那打听到的,我还挺高兴,毕竟腾讯视频也是重量级产品,一面二面非常顺利,但是三面我处处碰壁,而且面试官貌似也不知道前几面的情况,三面结束之后我等了19天才等到消息,挂了。

这样就是说我在腾讯的招聘系统里被锁定了19天,别的部门也不能捞我。

经过了这么长时间腾讯的暑期实习招聘已经开始了,所以我在系统中将日常实习改成了暑期实习,后来想想这也是因祸得福吧,如果不是当时日常实习被挂,可能就会错过腾讯的暑期实习。

QQ

腾讯视频结果一出我的简历就回到了简历池里,当天下午就被捞起来了。

当从一面中得知这次捞我的是QQ客户端团队,我非常高兴,虽然QQ在社交领域比不上10亿月活的微信,但也是明星级产品。我闲暇时间经常摆弄QQ App,有很多有趣的动画和交互,早些时候参加过腾讯举办的开发者大会,QQ的工程师上台讲解过他们是如何实现多端超大群的。所以我非常期待,希望加入。

一面结束的时候面试官突然和我说他们是深圳总部,他看到我的意向城市是北京,所以和我说一下,这我就犯了难,一面自我感觉良好,但是我学校在北京,正好是疫情管控最严的时候,我几乎不可能去深圳,但另一边是腾讯的top业务QQ,经过2天的慎重考虑,我决定放弃这次机会,因为当时去深圳太冒险,而且我相信我会有下一次机会。

二面的时候我直接在面试开始的时候告诉面试官我要放弃并且说明了原因,面试官表示理解,并当场帮我解锁了简历。

微信

又是当天下午就被捞了,看来我的简历很吃香。

那时我还一个offer没有,所以要求不高了,甚至还投了一些小厂,一面面试官先介绍了部门,但我当时没有听清,怕打断节奏就没有问,以为是个小部门。

一面顺利通过,二面也比较顺利,二面的反问环节我又问了一遍部门的事情,面试官告诉我是微信事业群,我非常开心,保险起见,我又问了一下工作地点,因为我听说微信都是在广州,得到确定北京的回答我放心了。三面是总监面,后来我才知道是我们中心的总监亲自面试的我,他先简单聊了聊,然后给我出了5道题,题目都非常有趣,由于保密要求,我不能在这里分享题目,想知道的可以私聊。

前两道是概率论,之前别的公司的很多面试从来没有考这个的,所以我压根就没复习,而且题目的难度也不小,我直接连挂两道,这时我的好心态开始发挥作用,我没有紧张,反而越战越勇,将后面的三道算法题都解了出来。

总监面之后很快官网的状态就变成了HR面,这说明我通过了所有技术面试,只差最后一步。

HR面之前我已经接到了美团的OC,但是美团姗姗来迟的offer我实在是没什么兴趣,所以我拖了3天面了腾讯的HR面,面试那天是周六,我嘱咐HR小姐姐帮我快点处理,结果我周一晚上就收到了总部的OC,这样我就立刻拒绝了美团,接受了微信的暑期实习offer。

到此我已经在腾讯经历了9次面试,那最后一次便是实习转正的答辩了,后面再说。

其他公司

只列出能记起来的

image-20201206193637630

在滴滴

我的第一份实习岗位是滴滴的测试开发工程师,滴滴今年没有暑期实习,所以这是一份日常实习。当时我的计划是在秋招之前完成两份实习,一份前端/客户端,一份后端,在牛客看到滴滴的招聘简章写的是开发工程师,用的语言是golang,我自然以为是后端,结果拿到offer才知道是测开。

由于当时疫情严峻,公司允许远程办公,这是我的工卡的公司邮寄过来的电脑。

IMG_2697

我的mentor(导师)是冲哥,也是我的一面面试官,他待我很好,给我分的工作不重,我负责一个公司内使用的质量检测平台的开发和维护,这个项目在我之前经过了4任实习生之手,代码是名副其实的一坨屎,别谈什么编码规范,连跑在线上的项目都有很多错误,冲哥教给我一个道理:

有的项目本就是一坨屎,你要做的就是在一坨屎上加更多的屎,最后这一整坨屎还能完成功能。

不过我并没有完全按照冲哥说的做,我虽然水平不高,但是我对自己写的代码有较高的要求,小到一个函数命名,大到一个功能的设计,我都会认真思考。

刚接手这个项目的时候我遇到的第一个困难就是读代码,之前我做的项目基本都是我自己从头开始写的,所以每一部分我都能回忆起当时的思路,但是实际工作中很少会自己从头开一个项目,所以这方面能力是需要锻炼的。

这个项目有数万行代码,三个进程,每个进程内的逻辑复杂混乱,甚至设计得很不合理,还记得有一个函数1500多行,当时看的我心态大崩。我花了3天的时间看所有的代码,终于基本理清了项目的逻辑,后面交给我的工作基本都是简单的业务,我可以很快完成。

说说在滴滴工作的感受吧,上午10点上班,10点半开一个组会,然后工作到12点左右休息,下午2点左右开始工作,晚上6点吃饭,然后就可以走了,但是大家往往为了免费打车就等到9点再走,因为滴滴员工工作日晚9点以后打车免费。我在的这个组做的不是核心业务,总体上很轻松,任务有很多的buffer,但开发流程和项目管理不是很规范。

在滴滴这段时间对我非常有价值,我学到了很多,尤其是对git的使用更加熟练,并且了解了一个后端项目从开发到部署上线的完整流程。很可惜的一点是,我最后也没能去公司上班,整个过程都是在线上完成的。

在腾讯

刚入职腾讯的时候我是在远程实习的,当时进北京管控比较严,我的岗位是客户端开发iOS,这是我完全没有接触过的领域,我在入职之前买了几本书,了解了一下Objective-C和swift还有iOS相关的一些最基本的知识,但我没有时间尝试。

我的导师是畅哥,他是我一面的面试官,经过一段时间的相处我了解到他是一个技术实力很强的人,而且他非常会当导师这个角色,精通如何带人,我后来能顺利拿到offer很多来源于他的帮助。

刚入职的时候畅哥交给我一个很简单的任务:给App加一个更新提醒弹窗,但就是这样一个简单的任务我也是四处碰壁,畅哥的要求很严,小到一个变量的命名都不能有问题,我上来很快做完了UI,自我感觉良好,但在代码审查的时候畅哥指出了我很多问题,我也发现我的程序的鲁棒性不够,也没有很好地遵循代码规范更别提屏幕适配了。后来我一遍又一遍地修改,最终成功迈出了我在iOS领域的第一步。

IMG_2699

很快好消息传来:北京管制放松了,外地可以进京了,这样我就可以去公司办公了,这将会极大降低沟通成本,并且我也能融入团队。但是学校还是不允许京外返京,甚至有学生偷偷返京被发现并处分,与老师沟通无果。我毅然决定冒险返京,这代表着我需要自己解决租房,并且要躲过学校的侦查,后来证明我的决定是无比正确的。

出发之前我做了万全的准备,首先我搞定了健康打卡系统,这很好办,因为我参与设计了这个系统,所以我写了一个脚本放到服务器上定时执行:每天凌晨自动签到在大连。然后我提前选好了房子,因为我是短租,而且我也不想因为租房干扰工作,所以我直接选了自如,品牌公寓的优点是快捷、可以短租,缺点就是贵,不过为了未来,现在多花点钱也没有关系,我的工资也还可以负担。

同时我也向后看一步,如果秋招落败,或者没有满意的offer,还要征战明年的春招,这样的话我就不回家过年了,我和身边的人也是这么说的,实际结果来看我提前胜出了?

在腾讯实习的内容可以分为两部分,第一部分是一些业务需求,从简单到复杂,畅哥带着我循序渐进,在这期间我对iOS和Objective-C的理解更加深入。第二部分是flutter混合开发的预研,畅哥希望在我们的项目中用上flutter,正好我之前有接触过,所以就让我做这个预研,这段时间我收获很大,拆解了几个开源项目,并且自己动手实现了其中的一些功能,我也学到了很多道理,如:优化再优化、拿数据说话、工匠精神。这部分工作也成为了我转正答辩上的主要内容,也是我拿下sp offer的关键,再次感谢畅哥。

最重要的是我确定了我毕业后的工作方向:iOS客户端。

腾讯的上班时间是弹性的,强度据我了解应该算互联网公司中等的,但这也分部门。我在腾讯的一天是这样的:7点半起床洗漱坐班车、9点到公司吃早饭、吃完后开始工作到12点吃午饭,午休到2点,下午6点吃晚饭,吃完之后其实就可以走了,但我当时一心求成,所以晚上会搞得很晚,最晚10点多赶最后一班地铁。

IMG_2704

晚上的办公区

在腾讯暑期实习生可以享受和正式员工差不多的福利待遇:免费早晚餐、免费饮料、节日礼品等。实习生入职的时候可以选择自己的办公套装,设备都是全新的,配置也都很豪华,我当时没有选择iMac Pro,因为我自己有一台mbp,而我想多要一个显示器,所以就让畅哥帮忙搞了一套think center。

IMG_2703

腾讯班车

IMG_2700

中秋月饼礼盒

IMG_2702

茶水间

IMG_2701

我的工位

IMG_1814

我的工卡

后来开学了,学校要求返校并且不允许随意出校,我在腾讯2个月的实习就告一段路,至于转正答辩等到秋招篇章再说。

秋招

这是最终的战斗,之前所有的努力都是为了在秋招中拿下高质量的offer。首先来说一下我选择offer的三个要素(按重要程度先后顺序):

  • 业务(团队)

    我希望加入一个朝气蓬勃的团队,做一个有前途的业务,这点是最重要的。

  • 薪资

    不必多说,这个行业毕业后第一份工作的薪资也叫起薪很重要。

  • 城市

    我并不打算在刚毕业就选择后半生生活的城市,但第一份工作我会以北京优先,因为大学4年都生活在北京,在这里我已经积累了一定的人脉,也熟悉这片土地,在刚毕业这个羽翼尚未丰满的时候这些都是能助我快速发展的重要因素。


接下来我还是分公司来说一下我秋招的情况。

网易

网易秋招刚开始的时候我填了网申,业务选的是网易传媒,也就是网易新闻,其实我对严选很感兴趣,但可惜的是严选在杭州,北京只有有道词典和网易传媒。

网易的招聘流程是:行测➡️笔试➡️若干面试➡️offer

填了之后网易立刻给我发了行测邀约,但由于那段时间在准备答辩,比较忙,所以打算过段时间再说,后来答辩之后感觉不错,再加上美团那边HR面结束也自我感觉良好,让我觉得网易没啥面的必要了,因为腾讯和美团过一个我都不会再考虑网易了,不如把机会留给别的同学。之后就很戏剧性了:

IMG_2706

既然网易如此有诚意,我还是给个面子吧,然后我就很顺利的过了3轮技术面+1轮HR面,这里再次感谢畅哥在实习期间待我做的工作,真的非常有帮助,在面试的有限时间里,我语速很快也很难讲完我的成果。

最后我在10月份接到了正式的offer,但是我拒绝了,网易的offer今年都很低,而且业务我也不是很感兴趣。

字节跳动

我在19年的时候参加过一场字节跳动举办的技术沙龙,主题是flutter,无论是精致的点心还是台上工程师讲解的技术都给我留下了很深的印象。

IMG_0934

其中GitYuan(袁辉辉老师)更是令我折服,他是Android高手、Flutter贡献者,他的博客更是有很多非常精彩的文章,我打听到他目前带领字节跳动客户端中台技术的一个团队,我就对他的团队非常向往。拿到他的内推码之后就开始了字节的面试,一面顺利,二面挂了,这两面的面试官给我一个非常差的印象,然后我又被捞起来了,一面过,直接接到OC,业务是抖音,我不喜欢,再加上字节是大小周工作制,直接拒绝了。

看来我和字节跳动的缘分还没到。

美团

美团我一直很关注,今年美团搞了个客户端提前批,说明客户端比较缺人,项目开始我就投了客户端iOS,由于我曾经拿到过暑期实习offer,所以直接走绿色通道跳过了笔试,2轮技术面+1轮HR面非常顺利,这次选中我的还是到店试业部,具体业务是门票,也是top业务,我也很喜欢。

在10月中旬我收到了美团的正式offer,这之间还有个小插曲,美团HR特地打电话来补发了一次邮件,增加了6w的签字费,第一次发的offer邮件中没有。美团只给了48小时确认时间,虽然那时我已经收到了腾讯的OC,但并不知道待遇,所以我只能先在网站上点了接受,后来又拒绝了。

由于后来我选择了腾讯,所以我可以透露一下,美团给我的首年年薪是39w,至于为什么要强调首年,是因为公司每年都会有若干次调薪,一般第二年会比第一年高一些。这也只是美团客户端今年的白菜offer。

腾讯

先说说转正答辩吧,我收到答辩通知邮件的时候就开始准备,用keynote做了一个很长的ppt。

image-20201206182620431

那几天早晨来的早就做ppt,晚上回家之后也是做ppt或练习,在这个过程中也有几位朋友帮助我练习,这里重点感谢天哥,他熬夜帮我美化了ppt,祝他求学顺利,早日成为大拿。这个答辩要求15分钟不能超时,之前我很少做这么长的报告,一开始还怕内容不够,后来发现是我多虑了,还是感谢前期畅哥带我做的事情,让我有很多可在答辩的时候展示的。答辩过程很顺利,我全程语速很快,正好将想说的内容讲完,感觉评委也很满意,当时心里想:稳了!

果然不久之后我就收到了OC,接着在11月初收到了正式offer。

image-20201206185853473

至于薪资是公司的高压线,不能告诉第三方,员工之间也不能互相打探,在开奖(HR通知薪资)之前我是有心理准备的,但还是没想到会那么高,之前不敢想的数字。


这里再次强调一下暑期实习的重要性,尤其对于低学历和普通学校的学生,面试官往往无法在面试的短短几个小时里看到一个人的全部能力,更别提潜力,而且应试者还会有状态的好坏。但实习几个月的时间,足以让企业了解你到底有几把刷子,最后给你一个中肯的评价。所以说:暑期实习是面试的后半场。

至于我为什么没有投更多公司,因为我觉得这几家已经足够我选了,拿太多offer会吃掉其他同学的HC,我不想当海王。肺腑之言,不装圣母。

最后

至此我的秋招之路走到了终点,或者说提前胜利了,这一路走来并不容易,需要耐心和努力,在关键的时候需要勇气踏出一步或做出选择,当然也需要一点运气。

我从一个双非一本的普通高校进入了国内顶尖互联网公司,我身边的同事大部分来自一流名校,高学历很多。终于,经过这几年的努力我和他们站到了一起。实习期间坐我前面的同事电脑上贴着一句话:

我不是天才,但我与天才们共事。

每每想起我都很有感触,的确,和优秀的人相处想不优秀都难。这里不得不说起男哥,他和我在大二一起转到计算机,我们虽有不同的目标(他要读研,我要工作),但我们前进的道路是一条,我们互相扶持,共同进步,最终他以专业第一的成绩保研北邮。

IMG_2648

很快我将迎来人生的下一篇章,放眼将来,我希望我能保持一颗年轻的心和探索精神,就像腾讯校招那句话:“让世界看到我的影响力。”

同时,我也不惧改变,敢于面对新的挑战,也许某一天我会抛下熟悉的一切,去另一个地方开疆扩土,就像当初来北京那样。


能读到这里的都是朋友,欢迎找我内推!

bbfat3047@qq.com

基于protobuf & MethodChannel实现Flutter混合开发通信

Flutter和Native的通信是混合开发的一个关键问题,Google官方给出了MethodChannel这个方案,使用起来很简单:设置好通道名和方法名还有钩子函数,两边就可以互相调用方法。MethodChannel支持下图的基本数据类型:

image-20200803172422873

MethodChannel对于简单的消息传递已经够用了,但如果想要传递一个复杂的消息需要将消息打包成一个json也就是Map进行传递,在打包和解包的过程中包含了很多类型检查及转换的工作,并且需要在native侧和flutter侧分别实现一次,人工实现还容易出错。

为了解决上述问题,我们开发了一款基于MethodChannel和Google的protobuf的插件。

protobuf是一款跨平台、跨语言的序列化数据结构,编写一个.proto文件声明数据的结构,然后通过插件就可以将它转换成支持的目标语言代码。

使用protobuf作为flutter和native通信的介质主要有以下几个优点:

  • 三端(iOS、flutter、Android)可以共用一个.proto文件
  • 强类型支持,可以将类型检查封装在解包过程中
  • protobuf编解码效率很高

实现上述功能需要以下几个步骤:

PBMethodChannelCodc

这个类是个单例,是PBMethodChannel使用的编解码器,它负责将消息打包、解包并进行最基本的类型校验,即将消息从PB对象转换成二进制和将消息从二进制数据解包成一个PB对象。

通信传输一个基本的pb消息,里面包含两个字端:methodparam

其中method是字符串类型,为方法名;param是Any类型,可以存放其他类型的pb消息。

当接收到消息的时候解码器会将二进制数据解码成上述基本pb消息并进一步转换成FlutterMethodCall,当调用一个方法的时候将FlutterMethodCall打包上述pb消息。

PBMethodChannel

封装这样一个类,它有类似MethodChannel的接口,数据载体是protobuf,下面是iOS头文件示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 调用Flutter回调闭包
typedef void (^PBMethodChannelResult)(GPBAny *arg);

// 接收Flutter调用闭包
typedef GPBMessage * _Nonnull(^PBMethodChannelHandler)(GPBAny *arg);

@interface PBMethodChannel : NSObject

+ (instancetype)methodChannelWithName:(NSString *)name
binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger;

- (void)invokeMethod:(NSString *)method;

- (void)invokeMethod:(NSString *)method arguments:(GPBMessage *)arguments;
- (void)invokeMethod:(NSString *)method arguments:(GPBMessage *_Nullable)arguments resultBlock:(PBMethodChannelResult _Nullable)resultBlock;

- (void)setMethodCallHandler:(NSString *)method handler:(PBMethodChannelHandler)handler;
- (void)removeHandler:(NSString *)method;

@end

PBMethodChannelApi

为了实现类型检查,还需要封装一个类来方便使用PBMethodChannel,希望达到的效果是:声明一个方法的输入类型和输出类型然后可以直接调用,并且响应的结果自动经过类型转换和检查。


经过以上的努力,我们就可以在混合开发中愉快地使用MethodChannel了:

  • 确定通道名、方法名
  • 写一个pb消息并运行插件生成三端代码
  • 分别在native和flutter之中实现调用/响应逻辑

Flutter引擎复用&路由管理

本文主要以iOS视角叙述,Android换汤不换药。

背景

Flutter目前非常火热,开发者使用Flutter编写一套代码在不同平台上的表现高度一致,并且在开发过程中可以使用Hot reload功能,这可以极大提升开发效率,在Google DevFest 2019上Flutter公布了add to App的能力,这使现有的原生项目接入Flutter成为可能。业界已经有很多公司和产品使用Flutter混合开发并且落地。

这片文章主要解决Flutter接入原生App之后资源占用过高的问题以及在此基础上如何获得一致的路由管理体验。

Flutter接入现有App的方式

主要有两个对象:

Flutter引擎负责运行Dart代码、渲染UI、响应事件。

  • FlutterViewController

    继承自UIViewController,Flutter嵌入到原生的视图,与FlutterEngine协同工作。

这两个对象都比较重,尤其是FlutterEngine,官方建议使用之前先pre-warm,如果在打开第一个Flutter页面的时候再初始化会比较慢。

目前有以下几个问题:

  • FlutterEngine和FlutterViewController必须一一对应

    一个引擎只能同时管理一个视图,也就是说需要有多个Flutter视图的时候就需要多个引擎。

  • 多个不同的FlutterEngine内存不共享

  • FlutterViewController和FlutterEngine的实现都在Flutter.framework中

    如果希望Flutter视图继承现有的某个BaseViewController以获得一些特性是做不到的。

引擎复用

解决资源占用过高的第一步就是实现FlutterEngine的复用,即多个FlutterViewController可以共用一个引擎。

目前业界的方案都是基于这个原则:同时只有一个FlutterViewController会呈现

通过替换engine上挂载的视图就可以实现engine的重用,这样任何时候内存中只需要一个engine,哪怕有多个视图,这样可以大大减少内存占用。

⚠️替换视图的时机很重要,如果把握不好Flutter会假死。

路由管理

当App中同时有不同种类的页面(native、webview、flutter。。。)如何优雅地管理路由?

首先我们来看一个开源项目:flutter_thrio

它实现了三端(iOS、Android、flutter)通过url统一管理路由并且复用引擎,给了我很大启发。

以push一个页面为例,这是官方的时序图:

thrio-push

thrio将所有的路由操作统一收拢到native侧执行,然后再由PlatformChannel发送给flutter。

图中有一些细节不是很详细,我阅读了它的源码之后自己画了个流程图:

image

thrio解决了engine复用和多端路由同步的问题,非常厉害,但是它需要同时维护原生和flutter两个导航器,这可能会造成些不稳定的表现,我在运行它的Demo的时候就触发过几次无法pop页面的Bug,另外一个问题就是当原生页面和flutter页面交替出现的时候thrio会连续创建多个FlutterViewController,内存消耗较大。

我们希望任何一方(native、flutter、webview。。。)页面概念统一为原生的ViewController,这样FlutterViewController也可以被复用,进一步节约资源。

FlutterViewController复用

基本思路是将FlutterViewController做成单例,挂在当前显示的ViewController上。

用一张类图讲解一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
classDiagram
FlutterViewController <|-- FlutterContainer

UIViewController <|-- FlutterViewController
UIViewController <|-- BaseViewController

class FlutterContainer {
-(void)mountToViewController(UIViewController *viewController);
}

class FlutterRouteSettings {
-String url;
}

class BaseViewController {
-(void)willDisappearWhenAnotherViewControllerPush:(BaseViewController *)viewController;
-(void)didShowAfterAnotherViewControllerPop:(BaseViewController *)viewController;
-(void)willPushInNavigationController;
-(void)didPopInNavigationController;
}

BaseViewController <|-- MyFlutterViewController
class MyFlutterViewController {
-(instancetype)initWithRouteSettings:(FlutterRouteSettings *)settings;
}

class FlutterCoordinator {
-MethodChannel pushChannel;
-MethodChannel popChannel;
-(void)pushWithSettings:(FlutterRouteSettings *)settings;
-(void)pop;
-(void)flutterPushWithSettings:(FlutterRouteSettings *)settings;
-(void)flutterPop;
}

FlutterCoordinator的作用:

  • 通过PlatformChannel接收Flutter传来的路由操作并操作路由
  • 通过PlatformChannel向Flutter发出路由操作的命令
  • 暴露接口给MyFlutterViewContorller传递来自原生模块的路由操作给Flutter
  • 持有FlutterEngine
  • 其他原生模块和Flutter的交互经过FlutterCoordinator传递

FlutterContainer的作用:

  • 集成自FlutterViewController可以配合引擎实现Flutter的功能
  • 单例模式保证只有一个FlutterViewController
  • mountToViewController可以将Flutter挂载到不同的ViewController上,实现FlutterViewController的复用

FlutterRouteSettings:存放flutter路由跳转时的信息。

MyFlutterViewController的作用:

  • 作为包裹FlutterViewController的容器,同时也是页面概念的基本单位
  • 集成自BaseViewController让Flutter页面也可以拥有基类的特性
  • 通过声明周期控制FlutterContainer的挂载

复用FlutterViewController确实能节省很多资源,但是有一个问题:Flutter只能挂载在一个页面,页面切换过程会很奇怪。

这个问题我通过一个“障眼法”来解决:当flutter页面之上有一个新的页面push进路由栈的时候将这个flutter页面截图并显示出来,这样即使flutter从这个页面卸载,在页面切换过程中看到的也是之前flutter显示的内容;当pop到该页面时先挂载flutter然后再将截图销毁。

为了实现这个“障眼法”我从BaseViewController中派生出了多个生命周期回调来确定合适的挂载、替换时机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
- (void)willDisappearWhenAnotherViewControllerPush:(BaseViewController *)viewController {
// 切换成截图
_screenShotView = [[UIImageView alloc]
initWithImage:[UIImage qmui_imageWithView:[XWFlutterContainer sharedInstance].view afterScreenUpdates:NO]];
_screenShotView.frame = CGRectMake(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
[self.view addSubview:_screenShotView];
}

- (void)didShowAfterAnotherViewControllerPop:(BaseViewController *)viewController {
[[FlutterContainer sharedInstance] mountToViewController:self];

// 卸载截图
if (_screenShotView) {
[self.view bringSubviewToFront:_screenShotView];
// 给flutter挂载和切换路由的时间
QHDispatchDelayMain(.1, ^{
[self.screenShotView removeFromSuperview];
self.screenShotView = nil;
});
}
}

- (void)willPushInNavigationController {
// 延迟0.01秒挂载可以避免flutter无响应的问题
QHDispatchDelayMain(.01, ^{
[FlutterShared pushWithSettings:self.settings];
[[FlutterContainer sharedInstance] mountToViewController:self];
});
}

- (void)didPopInNavigationController {
[FlutterShared pop];
}

这的处理其实还是比较粗糙,Flutter的挂载时机还需要更细致的研究,最好的情况是Flutter完成路由变换之后能回调原生。

参考资料

国税局网站js逆向思路

我之前的博客逆向工程Python爬虫——国税局发票查验平台记录了我逆向国家税务总局发票查验平台的js并用python实现自动化查验的经历。

2020年4月,国税局又对网站进行了更新,主要有以下内容:

  • 对所有js使用了更多种类更难逆向的混淆
  • 增加了反调试
  • 对部分地区的验证码接口返回的数据加密
  • 增加了一个请求拦截器,其功能是对所有接口通过请求参数计算生成一个新的签名flwq39

反调试

我的Chrome一直跟随官方升级,版本太高导致不允许访问自签名的网站,所以我需要通过命令启动Chrome来关掉安全设置:/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --ignore-certificate-errors

我之前的博客【爬虫技术】破解js反调试有讲过反调试的原理和解决办法,文章中主要讲解的办法比较复杂,但是足以应对几乎所有同类问题,我的同学向我提供了一个更快的办法:

  • 进入网站之后打开开发者工具,禁用断点然后继续执行。

    image-20200503162816568

    这时页面可以正常响应,但是我们设置断点也不好用了。

  • 打开console输入以下代码并执行

    1
    2
    3
    for(var i=0;i<99999;i++){
    window.clearInterval(i)
    }
  • 此时产生断点的函数就被清理干净了,这时候再点上图的1启动断点就可以了

逆向

本文的重点是逆向,我将混淆类型分为两类:

  • 字符混淆
  • 函数嵌套混淆

字符混淆

每个文件开头会有一个很长的字符数组,然后会有一段代码对这个数组进行加工,然后还有一个函数接收一个或两个参数输出一个字符串,这个字符串更接近原始的代码。

下面是以文件emwrs.js为例:

image-20200503164406422

上图的_0x42bd函数就是翻译用到的函数。

image-20200503164538028

可以发现这个函数在这个文件之中被调用了1000+次:

image-20200503164615389

初始化的函数我尝试用本地的Node执行会造成堆栈溢出,由于我不是专门搞js的,所以我不清楚这是为什么,我换一种思路逆向。

既然税务局网站上这些js都能正常运行,我何不利用一下?

以下是具体的操作步骤:

  • 打开网站解决反调试

  • 查看网页的源码,将body内的内容退格全删掉

    image-20200503165335315

    变成这样:

    image-20200503165427025

  • 接下来编辑HTML并将要逆向的代码全粘上去

    image-20200503165505681

    image-20200503165529245

  • 在console中运行逆向脚本

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    var s = $("body").text()
    var translate = function () {
    while (1) {
    var res = /_0x42bd\('.*?'\)/.exec(s)
    if (res == null) {
    break
    }
    var words = res[0].split("'")
    console.log(words[1], words[3])
    s = s.replace(/_0x42bd\('.*?'\)/, '"' + _0x42bd(words[1]) + '"')
    }
    }
    translate()

    如果要逆向的是别的文件就将_0x42bd改成那个文件的解密函数名,还要注意以下那个函数接收几个参数,要做相应的修改。

  • 运行完成之后s变量保存的就是逆向之后的js

    下面展示一下同样两部分代码逆向之前和之后的对比:

    image-20200503170312886

函数嵌套混淆

这种混淆一般发生在大一点的函数或者文件之中。

image-20200503170745825

它会定义一个有各种参数的对象,然后在这个对象的生命周期内各种调用。

比如这样一段代码:

1
2
3
if (_0x35d3d6['bxxlQ'](_0x35d3d6["bIpav"], _0x35d3d6['Cpovd'])) {
return !![];
}

其实它是这样的:

1
2
3
if (false) {
return !![];
}

这种混淆我没有太好的办法,处理方式有两种:

  • 在浏览器里开一个标签页,将这个对象在console执行,然后需要调用的就输到console里看结果
  • 确定这个对象没有其他依赖之后直接将它拉进Node要执行的js中

写在最后

个人看法,反混淆没有什么通法,也就是见招拆招的把戏。

最近国税局网站更新越来越频繁,在网友的帮助下刚搞定flwq39参数还没来得及测试它就又加了一个key9让我们措手不及。

打一个广告

我最近开了一个新坑,是一款自动迁移Markdown中图片的App,感兴趣的可以来看看Markdown Mover求?求反馈。

【爬虫技术】破解js反调试

爬虫的时候经常需要使用浏览器的调试工具分析请求或者给js打断点观察程序的运行过程,有一种反爬思路叫做反调试,字面理解很简单,不让你调试自然会给爬虫的分析阶段制造很大困难。这篇博客讲重点讲解其中比较常见的一种反调试——断点。

这篇博客的点子源于我的另一篇博客——逆向工程Python爬虫——国税局发票查验平台,这篇博客讲解了的项目InvoiceSpider——自动化发票查验。反调试是在这个项目中遇到的一个问题。

断点反调试原理

使用浏览器的开发工具可以给js打断点,断点除了点击开发工具的行号生成还可以通过js的关键字debugger生成,断点反调试的思路就是通过死循环debugger给程序打上无穷无尽的断点,如果这个死循环只是一个for循环的话,我们可以设置调试工具忽略这个断点。但事情往往不会这么简单,网站的开发者可以生成无数个匿名函数,这样debugger是在运行过程中出现的,我们不能用前面的方法避免。下面就来看看国家税务总局发票查验平台的反调试。

image-20200405113508228

这里是函数调用的堆栈:

image-20200405113613098

可以看到这是由emwrs.js生成的匿名函数。

image-20200405113717416

破解思路

思路1

究其本源,是这段代码:

1
2
3
4
5
6
7
8
setInterval(function() {
var _0x2b0b57 = {
'JUCUD': function(_0x5152b4) {
return _0x5152b4();
}
};
_0x2b0b57[_0x5a05('0x0', 'DAFb')](_0x43bdfc);
}, 0xfa0);

代码是经过混淆的,但是可以肯定是这段代码造成了死循环的debugger

我的思路很直接:干掉这段代码!

想要干掉这段代码,我就需要拦截这份js文件并且修改它的内容。

思路2

这个思路是由一位web开发的同学提供的。

下载全部js代码之后发现,所有的debugger是由数个Interval生成的匿名函数,我们首先禁用断点,这里我使用我手上的三款浏览器做了测试,Firefox遇到这种反调试会直接卡死;Chrome和Safari可以正常操作。然后在console里输入下面这段代码:

1
2
3
for(var i=0;i<99999;i++){
window.clearInterval(i)
}

这样就可以停掉所有的Interval,非常巧妙。

mitmproxy

mitmproxy是一个python写的开源抓包工具,安装好证书之后它可以很方便的拦截HTTPS数据并修改内容。

这是他的官网:https://mitmproxy.org

安装

直接用HomeBrew:brew install mitmproxy

启动

终端输入:mitmproxy

如果没有端口冲突就会默认以8080为代理端口。

这里由于税务平台的证书是自签名的,所以我需要加一个参数:mitmproxy -p 8080 --set ssl_insecure这样就可以正常抓包。

配置网络

接下来设置代理。

打开网络设置=>选中当前的网络=>高级=>代理

image-20200405114954737

勾选HTTP和HTTPS将代理服务器设置成本机ip端口写mitmproxy监听的端口。

然后 好=>应用(别忘点)

这样再用浏览器任意访问一个页面就会看到抓到的数据包。

image-20200405134518250

安装证书

HTTPS由于有SSL或TLS加密保护,所以如果想抓加密的包需要安装mitmproxy的证书,在上面的步骤都完成之后打开这个网站:

http://mitm.it

image-20200405134812958

选择相应的系统下载,本人使用的是Macbook Pro,所以下载第一个,将证书安装好之后手动改成信任,这里如果有疑问请自己搜索,搜索的时候别忘了带上你的操作系统。

使用方法

接下来我们简单测试一下HTTPS抓包是否正常,浏览器访问百度。

mitmproxy中上下键选择请求,然后回车查看详情。

image-20200405135354512

这里可以完整的看到HTTPS请求的详细数据。

q返回上一级。

f对请求进行筛选。

这里我直接输入set view_filter 'baidu',回车之后就只会显示url中带有“baidu”的请求。

接下来简单看一下拦截器的使用,按i然后输入baidu,接下来浏览器刷新一下百度。

可以看到这边请求变成了红色,这代表次GET请求被拦截了。

image-20200405135845152

此时我们可以修改它的内容,按回车进入详情,tab切换到Response,然后按e然后按b也就是response body,接下来会进入一个vim编辑,随便输入点什么,然后保存退出编辑,接下来按a放行请求。

image-20200405140159992

请求就被更改了。接下来就是实战了,我们破解一下反调试。

实战

首选浏览器Chrome版本太高,无法正常访问那个网站,所以我退而其次用Firefox。

处理js

首先正常访问目标网站,加载完成之后 文件=>另存为=>网页,全部。

接着用编辑器打开保存下来的网站所有js,全局搜索setInterval

image-20200405140547109

一共有5处匹配,排除jquery和common中的两处,原来税务总局的程序员在3个代码文件中加了死循环debugger,我们一一注掉。

image-20200405140722942

编辑器放在这里备用。

拦截并修改js

启动mitmproxy,然后设置拦截规则,直接将这三个文件的文件名写上,筛选条件写税务总局的域名。

image-20200405141005626

接下来从Firefox打开一个无痕窗口(防止某些js被缓存,不真正发请求)。

访问目标网站。

接下来首先会拦截到emwrs.js

image-20200405141339164

修改其response body将之前准备好的代码粘贴过来,然后连按两次a放行。

剩下两个文件同理。

image-20200405141458101

经过这一步之后浏览器的网站就正常打开了。

接下来我们可以轻松加愉快打开调试器,不会有烦人的断点了。

image-20200405141644493

⚠️Firefox遇到无限断点的时候打开调试器会因为无限堆栈一会就卡死,所以在修改js成功之前不要随便打开调试器。

测试跟踪调试

我们对发票号码输入框的blur(失焦)进行跟踪。

image-20200405141850720

可以正常跟踪调试了,反调试攻破!

关于混淆

就拿这行代码为例,是经过混淆的js:

var _0x4a95b4 = $(_0x2e7e('0x5b')) [_0x2e7e('0x3b')]() [_0x2e7e('0x60')]();

这个看起来很吓人,其实破解的思路不难,可以看出$(_0x2e7e('0x5b'))这是在使用jQuery选择器选一个对象,由于这里是发票号码的失焦,所以我猜一定和发票号码有关系,我们去控制台看一下。

image-20200405142530645

果然,继续猜测,混淆的思路应该是将容易看懂的变量名通过对象做了一下转换,我们直接看一下这个_0x2e7e是啥?

image-20200405142652671

image-20200405142834585

出现了!一些熟悉的变量名。既然能看到可以破解,但我暂时还没有太好的办法,再研究研究。

逆向工程Python爬虫——国税局发票查验平台

这片文章初成于2019年8月31日,当时平台的代码版本是V1.0.07_001,我的Chrome版本是76.0.3809.132,后文所述的都是在上述情况下完成的。

本文最新修改与2020年4月12日,税务平台的代码也经过了多次迭代,最新版的Chrome会禁止访问自签名的网站,可以在启动Chrome的时候增加一个--ignore-certificate-errors参数来解决这个问题,或者可以使用Firefox或Safari。

平台最新的版本增加了反调试,如果你想正常使用调试工具就需要解决这个问题,推荐阅读我的这篇博客【爬虫技术】破解js反调试

由于本人没有涉猎机器学习,验证码识别部分的模型是Siege LionY先生在COVID-19期间费尽心力训练成的,在这里衷心向他们表示感谢。

为了保护文章的整体性,2019年已经完成的部分不做改动,后续的更新将加在博客的最后。


前言

这是一篇含金量很高的干货文章,笔者将手把手带领各位一步一步地实现爬取国家税务总局全国增值税发票查验平台(以下简称“查验平台”)。这个想法诞生在19年初,当时在做一款通过扫描二维码就可以查验发票的小程序。
IMG0431.PNG
当时由于笔者学艺尚浅,没办法模拟请求爬取查验平台,所以最终采用的技术方案是通过web自动化测试工具selenium控制浏览器去模拟查验步骤,即使这样,开发过程也是困难重重,不过最后笔者和伙伴们成功实现了整套流程,最后开发出的产品口袋发票夺得了包括2019微信小程序开发大赛赛区三等奖在内的多个奖项。
但是产品是无法真正上线的,因为通过selenium爬虫的方式实在是太消耗性能了,测试结果表明:百度云4核8G的服务器只能同时服务10人以内。
笔者一直不甘心,暗自下定决心:一定要实现模拟请求爬取。
那么闲话少说,我们开始吧。

查一张发票

第一步肯定是分析查验平台整体的逻辑,所以我们首先来真实地查验一张发票。
这里笔者使用的是Chrome 76.0.3809.132,是本文发布时的最新版本。
image.png

  • 这里我找到了一张发票,首先输入发票号码
    image.png
    我发现:当我的光标移动到发票号码输入框时发票代码右侧出现了一个对勾,这说明在发票代码失焦的时候会检测发票代码正确性,然后给用户一个反馈。
    这是我在京东买书的发票,是在北京开具的,这里可以了解一下发票号码的含义:百度百科
  • 接下来我们继续输入发票号码
    image.png
    有趣的事情出现了,发票号码失焦之后下面突然出现了验证码,这里我们得出结论:验证码请求的时机是在发票号码失焦之后
  • 继续输入开票日期和校验码后六位
    image.png
    此时查验按钮还不可点击
  • 输入验证码
    image.png
    此时我们了解到税务总局的验证码含有中文和英文,并且需要根据颜色指示输入,让我们多刷新几次验证码。
    image.png
    image.png
    image.png
    image.png
    我刷新了n次之后发现:验证码提示只有4种情况:输入全部、黄色、红色、蓝色
    查验按钮在所有信息填写完之后出现
  • 最后点击查验即可得到发票的真伪和详细信息

查看获取验证码的请求

  • 我将网页刷新然后F12打开Chrome的调试工具,点击Network然后将列表清空。
    image.png
  • 接下来我们重复刚才查验发票的操作,直到失焦发票号码输入框,然后观察验证码是怎么来的。
    image.png
    此时我们发现了验证码的请求,看一下详细信息。
    image.png
    这是一个jQuery发起的请求,笔者对jQuery了解的不深,不过我们继续看请求参数:
参数名 含义
callback 固定值
fpdm 发票代码
fphm 发票号码
r 看起来像是个用来签名的随机数
v 应该是版本号 是个固定值
nowtime 请求发起时的时间
area 简单猜测和地区有关系
publickey 签名(我们需要破解的东西)
_ 不知道这是什么

那么现在我们的目标很明确了,找到publickey的计算方法。
让我们来利用Chrome调试工具的动作追踪功能,首先定位发票号码的输入框,然后打开这个项目
image.png
下面的blur指的就是失焦操作,我们看到有面有一个js文件,打开它,并点击左下角格式化。
这里有必要说明一下,这个文件叫做“VM54403”并不是说真的有这么一个文件,而是说这个文件是由其他的js代码解码而来的虚拟文件。

1
2
3
4
5
6
7
8
9
$('#fphm').blur(function() {
var fphm = $("#fphm").val().trim();
if (fphm.length != 0 && fphm.length < 8) {
ahmch(fphm)
}
var fpdm = $("#fpdm").val().trim();
afcdm(fpdm);
acb(fplx)
});

这里有3个函数ahmch、afcdm、acb,我们不清楚它们的作用,那么我们来利用调试工具的断点来执行语句。
image.png
加下来我们发现fpdm长度大于8,ahmch不执行,那我们就先不管。
afcdm函数是核心函数之一,它有打断篇幅在检测fpdm的合法性。

  • 第13行:
1
var swjginfo = getSwjg(fpdm, 0);

这里出现了getSwjg函数,这是一个很重要的函数,我们来看看它是做什么的,定位断点到13行,然后F8执行到断点,我们得到了getSwjg函数的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function getSwjg(fpdm, ckflag) {
var flag = "";
eval(function(p, a, c, k, e, d) {
e = function(c) {
return (c < a ? "" : e(parseInt(c / a))) + ((c = c % a) > 35 ? String.fromCharCode(c + 29) : c.toString(36))
}
;
if (!''.replace(/^/, String)) {
while (c--)
d[e(c)] = k[c] || e(c);
k = [function(e) {
return d[e]
}
];
e = function() {
return '\\w+'
}
;
c = 1;
}
;while (c--)
if (k[c])
p = p.replace(new RegExp('\\b' + e(c) + '\\b','g'), k[c]);
return p;
}('24 X=[{\'7\':\'12\',\'8\':\'13\',\'6\':\'0://3.G.4.2.1:5\',\'9\':\'0://3.G.4.2.1:5\'},{\'7\':\'14\',\'8\':\'Y\',\'6\':\'0://3.L.2.1:5\',\'9\':\'0://3.L.2.1:5\'},{\'7\':\'1j\',\'8\':\'1g\',\'6\':\'0://3.U.4.2.1\',\'9\':\'0://3.U.4.2.1\'},{\'7\':\'1k\',\'8\':\'1f\',\'6\':\'0://3.K.4.2.1:5\',\'9\':\'0://3.K.4.2.1:5\'},{\'7\':\'1a\',\'8\':\'18\',\'6\':\'0://3.R.4.2.1:5\',\'9\':\'0://3.R.4.2.1:5\'},{\'7\':\'1e\',\'8\':\'1h\',\'6\':\'0://3.m.4.2.1:5\',\'9\':\'0://3.m.4.2.1:5\'},{\'7\':\'1c\',\'8\':\'1b\',\'6\':\'0://3.q.2.1:5\',\'9\':\'0://3.q.2.1:5\'},{\'7\':\'1d\',\'8\':\'17\',\'6\':\'0://3.j.4.2.1:d\',\'9\':\'0://3.j.4.2.1:d\'},{\'7\':\'19\',\'8\':\'1l\',\'6\':\'0://3.f-n-
//省略部分乱码
)
var dqdm = null;
var swjginfo = new Array();
if (fpdm.length == 12) {
dqdm = fpdm.substring(1, 5)
} else {
dqdm = fpdm.substring(0, 4)
}
if (dqdm != "2102" && dqdm != "3302" && dqdm != "3502" && dqdm != "3702" && dqdm != "4403") {
dqdm = dqdm.substring(0, 2) + "00"
}
for (var i = 0; i < citys.length; i++) {
if (dqdm == citys[i].code) {
swjginfo[0] = citys[i].sfmc;
if (flag == 'debug') {} else {
swjginfo[1] = citys[i].Ip + "/WebQuery";
swjginfo[2] = dqdm
}
break
}
}
return swjginfo;
}

观察这个函数,我们发现它的作用是根据fpdm查询信息。其中有一段加密混淆的js代码,我们利用一个工具网站解密。
JavaScript Eval Encode/Decode
将eval函数和其中的代码拷贝进去,然后点击解密,我们得到一个js对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var citys = [{
'code': '1100',
'sfmc': '北京',
'Ip': 'https://fpcy.beijing.chinatax.gov.cn:443',
'address': 'https://fpcy.beijing.chinatax.gov.cn:443'
},
{
'code': '1200',
'sfmc': '天津',
'Ip': 'https://fpcy.tjsat.gov.cn:443',
'address': 'https://fpcy.tjsat.gov.cn:443'
},
{
'code': '1300',
'sfmc': '河北',
'Ip': 'https://fpcy.hebei.chinatax.gov.cn',
'address': 'https://fpcy.hebei.chinatax.gov.cn'
},
//省略后面的数据

这是我们需要的数据。
接下来我们回到afcdm函数,又发现了28行出现了关键的代码:

1
fplx = alxd(fpdm);

这里调用了一个alxd函数对发票类型进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function alxd(a) {
var b;
var c = "99";
if (a.length == 12) {
b = a.substring(7, 8);
for (var i = 0; i < code.length; i++) {
if (a == code[i]) {
c = "10";
break
}
}
if (c == "99") {
if (a.charAt(0) == '0' && a.substring(10, 12) == '11') {
c = "10"
}
if (a.charAt(0) == '0' && (a.substring(10, 12) == '04' || a.substring(10, 12) == '05')) {
c = "04"
}
if (a.charAt(0) == '0' && (a.substring(10, 12) == '06' || a.substring(10, 12) == '07')) {
c = "11"
}
if (a.charAt(0) == '0' && a.substring(10, 12) == '12') {
c = "14"
}
}
if (c == "99") {
if (a.substring(10, 12) == '17' && a.charAt(0) == '0') {
c = "15"
}
if (c == "99" && b == 2 && a.charAt(0) != '0') {
c = "03"
}
}
} else if (a.length == 10) {
b = a.substring(7, 8);
if (b == 1 || b == 5) {
c = "01"
} else if (b == 6 || b == 3) {
c = "04"
} else if (b == 7 || b == 2) {
c = "02"
}
}
return c
}

我们得到了根据fpdm计算fplx的函数。
继续观察afcdm函数,103行出现了请求验证码的函数:getYzmXx
这个文件都是控制验证码请求的,所以我们将它保存下来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function getYzmXx() {
show_yzm = "1";
var fpdm = $("#fpdm").val().trim
var swjginfo = getSwjg(fpdm, 0);
var url = swjginfo[1] + "/yzmQuery";
var nowtime = showTime().toString();
var fpdmyzm = $("#fpdm").val().trim();
var fphmyzm = $("#fphm").val().trim();
var kjje = $("#kjje").val().trim();
var rad = Math.random();
var area = swjginfo[2];
var param = {
'fpdm': fpdmyzm,
'fphm': fphmyzm,
'r': rad,
'v': VVV,
'nowtime': nowtime,
'area': area,
'publickey': $.ckcode(fpdmyzm, nowtime)
};
$.ajaxSetup({
cache: false
});
yzmFlag = 1;
$.ajax({
type: "post",
url: url,
data: param,
dataType: "jsonp",
jsonp: "callback",
success: function(jsonData) {
//处理返回代码省略
},
timeout: 5000,
error: function(XMLHttpRequest, textStatus, errorThrown) {
if (retrycount == 9) {
jAlert("系统繁忙,请稍后重试!", "提示")
} else {
retrycount = retrycount + 1;
getYzmXx()
}
}
});
yzmWait = 2;
yzmTime($('#yzm_img'))
}

这部分代码很好懂,我们来删减一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function getYzmXx() {
var fpdm = $("#fpdm").val().trim();
var swjginfo = getSwjg(fpdm, 0);
var url = swjginfo[1] + "/yzmQuery";
var nowtime = showTime().toString();
var fpdmyzm = $("#fpdm").val().trim();
var fphmyzm = $("#fphm").val().trim();
var rad = Math.random();
var area = swjginfo[2];
var param = {
'fpdm': fpdmyzm,
'fphm': fphmyzm,
'r': rad,
'v': VVV,
'nowtime': nowtime,
'area': area,
'publickey': $.ckcode(fpdmyzm, nowtime)
};
$.ajax({
type: "post",
url: url,
data: param,
dataType: "jsonp",
jsonp: "callback",
success: function(jsonData) {
//处理成功返回省略
}
});
}
function showTime() {
var myDate = new Date();
var time = myDate.getTime();
return time
}

模拟验证码请求

getYzmXx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def getYzmXx(VVV, fpdm, fphmyzm):
'''
VVV:系统版本号
fpdm:发票代码
fphmyzm:发票号码
'''
swjginfo = getSwjg(fpdm, 0)
url = swjginfo[1] + "/yzmQuery"
nowtime = showTime()
rad = random.random()
area = swjginfo[2]
param = {
'fpdm': fpdm,
'fphm': fphmyzm,
'r': rad,
'v': VVV,
'nowtime': nowtime,
'area': area,
'publickey': ckcode(fpdm, nowtime)
}
s=requests.session()
s.headers['user-agent']="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36"
resp=s.post(url,data=param)
res=json.loads(resp.text)
return res,s

由于查验平台使用cookie,所以我们使用了requests库中的session来自动维持会话。

getSwjg

接下来我们需要实现那个查询信息的函数:getSwjg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def getSwjg(fpdm, ckflag):
citys = [
{
'code': '1100',
'sfmc': '北京',
'Ip': 'https://fpcy.beijing.chinatax.gov.cn:443',
'address': 'https://fpcy.beijing.chinatax.gov.cn:443'
},
{
'code': '1200',
'sfmc': '天津',
'Ip': 'https://fpcy.tjsat.gov.cn:443',
'address': 'https://fpcy.tjsat.gov.cn:443'
},
//省略部分数据
]
swjginfo = []
if len(fpdm) == 12:
dqdm = fpdm[1:5]
else:
dqdm = fpdm[0:4]
if dqdm != "2102" and dqdm != "3302" and dqdm != "3502" and dqdm != "3702" and dqdm != "4403":
dqdm = dqdm[0:2]+"00"
for city in citys:
if dqdm == city["code"]:
swjginfo.append(city["sfmc"])
swjginfo.append(city["Ip"] + "/WebQuery")
swjginfo.append(dqdm)
break
return swjginfo

这个很简单,照搬js就可以了。

分析签名

这是第一堵高墙,我们模拟请求中的签名算法ckcode,继续使用调试工具找到ckcode的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
!function(n) {
var e, r = function(n, r) {
return e = "402880bd5c76166f015c903ee811504e",
n << r | n >>> 32 - r
}, c = function(n, r, c) {
return e = "402880bd5c76166",
n & c | r & ~c
};
n.extend({
ck: function(e, t, p, u, y, o) {
var d, i = c(t, e, p), f = n.encrypt(e), g = n.encrypt(u + y), a = r(e, t);
i = 2147483648 & e,
i += 2147483648 & t,
i += d,
i += d = 1073741824 & I,
a = i = n.encrypt(e) + n.bs.encode(n.encrypt(t)) + p;
var b = n.gen(i, a)
, v = n.encrypt(f) + g
, j = n.gen(b + n.gen(e, a) + v, g);
return n.prijm(e, t, p, u, y, o, j)
},
ckcode: function(e, r) {
var c = n.encrypt(e + r)
, t = n.encrypt(e) + n.bs.encode(n.encrypt(r))
, p = n.gen(t, c)
, u = n.encrypt(c)
, y = n.gen(p + n.gen(e, t) + u, t);
return n.pricd(e, r, y)
}
})
}(jQuery);

我们得到了这样一个文件,接下来我们要实现两个算法ck(后面一定用的到)和ckcode,但是我们遇到了阻碍,ck和ckcode包含了另外5个函数:encrypt、encode、gen、prijm、pricd

破解加密算法

encrypt

通过调试工具找到源码,通过观察我们发现encrypt函数加密过程中所有的字函数都在这份文件中,那么我们直接通过python执行js就可以了,这个函数很好解决。
首先确保安装了PyExecJS

1
pip3 install PyExecJS

编写脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def encrypt(n):
js=r'''
var r = function (n, r) {
return n << r | n >>> 32 - r
},
t = function (n, r) {
var t, e, u, o, I;
return u = 2147483648 & n, o = 2147483648 & r, t = 1073741824 & n, e = 1073741824 & r, i = (1073741823 & n) + (1073741823 & r), t & e ? 2147483648 ^ i ^ u ^ o : t | e ? 1073741824 & i ? 3221225472 ^ i ^ u ^ o : 1073741824 ^ i ^ u ^ o : i ^ u ^ o
},
e = function (n, r, t) {
return n & r | ~n & t
},
u = function (n, r, t) {
return n & t | r & ~t
},
o = function (n, r, t) {
return n ^ r ^ t
},

//部分js代码省略

return (l(s) + l(d) + l(v) + l(S)).toLowerCase()
};
'''
ctx = execjs.compile(js)
return ctx.call("encrypt",n)

在Chorme的控制台调用一下原版的函数,核对一下算法有没有问题:

1
2
$.encrypt("qwer")
"962012d09b8170d912f0669f6d7d9d07"

然后执行python脚本:

1
2
MacBook-Pro-2:py bbfat$ python3 encrypt.Py
962012d09b8170d912f0669f6d7d9d07

OK!

encode

和encrypt基本一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def encode(r):
js=r'''
var n = "=",
h = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var t=function(r, t) {
var e = h.indexOf(r.charAt(t));
if (-1 === e) throw "Cannot decode encrypt";
return e
},
e = function(r, t) {
var e = r.charCodeAt(t);
if (e > 255) throw "INVALID_CHARACTER_ERR: DOM Exception 5";
return e
},
encode = function (r) {
var t, a, c = [],
o = (r = String(r)).length - r.length % 3;
if (0 === r.length) return r;
for (t = 0; t < o; t += 3) a = e(r, t) << 16 | e(r, t + 1) << 8 | e(r, t + 2), c.push(h.charAt(a >> 18)), c.push(h.charAt(a >> 12 & 63)), c.push(h.charAt(a >> 6 & 63)), c.push(h.charAt(63 & a));
switch (r.length - o) {
case 1:
a = e(r, t) << 16, c.push(h.charAt(a >> 18) + h.charAt(a >> 12 & 63) + n + n);
break;
case 2:
a = e(r, t) << 16 | e(r, t + 1) << 8, c.push(h.charAt(a >> 18) + h.charAt(a >> 12 & 63) + h.charAt(a >> 6 & 63) + n)
}
return c.join("")
};
'''
ctx = execjs.compile(js)
return ctx.call("encode",r)

gen

拿到源码之后分析一下,gen这个算法稍微有点麻烦。
首先是n函数

1
2
3
4
5
6
7
8
9
var n = function() {
var e = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth
, n = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
if (e * n <= 12e4)
return !0;
var c = window.screenX
, r = window.screenY;
return c + e <= 0 || r + n <= 0 || c >= window.screen.width || r >= window.screen.height
}

它写了个奇怪的逻辑,不过我发现它不接受参数,那就意味着它很可能只是一个定值,在console中运行一下。
这里有一个小技巧我们直接在函数前加一个!它就会被当作表达式执行。
image.png
可以看到它的值的确是定值false。


接下来是c函数,它麻烦的地方在于它调用了几次encrypt函数,不过通过观察我们看到它的几个运算结果也是固定的,所以我们直接手动将数据算出来即可。这里一定要仔细,笔者最后一个bug就是这里的一个值算错了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
c = function (c) {
wzwschallenge = "bba95d42cb1f78da172b87b909af0b3c", wzwschallengex = "cmhyZXdyY2hi", encoderchars = "8c7ff3d6144ced934021f6c1268bbe65315206d77ee9621c7e3aaa1df14c96fe";
var t, h, o, d, i, a;
for (o = c.length, h = 0, t = ""; h < o;) {
if (d = 255 & c.charCodeAt(h++), h == o) {
t += encoderchars.charAt(d >> 2), t += encoderchars.charAt((3 & d) << 4), t += "==";
break
}
if (i = c.charCodeAt(h++), h == o) {
t += encoderchars.charAt(d >> 2), t += encoderchars.charAt((3 & d) << 4 | (240 & i) >> 4), t += encoderchars.charAt((15 & i) << 2), t += "=";
break
}
a = c.charCodeAt(h++), t += encoderchars.charAt(d >> 2), 3 & d, (3 & d) << 4, 240 & i, (240 & i) >> 4, (3 & d) << 4 | (240 & i) >> 4, t += encoderchars.charAt((3 & d) << 4 | (240 & i) >> 4), t += encoderchars.charAt((15 & i) << 2 | (192 & a) >> 6), t += encoderchars.charAt(63 & a)
}
var w = 0;
return n() || (w = r(wzwschallenge, wzwschallengex)), t + w
},

r函数很简单,直接复制。然后是gen函数。
这里也有一个坑,gen函数最后有一个3元运算,结果可能有两种情况,笔者没有摸透这个判断条件,索性直接写了两个版本的gen:gen和gen_s,后面准备通过调试选择该用哪一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
def gen(n, c):
d = encrypt(n)
i = encrypt(n)+c
h = "402880bd5c76166f015c903ee811504e"
return encrypt(d+i+h).upper()


def gen_s(n, c):
o = str(len(n))
d = encrypt(n)
i = encrypt(n)+c
h = "402880bd5c76166f015c903ee811504e"
return encrypt(d + i + h + o).upper()

最后是moveTo函数:

1
2
def moveTo(n):
return encrypt(ctx.call("c", n))

至此gen函数解决了。

prijm和pricd

这两个函数非常相似,都是对地区代码进行运算,我们直接用python翻译js就行了,这俩函数会用到之前的函数。
有一点需要注意,js中出现了很多这样的代码:

1
2
m = e.encrypt(r + e.moveTo(m)).toUpperCase(),
"0" == swjgmcft && (m = e.encrypt(r + e.moveTo(m) + r).toUpperCase());

我们需要理解m是怎么计算出来的,第一行很好理解,第二行的“”0” == swjgmcft”通过在console里运行我们发现,这个是真值,所以后面的语句会执行,m会再运算赋值一次。
翻译成python是这样子:

1
2
m = encrypt(r + moveTo(m)).upper()
m = encrypt(r + moveTo(m) + r).upper()

那么现在我们已经把5个加密函数都搞定了,可以开始请求了!

请求验证码

我们将getYzmXx的python版本补全然后运行,得到了验证码的json数据包:

1
2
3
4
5
6
7
{
"key1": //省略验证码图片的base64,
"key2": "2019-08-30 09:42:55",
"key3": "d19b533f223d9b04fdeee8511ed485f6",
"key4": "01",
"key5": "2"
}

接下来我们稍微加工一下getYzmXx函数,分析原版的getYzmXx函数,我们可以知道key4指明了验证码的颜色,所以我们添加几行代码:

1
2
3
4
5
6
7
8
9
res["key1"] = "data:image/png;base64,"+res["key1"]
if res['key4'] == "00":
res['key4'] = ""
elif res['key4'] == "01":
res['key4'] = "红色"
elif res['key4'] == "02":
res['key4'] = "黄色"
elif res['key4'] == "03":
res['key4'] = "蓝色"

这样函数返回的数据包可以直接显示颜色。

模拟查验请求

下面我们就来进行最关键的一步:模拟查验请求。
还是老办法,先看js,我们找到请求参数:

1
2
3
4
5
6
7
8
9
10
11
12
param = {
'fpdm': fpdm,
'fphm': fphm,
'kprq': kprq,
'fpje': kjje,
'fplx': fplx,
'yzm': yzm,
'yzmSj': yzmSj,
'index': jmmy,
'area': area,
'publickey': $.ck(fpdm, fphm, kprq, kjje, yzmSj, yzm)
}

简单分析可以得出:yzmSj是验证码时间(在验证码数据包里有),index是验证码数据包中的key3,最后又是一个签名,好在我们已经搞定了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def check(session,fpdm,fphm,kprq,kjje,yzm,yzm_keys):
'''
session:会话对象
fpdm:发票代码
fphm:发票号码
kprq:开票日期
kjje:开票金额或校验码后六位
yzm:验证码
yzm_keys:验证码数据包
'''
dmchek = getSwjg(fpdm, 1)
area = dmchek[2]
fplx=alxd(fpdm)
if fplx == "01" or fplx == "02" or fplx == "03":
index = kjje.index(".");
if index > 0:
arr = kjje.split(".");
if arr[1] == "00" or arr[1] == "0":
kjje = arr[0]
elif arr[1][1]== "0":
kjje = arr[0] + "." + arr[1][0]
sjip = dmchek[1]
url = sjip + "/vatQuery"
param = {
"callback":"jQuery110207235993437777108_1567158341877",
'key1': fpdm,
'key2': fphm,
'key3': kprq,
'key4': kjje,
'fplx': fplx,
'yzm': yzm,
'yzmSj':yzm_keys["key2"],
'index': yzm_keys["key3"],
'area': area,
'publickey': ck(fpdm, fphm, kprq, kjje,"", yzm)
}
resp=requests.post(url,data=param).text
tmp=resp.split("(")[1]
return json.loads(tmp[:-1])

测试

接下来我们写一个脚本,调用所有函数进行一次测试:

1
2
3
4
5
6
7
from yzm import *
from check import *

yzm_keys,s=getYzmXx('V1.0.07_001','011001900311','42558341')
print(yzm_keys["key1"]+'\n'+yzm_keys['key4'])
yzm=input("输入验证码:")
print(check(s,"011001900311","42558341","20190829","643785",yzm,yzm_keys))

image.png

当然到这里还不够完美,我还没有写解析结果数据的部分,并且这个版本验证码是需要手动输入的,接下来我会尝试通过orc的方式识别验证码。


2020年2月11日更新v2.0

这部分内容是针对V2.0.01_001版本的更新,并且加入了验证码识别模块。

这里就简单说一下发票平台从V1.0.07_001V2.0.01_001有什么具体的变化:

  • 请求验证码接口修改

    原本请求验证码是一个POST请求,在V2.0.01_001中改成了一个由JQuery发起的GET请求,其他参数和加密算法不变。

  • 验证码请求增加了频率限制

    查验平台以前的验证码接口一直不稳定,经常遇到404,此次更新之后稳定了许多,但是增加了一个验证码请求频率的限制,起初我以为是对ip进行限制,后来实验发现是对会话进行限制,也就是说我们每请求一次验证码重新开启一个会话就可以避免被限制。

这是v2.0的项目目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.
├── README.md
├── main.py //测试脚本
├── predictCaptcha //验证码识别模块
│ ├── __pycache__
│ ├── model
│ ├── picProcess.py
│ └── sdk.py
├── requirements.txt
├── scripts //查验用到的主要逻辑和算法
│ ├── __pycache__
│ ├── check.py
│ ├── ck.py
│ ├── encode.py
│ ├── encrypt.py
│ ├── fplx.py
│ ├── gen_moveTo.py
│ ├── getSwjg.py
│ ├── json
│ ├── pricd_prijm.py
│ └── yzm.py
├── temp //验证码临时存储目录
│ └── README.md
└── venv
├── bin
├── include
├── lib
└── pyvenv.cfg

主要来看一下main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from scripts.yzm import getYzmXx
from predictCaptcha.picProcess import get_aim_letters
from scripts.check import check

import time


def checkInvoice(fpdm, fphm, kprq, kjje):
# res = None
# while res is None or res.get("key1") != "001":
yzm_keys, s = getYzmXx('V2.0.01_001', fpdm, fphm)
yzm = get_aim_letters(yzm_keys)
res = check(s, fpdm, fphm, kprq, kjje, yzm, yzm_keys)
return res


if __name__ == "__main__":
count = 0
success = 0
t0 = time.time()
while True:
count += 1
res = checkInvoice('011001900411', '61636940', "20190929", "712285")
if res["key1"] == "001" or res["key1"] == "002":
success += 1
print("序号:%d\t平均用时:%.2f\t识别率:%.2f" % (count, (time.time() - t0) / count, (float(success) / count) * 100))

这里简单解释一下check函数返回的字典中key1的含义:

  • 001查验成功
  • 002此发票本日已超过查验次数(一张发票一天最多可以查5次)
  • 008验证码错误

通过以上信息,就可以完成发票查验的整个过程。由于模型不是使用发票平台生成的验证码训练而来的,而是另一个相似的数据集,所以准确率不是很高。

通过以上脚本,我测试了171组数据:

1
序号:171  平均用时:6.59  识别率:22.22

每一次查验的时间包括了两次网络请求和识别,网络请求平均消耗3秒左右,识别平均消耗3.2秒,粗略估算一下,这样能得出一张发票的正确结果的时间大概是30秒左右,这个速度还有待提高,我简单分析了一下,可提高的方向有两个:

  • 减少识别时间
  • 增加识别率

以后我会将这个项目继续完善下去,再次感谢Siege LionY先生两位伙伴,没有他们这个就没有现在的验证码识别功能。


2020年4月2日更新

感谢shimachao在GitHub上发的issue,我得知平台又进行了一次更新,增加了反调试而且我的v2.1版本代码失效了。

这次更新税务平台其实就两个改动:

  • 增加了反调试
  • 更新了版本代码

在前端实现反调试非常简单,只要加一段这样的代码:

1
setTimeout(function(){while (true) {eval("debugger")

这样如果你打开调试器,会一直触发中断,烦都烦死。

image-20200402162741225

我使用Safari关闭了断点功能。

image-20200402162808849

然后抓包发现版本号发生了变化,变成了V2.0.02_002

由于版本号是签名算法的参数,所以版本号变动之后就拿不到验证码了,更新一下功能恢复正常。


2020年4月5日更新

网站上一次更新增加了反调试,我已经有了对策:【爬虫技术】破解js反调试


2020年4月12日更新

有网友反映部分发票验证码接口返回的数据是加密的,无法直接拿到5个key。我们先来看一下正常的验证码接口返回的数据:

1
2
3
4
5
6
7
{
"key1": "iVBORw0KGgoAAAANSUhEUgAAA<省略后面的图片Base64>", //图片
"key2": "2020-04-12 16: 46: 48",
"key3": "47a840595ef90caa6926f8d86c502c68", //校验码
"key4": "03", //颜色
"key5": "2"
}

而更新之后部分发票的验证码接口返回的数据:

1
2
{'data': 'vyJrZXkxRjoiaVZCG1J3MEtHA29BQUFBGlNVaEVVA0FBQUZvJUFBQWpDJUlBQUFDBjU0cGNBJUFMVDBsIVFWUjQyx1ZhQ1ZpGWF4ditLIWtsRmRFzXFSQlJvF2kvNkhjzVd5Z2lXBU5vb1J<一大堆乱码>'}

一开始我以为这只是Base64编码之后的字串,decode之后发现事情没有这么简单,后来我逆向了(有空打算写一篇博客记录逆向历程)官方eab23.jsBase64.js发现这是一种加盐的编码,最后成功解密。

我本来想逆向js之后找出决定发票加密的是什么条件,结果发现官方的js只是判断返回的json中有没有data字段,如果有的话就解密。

项目源码
验证码识别模块

⚠️注意:本项目未经本人允许不得用于商业目的。如有需要请联系:bbfat.996@icloud.com