SkyWT

7/10/2022

「无忧乐行」切后台检测、随机验证码屏蔽方案

This blog post is only available in Simplified Chinse.

学车学到科目三,要我们在无忧乐行平台上看满 9 小时课程之后才能预约考试。这个平台非常恶心,不仅 UI 丑得没法看,而且在看视频的时候每隔一定时间会弹出验证码窗口,必须在指定时间之内完成验证;把网站标签页或窗口切到后台就会停止记录学时,必须保证前台观看。

魔高一尺,道高一丈,既然都是前端的小把戏,我们自然可以找到前端的解决方法。

TL;DR

最终解决方案非常简单,就三行代码……

window.inspect = function(){};
$(document).off('hide');
$(document).off('visibilitychange');

估计这是最简单的方案了。看到这里的你是不是有点智商被侮辱的感觉 😅……

已经上传 Greasy Fork 啦:无忧乐行刷学时。虽然这么简单可能没啥必要上传……

以下记录一下对这个网页的探索过程。

现存其他方案

最开始在 Greasy Fork 上找了一下,比较有用的是这个脚本:【刷学时】杭州科目驾驶培训网络课程。它的功能是「在任意切换窗口、浏览其他页面时会自动计时;如果有验证弹窗,发送桌面通知」。

然而在我使用的时候,时不时的弹窗验证通知还是让人非常不爽,而且这个脚本屏蔽「切换窗口停止计时」的手段好像是高频率地检测,这样会导致记录的学时少于实际的时间。总之不太理想。

于是晚上就开始研究了一下这个网站……

网站技术初探

这个网站后端是 JSP,估计是很 low 的外包公司,主要业务逻辑的 JS 代码都内联在 HTML 里。还有诸如 FuckInternetExplorer 这样的暴躁函数名(这搁 NOI 不得被禁赛几年)(虽然 IE 确实 👎),甚至锁进混用 Tab 和空格!

前端用了 jQuery,代码可以说非常杂乱(好在留了注释)。我以为这垃圾网站估计是十年前的,没想到代码里记录的最近更新日期是 2021-6-1……

探索「模拟请求」的方案

这种方法的探索失败了(或者说由于过于麻烦而放弃了)。可以跳过不看。

由于大量的业务逻辑放在前端,一开始我尝试研究了一下直接用前端发请求计学时的可能性。因为之前用过超星学习通小助手-视频专版这个脚本,这种直接发请求挂机的方式给我留下了深刻印象。

//签退 num为1时 强制  //随机验证
var todayEnd;
function signOut(type){
	//判断是否在自动上传,如果在,则延后0.5S,防止同时进行后台自动屏蔽学时
	if(!automaticUpload) {
		//失败则跳转到课件列表页面
		$(".tt_p").focus();
		isInspect =true;
		todayEnd=type;
		suspend();
		if(courseware.ruleDto.automatic == 1) {
			validate(1,3);
		}else {
			interfaceForStu(3);   //签退记录
		}
	}else {
		setTimeout("signOut("+type+")", 500);
	}
}

//视频验证接口 num: 1-签到 2-中间验证 3-签退
function interfaceForStu(num){
	//获取验证方式
	    styleType = num;
	    // 20200728:视频验证是通过配置来选择走的海景还是云从,故这边代码无需改造
		if(courseware.ruleDto.autoVerifyStyle==1){ //海景
			//...
		}else if(courseware.ruleDto.autoVerifyStyle==6){//2017.09.26 云从人脸识别
			//...
		}else if(courseware.ruleDto.autoVerifyStyle==3){ //回答问题
			window.scrollTo(0,0);//滚动到顶部
			page=$.layer({
				//...
				iframe : {
	                src : 'https://5u5u5u5u.com:443/learning/popup/inspect_ques2.jsp?type='+num+'&frequency='+courseware.ruleDto.verifyTimes+'&time='+courseware.ruleDto.verifyValidTime+'&verifyNotPassStyle='+courseware.ruleDto.verifyNotPassStyle+'&timeoutStyle='+courseware.ruleDto.timeoutStyle+'&resType='+courseware.resType
	            }
			});
		}else if(courseware.ruleDto.autoVerifyStyle==5) { //不用了
			//...
		}else if(courseware.ruleDto.autoVerifyStyle == 4) {   //拍照
			//...
		}
}

interfaceForStu 这个函数就是弹出点击验证码的。可以看到点击签退时,弹出了一个浮层,尝试构造一个 src 去看看,比如 https://5u5u5u5u.com:443/learning/popup/inspect_ques2.jsp?type=2&frequency=3&time=3&verifyNotPassStyle=1&timeoutStyle=1&resType=1。可以看到这个 iframe 内部的验证码页面:

“无忧乐行”的验证码

形式是向后端请求一个图片,要求用户在指定时间内按照顺序用鼠标点击四个汉字的位置。

F12 审阅这个页面的代码。还是一样的风格,JS 内联在 HTML 里……(看第五行和第八行,一个 type 字段写了两遍,单双引号、空格风格还不统一…… 🤮)

//后台验证
function InpectvalidateCode(result){
	$.ajax({
		url:"/learning_json/inpectvalidateCode.action", //url需重定义
		type : "post",
		data : "result="+result,
		async:false,
		type:'post',
		success:function(json){
	    	if(json.msg !="success"){
       			nextInpect();
			}else{
				countFlag=false;
				parent.validate(1,validateType,null, json.code);
				try {
					window.parent.closeCur();
				}catch(e) {
				}
			}
		}
	});
}

//获取点击位置
function getSelPic() {
	//...
}

$(function(){
	//...
	$(".queding_btn").click(function(){
		countFlag = false;
		var result = getSelPic();
		InpectvalidateCode(result);
	});
	//...
});

点击「确定」按钮后,向后端发送了用户点击的坐标,然后拿到了后端返回的一个 code,传给 parent(也就是 iframe 外层的 document)的 validate 函数,这个函数应该就是用来增加有效学时的。

回到视频播放页面:

//验证回调方法  validateType:1-签到  2-随机 3-中间验证
function validate(result,validateType,image, code){
	//...
	if(validateType==1){
		//...
	}else if(validateType==3){
		layer.closeAll();
		var valid=getValidTime();
		var newVideoTime=getVideoTime();
		//...
		$.ajax({
			url:"/learning_json/signOut.action?ttm=" + (new Date()).getTime(),
			async:false,
			data:"id="+courseware.id+"&result="+result+"&videoTime="+newVideoTime+"&valid="+valid+"&type="+todayEnd+"&lecturePage="+pageNo+"&verifyType="+courseware.ruleDto.autoVerifyStyle+"&stageId="+stageId+"&photoUrl="+image+"&stageEnd="+stageEnd+"&tag="+tag+"&code="+code+"&videoid="+videoid,
			type:"post",
			success:function(json){
				if(result==-1){
					faultMessage();
					return ;
				}
				if(json=="success"){
					endSucAlert();
				}else if(json ){
					//入库失败
					//...
				}else {
					toLessonList();
				}

			}
		});
	}else if(validateType==2){ //随机验证
		var _flag = true;
		var valid=getValidTime();
		var newVideoTime=getVideoTime();
		//同步方法 async:false,
		$.ajax({
			url:"/learning_json/inspect.action?ttm=" + (new Date()).getTime(),
			async:false,
			data:"id="+courseware.id+"&result="+result+"&videoTime="+newVideoTime+"&valid="+valid+"&lecturePage="+pageNo+"&verifyType="+courseware.ruleDto.autoVerifyStyle+"&stageId="+stageId+"&photoUrl="+image+"&stageEnd="+stageEnd+"&tag="+tag+"&code="+code+"&videoid="+videoid,
			type:"post",
			success:function(json){
				if(sucRedirect) {
					endSucAlert();
					return;
				}
				if(result==-1){
					faultMessage();
					return ;
				}
				if(json && json !="success"){
					//...
				}
			}
		});
		if(result==1 && !sucRedirect && _flag){//验证结果返回成功
			curState = cast;
			validTime=validTime+valid;//随机验证已经有效的学习时间
			setRandomTime();
			goon();
		}

	}
	//...
}

尝试修改 cast 变量,会发现右上角的计时器时间也会更改;假设改为 2 小时,点击签退,验证之后会发现弹窗显示「有效学时 2 小时」,然而进入课程列表刷新会发现实际更新的时间并不是 2 小时,而是实际进入页面的时间。

尝试了多种方式,用 BS 修改请求啥的,结果记录的时间永远是我实际打开视频页面的时间……看来后端有某种不太好搞的验证方式。暂时懒得研究了。

屏蔽「随机验证码」

接下来还是关注一下比较简单的前端 tricks 了。

首先是这个不定时弹出的「随机验证码」,可以找到这段代码:

//随机验证
function inspect(){
	if(!automaticUpload) {
		$(".tt_p").focus();
		inspectUpload = true;
		isInspect =true;
		suspend();
		interfaceForStu(2);   //视频验证
	}else {
		setTimeout("inspect()", 500);
	}
}


//显示计时时间
function startCount(){
	//只有讲义
	if(courseware.type==2){
		//...
	}
	//非讲义
	//alert(flag);
	if(flag){
		if(courseware.type!=2){
		//按消耗时间计时
		if(courseware.recordType==3){
			operation(cast);
			setStuTime(cast);
			if(courseware.ruleDto.autoVerify==1)
				if(randomTime==cast&&flag&&!endFlag){
					inspect();
				}else if(randomTime&&randomTime<cast){
					setRandomTime();
				}
			if(courseware.ruleDto.autoVerify==3){
				if(randomTime==cast&&flag&&!endFlag&&!validone){
					inspect();
					validone=true;
				}else if(randomTime&&randomTime<cast){
					setRandomTime();
				}
			}
			if(courseware.ruleDto.autoVerify==2){
				if(validonetime>73 && !validone){
					inspect();
					validone=true;
				}else {
					validonetime++;
				}
			}
		}else if(courseware.recordType==2){
			//...
		}else if(courseware.recordType==1){
			//...
			}
		}
	}
	//...
	 ds = window.setTimeout(function(){startCount();},1000);
}

看来这是个周期函数。如果 cast(学时)达到 randomTime 就调用 inspect() 进行验证。经过实测 flag 变量如果为 false 则不会再计时……

那么,我们直接把 inspect() 函数弄成空,让它什么都不做不就好了。也就是:

window.inspect = function(){};

(足够简单粗暴……)

屏蔽「切后台检测」

继续看代码,发现这一段:

//笔记  评价  窗口最小化暂停视频播放
$(function(){
	 $('.xubox_close').css('display','none');
	//最小化窗口暂停视频播放
	$(document).on('hide', function() {
		//...
		layer.alert('您已离开当前页面,计时停止.', 8,function(index){
			//...
		});
	});
});

很简单,只要把绑定在 document 上 hide 事件的监听器取消掉就好了。

然而其实还有另一个监听器(虽然没找到代码在哪里):visibilitychange,通过 Chrome 的 F12 可以找到:

Chrome 的 F12 工具可以看到监听器

用 jQuery 的 off() 可以解绑这俩监听器:

$(document).off('hide');
$(document).off('visibilitychange');

大功告成!

遗留问题

好像如果把页面放在后台,这个学习时间的计时会慢一点,过了 15min 它好像只记了 9min。猜测可能和 Chrome 的后台节省内存的机制有关?

Post a New Comment

Please login to leave a comment.