原来公司使用的是某叮打卡,就是普通的定位打卡,之前已经从系统层做好了位置修改,配合自己写的APP做了注入任意位置,就在周五2022年12月30日突然发公告切换了打卡软件某力e,既然换了那就试试某叮的那一套对它有没有效果,结果很显然无效,如果有效就没有这一篇文章了。
一些猜想
因为之前分析过某叮的定位逻辑,这里大概描述一下。
它的定位逻辑是两个方向的,第一它通过SDK接口,申请了系统的GPS定位,调用了LocationManager这个方法
requestLocationUpdates(@NonNull String provider, long minTime, float minDistance,
@NonNull LocationListener listener, home.php?mod=space&uid=1043391 Looper looper)
另一个方向就是基于基站和周边WiFi列表的定位+ip,这里叫LBS,这里就取2种逻辑中最快回调回来的,比如GPS快,就拿GPS的经纬度,再使用高德SDK的api获取坐标对应的地点名字,如果GPS不可用,或者无回调,或者LBS定位回调比GPS快,拿到坐标后,也通过SDK获取坐标对应的地点名字。以上就是它的总体逻辑。不要问怎么知道的[doge].
通过分析,某力和某叮都是用了高德SDK,那刚开始我直接修改系统GPS以为能成功,没想到没效果,而且requestLocationUpdates是没有被调用的,那只能说它用的是LBS方式。
打开高德SDKdemo,发现了一个叫H5辅助定位,进入看看,且试用了一下。
Xnip2023-01-01_18-53-00.png (481.14 KB, 下载次数: 0)
下载附件
2023-1-2 12:20 上传
原来还能这样啊。
再结合这个大佬的分析https://www.52pojie.cn/thread-1709943-1-1.html,定位重要代码是在SDKWebViewFragment中,大概能确定某力只用LBS方式。改系统的GPS数据是没办法的。
既然不吃系统数据,那就开始分析最新版本的情况吧,上面吾爱大佬的破解方式已经不适用最新版本了,他的文章也没有支出是那个版本,没有给出样本。所以能参考的就只有SDKWebViewFragment中获取定位的方法,也就是web和原生native沟通的方法:
public BaseSDKResult a(LocationGetRequest locationGetRequest, com.delicloud.app.jsbridge.main.c cVar)
开始分析定位重要位置
某力版本android:versionName="2.5.9"
样本地址:https://wwsk.lanzouy.com/iikQR0judryj,MD5:76f852dc4108e05cdb6f105df26c32a5
反编译工具:jadx
我们把样本拖进去jadx中,搜索SDKWebViewFragment,找到之后打开它。
根据上面吾爱大佬的文章,参考是否还存在这个方法,根据开发经验,一般都不会乱改web和原生通信方法。
继续搜索BaseSDKResult a(LocationGetRequest locationGetRequest找到获取定位的方法。
Xnip2023-01-01_19-11-00.png (454.67 KB, 下载次数: 0)
下载附件
2023-1-2 12:20 上传
我们是幸运的,方法还在,而且逻辑也不复杂。我们来大体分析下整个方法的代码吧。
我希望你有Android应用层开发经验,就算混淆的代码,也能看懂大概。不然瞎猜代码是很痛苦的,且会走弯路。
a方法是web和原生通信的方法,返回了一个BaseSDKResult对象。
home.php?mod=space&uid=1892347 // com.delicloud.app.jsbridge.b
public BaseSDKResult a(LocationGetRequest locationGetRequest, com.delicloud.app.jsbridge.main.c cVar) {
//第一个判断①gd,通过传入context获取了系统服务LocationManager,
//然后isProviderEnabled(GeocodeSearch.GPS)翻译:GPS服务能用吗,
//locationManager.isProviderEnabled("network")网络定位能用吗,
//如果其中一个可以用,那么就进入里面代码块,如果都不能,就进入②toast一个提示语。
//显然,这个APP在进入首页的时候会问你要定位权限,你一定要给,有了定位
//这里的判断就一定会成立。
if (com.delicloud.app.tools.utils.i.gd(this.mContentActivity)) {
//③n方法代码判断
//这里就是再次检查权限,如果没有弹窗提示要权限,
//如果没有权限,就没有下文了,我们会给APP权限的,这里判断就可以默认为true了
if (com.delicloud.app.tools.utils.m.n(this)) {
//④be方法,从本地sp文件中根据某个key取出数据,然后强转为AddressModel对象。
AddressModel addressModel = (AddressModel) dl.a.be(this.mContentActivity, com.delicloud.app.commom.b.bBz);
//请求的时候如果需要缓存,且缓存的addressModel不是空的
//且当前时间和缓存的时间小于10000ms(10秒),就使用缓存。
//经过我的动态调试,addressModel是null,也就是这里的判断不成立。
//⑤,看我给出的动态调试代码。
if (locationGetRequest.isCache() && addressModel != null && System.currentTimeMillis() - addressModel.getCache_time() () { // from class: com.delicloud.app.jsbridge.ui.fragment.SDKWebViewFragment.7
@Override // io.reactivex.ObservableOnSubscribe
public void subscribe(ObservableEmitter observableEmitter) throws Exception {
if (SDKWebViewFragment.this.bLI == null) {
return;
}
SDKWebViewFragment.this.bLI.a(new a.InterfaceC0166a() { // from class: com.delicloud.app.jsbridge.ui.fragment.SDKWebViewFragment.7.1
@Override // com.delicloud.app.tools.utils.a.InterfaceC0166a
public void a(double d2, double d3, String str, String str2, String str3, String str4) {
zArr[0] = false;
LocationGetResult locationGetResult2 = new LocationGetResult();
locationGetResult2.setData(new LocationGetResult.LocationGetData(Double.valueOf(d2), Double.valueOf(d3), str, str2));
Log.i(SocializeConstants.KEY_LOCATION, com.delicloud.app.http.utils.c.aq(locationGetResult2));
SDKWebViewFragment.this.a(com.delicloud.app.jsbridge.b.chq, locationGetResult2);
}
@Override // com.delicloud.app.tools.utils.a.InterfaceC0166a
public void ZP() {
zArr[0] = false;
SDKWebViewFragment.this.a(com.delicloud.app.jsbridge.b.chq, new BaseSDKResult(JsSDKResultCode.GET_LOCATION_RESULT_FAIL));
}
});
}
}).timeout(60L, TimeUnit.SECONDS).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(new Observer() { // from class: com.delicloud.app.jsbridge.ui.fragment.SDKWebViewFragment.6
@Override // io.reactivex.Observer
public void onComplete() {
}
@Override // io.reactivex.Observer
public void onSubscribe(Disposable disposable) {
}
@Override // io.reactivex.Observer
/* renamed from: e */
//⑥这里是定位返回成功后
public void onNext(Long l2) {
//看到这个判断我觉得很奇怪,为什么要这样呢,到底是否成立呢。
//看到申请定位前有那么一句final boolean[] zArr = {true};
//这又是何苦,上面标记了true,那判断就一定成立。
//最后我们调用了a方法。文章往下拉,看a的实现。⑥
if (zArr[0]) {
SDKWebViewFragment.this.a(com.delicloud.app.jsbridge.b.chq, new BaseSDKResult(JsSDKResultCode.GET_LOCATION_RESULT_FAIL));
}
}
@Override // io.reactivex.Observer
//这里是rxJava流程遇到错误回调
public void onError(Throwable th) {
if (zArr[0]) {
SDKWebViewFragment.this.a(com.delicloud.app.jsbridge.b.chq, new BaseSDKResult(JsSDKResultCode.GET_LOCATION_RESULT_FAIL));
}
}
});
}
return null;
}
//如果GPS和定位不能用,就toast
es.dmoral.toasty.b.bQ(this.mContentActivity, "当前系统定位开关未开启,无法定位").show();
return new BaseSDKResult(JsSDKResultCode.IBEACON_NEED_LOCATION_PERMISSION);
}
①:gd这个判断,做了什么呢,进去看看。
public class i {
//获取定位服务,判断是否可用,如果我们授权了肯定可以用
public static boolean gd(Context context) {
LocationManager locationManager = (LocationManager) context.getSystemService(SocializeConstants.KEY_LOCATION);
return locationManager.isProviderEnabled(GeocodeSearch.GPS) || locationManager.isProviderEnabled("network");
}
}
③再次检查定位权限
public static boolean n(final Fragment fragment) {
if (c(fragment.getContext(), cxo)) {
return true;
}
if (com.delicloud.app.commom.b.bAc) {
return false;
}
com.delicloud.app.commom.b.bAc = true;
com.delicloud.app.deiui.feedback.dialog.b.bVs.d(fragment.getActivity(), "得力e+申请访问精准定位权限", "用于极速打卡、考勤签到打卡、天气服务等功能。拒绝或取消授权不影响其他服务", "去开启", "取消", true, new b.a() { // from class: com.delicloud.app.tools.utils.m.6
@Override // com.delicloud.app.deiui.feedback.dialog.b.a
public void Za() {
es.dmoral.toasty.b.bQ(Fragment.this.getActivity(), "权限拒绝后,将无法使用该功能").show();
}
@Override // com.delicloud.app.deiui.feedback.dialog.b.a
public void Zb() {
m.a(Fragment.this, m.cxo, 12);
}
}).show(fragment.getChildFragmentManager(), "权限申请");
return false;
}
④根据一个key字符串,获取本地储存的数据,然后转Java bean对象。
public static T be(Context context, String str) {
try {
return (T) bh(context, str);
} catch (Exception e2) {
e2.printStackTrace();
return null;
}
}
private static Object bh(Context context, String str) throws IOException, ClassNotFoundException {
//从sp中取出数据
String string = getString(context, str);
if (TextUtils.isEmpty(string)) {
return null;
}
//这里经过base64解码,也就是我们可以根据str这个key去sp中找到里面的数据
//然后base64解码就可以看到存储内容了,有兴趣可以hook得到str,看看sp的数据哦
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(Base64.decode(string.getBytes(), 0));
ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
Object readObject = objectInputStream.readObject();
byteArrayInputStream.close();
objectInputStream.close();
return readObject;
}
⑤AddressModel addressModel = (AddressModel) dl.a.be(this.mContentActivity, com.delicloud.app.commom.b.bBz);中be方法frida hook代码,这里也可以用Xposed的hook,看你自己会那个。
let a = Java.use("dl.a");
a["be"].implementation = function (context, str) {
console.log('be is called' + ', ' + 'context: ' + context + ', ' + 'str: ' + str);
let ret = this.be(context, str);
console.log('be ret value is ' + ret);
return ret;
};
经过我的调试,返回值是null,也就是没缓存,取缓存条件不成立,继续往下走。
⑥onNext流程调用了a
public void a(String str, BaseSDKResult baseSDKResult) {
//这里可以忽略,这东西不是null,原因,fragment创建的时候,获取了webview组件
//ckD就是BridgeWebView实例,不可能,
//这里判断是防止fragment退出之后清理了BridgeWebView,rxJava还继续回调引发
//空指针异常,要判断下,正常情况下可以理解往下走
//以下就是ckD赋值代码
//public View onCreateView(LayoutInflater layoutInflater, \
//@Nullable ViewGroup viewGroup, @Nullable Bundle bundle) {
//View onCreateView = super.onCreateView(layoutInflater, viewGroup, bundle);
//this.ckD = (BridgeWebView) onCreateView.findViewById(R.id.fragment_web_view);
//return onCreateView;
//}
if (this.ckD == null) {
return;
}
//这个开发真是个大聪明,居然直接把重要数据log打印出来,我真的谢谢你。
//在开发的时候调试版本可以打log,辅助开发,到了生产环境一定要把log关掉
//通常我们都是使用一个log工具做代{过}{滤}理,统一打印log处理
//在发布最终版本的时候关闭log工具开关,同时通过混淆把Log.i等代码移除
//大家可以过滤看看,这里打印了什么,破解的方案就在这里了
Log.i("SDKWebViewFragment", "call back registerMethod=" + str + ",result=" + com.delicloud.app.http.utils.c.aq(baseSDKResult));
//ckF是private HashMap ckF = new HashMap();一个Map
//这里的判断是:Map中是否包含了str这个(变量里面的值)键值对,如果有,就取出str对应的
//value,这个value就是c对象,c对象是:
//public interface c {
// void nm(String str);
//}
//用人话说就是:取出str对应的c接口的具体实现对象然后调用nm方法
if (this.ckF.containsKey(str)) {
//这里调用nm之前,aq方法处理了baseSDKResult(里面就是定位结果)⑦
this.ckF.get(str).nm(com.delicloud.app.http.utils.c.aq(baseSDKResult));
}
}
⑦进去aq看看,里面做了什么呢。
public static String aq(Object obj2) {
gson = afV();
Gson gson2 = gson;
//gson是一个处理json的工具,这里使用了gson的api,把java bean转成json字符串
if (gson2 != null) {
//把传递进来的java bean转json字符串
return gson2.toJson(obj2);
}
return null;
}
做开发的人基本上秒懂了,就是java bean转json吗,那么我们可以看看aq方法到底返回了什么。
上frida hook就行。
let c = Java.use("com.delicloud.app.http.utils.c");
c["aq"].implementation = function (obj2) {
console.log('aq is called' + ', ' + 'obj2: ' + obj2);
let ret = this.aq(obj2);
console.log('aq ret value is ' + ret);
return ret;
};
{
"code":0,
"data":{
"address":"广东省广州市",
"latitude":23.xxxx,
"longitude":113.xxxx,
"name":"xx大厦"
},
"method":"",
"msg":"成功"
}
打印的数据居然是这样的。
聪明的你应该知道怎么做了吧?
frida hook改位置
经过动态调试之后,我发现了aq的数据居然包含了位置信息,我当时就想到了破解方案。
替换大法!
我去替换里面的数据,看看效果如何。
说干就干。
//com.delicloud.app.http.utils.c
Java.perform(function() {
var com_delicloud_app_http_utils_c_clz = Java.use('com.delicloud.app.http.utils.c');
var com_delicloud_app_http_utils_c_clz_method_aq_4105 = com_delicloud_app_http_utils_c_clz.aq.overload('java.lang.Object');
com_delicloud_app_http_utils_c_clz_method_aq_4105.implementation = function(v0) {
var ret = com_delicloud_app_http_utils_c_clz_method_aq_4105.call(com_delicloud_app_http_utils_c_clz, v0);
console.log("json:", ret);
//data":{"address
console.log("判断地址::" + ret.indexOf("data\":\{\"address") != -1 );
//代码中必须把json转义,不转义语法错误。
var addr = "{\n" +
" \"code\":0,\n" +
" \"data\":{\n" +
" \"address\":\"目标地址名称,自己替换经纬度,后面给工具获取\",\n" +
" \"latitude\":23.2222,\n" +
" \"longitude\":113.22222,\n" +
" \"name\":\"某大厦\"\n" +
" },\n" +
" \"method\":\"\",\n" +
" \"msg\":\"成功\"\n" +
"}";
//如果返回的数据包含了这样的字符串,就直接替换我们目的地,其他
//java bean转json的就正常返回就行。
if (ret.indexOf("data\":\{\"address") != -1) {
return addr;
}
return ret;
};
});
执行脚本,下拉刷新看看效果。
Xnip2023-01-01_20-39-53.png (408.5 KB, 下载次数: 0)
下载附件
2023-1-2 12:19 上传
我反手就点了,打卡成功。
看到这里,像做持久化hook的应该秒懂了。
我就不提供相关的成品了。
如何获取正确坐标
刚开始的时候我是去https://lbs.amap.com/tools/picker取坐标。
当我从这个网站取回来坐标后,并没有效果,显示的位置是目标坐标的4点钟方向再过一段距离。
后来问了其他有坐标处理经验的朋友阿肥,他告诉我地图坐标可能需要做标准换算。
public class JXMapUtil {
private static final String PN_GAODE_MAP = "com.autonavi.minimap"; // 高德地图包名
private static final String PN_BAIDU_MAP = "com.baidu.BaiduMap"; // 百度地图包名
private static final String PN_TENCENT_MAP = "com.tencent.map"; // 腾讯地图包名
private static final double a = 6378245.0;
private static final double pi = 3.1415926535897932384626;
private static final double ee = 0.00669342162296594323;
//____________________________坐标转化_____________________________________________________
/**
* 转化为火星坐标系
* home.php?mod=space&uid=952169 latitude
* @param longitude
* @return
*/
public static double[] toGCJ02Point(double latitude, double longitude) {
double[] dev = calDev(latitude, longitude);
double retLat = latitude + dev[0];
double retLon = longitude + dev[1];
return new double[] { retLat, retLon };
}
/**
* 火星坐标系 转化为 WGS84(国际坐标系
* @param latitude
* @param longitude
* @return
*/
public static double[] toWGS84Point(double latitude, double longitude) {
double[] dev = calDev(latitude, longitude);
double retLat = latitude - dev[0];
double retLon = longitude - dev[1];
dev = calDev(retLat, retLon);
retLat = latitude - dev[0];
retLon = longitude - dev[1];
return new double[] { retLat, retLon };
}
private static double[] calDev(double wgLat, double wgLon) {
if (isOutOfChina(wgLat, wgLon)) {
return new double[] { 0, 0 };
}
double dLat = calLat(wgLon - 105.0, wgLat - 35.0);
double dLon = calLon(wgLon - 105.0, wgLat - 35.0);
double radLat = wgLat / 180.0 * pi;
double magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
double sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
return new double[] { dLat, dLon };
}
private static boolean isOutOfChina(double lat, double lon) {
if (lon 137.8347)
return true;
if (lat 55.8271)
return true;
return false;
}
private static double calLat(double x, double y) {
double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * pi) + 20.0 * Math.sin(2.0 * x * pi)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * pi) + 40.0 * Math.sin(y / 3.0 * pi)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * pi) + 320 * Math.sin(y * pi / 30.0)) * 2.0 / 3.0;
return ret;
}
private static double calLon(double x, double y) {
double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * pi) + 20.0 * Math.sin(2.0 * x * pi)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * pi) + 40.0 * Math.sin(x / 3.0 * pi)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * pi) + 300.0 * Math.sin(x / 30.0 * pi)) * 2.0 / 3.0;
return ret;
}
}
也就是你从网站拾取的坐标需要转wgs84坐标
double[] wgs84Point = JXMapUtil.toWGS84Point(latitude, longitude);
这样的坐标喂给高德腾讯百度相关定位就OK了。
我这里提供一个apk方便坐标拾取。
如果使用,打卡拾取,点击你需要的位置,然后右下角点击√,返回的坐标就是wgs84标准的坐标了。
工具下载地址:
https://wwsk.lanzouy.com/iAFqH0jut6yj
MD5:d3c87fdd3d1982d29d485dc7baaab176
device-2023-01-01-211334.gif (1.59 MB, 下载次数: 0)
下载附件
2023-1-2 12:19 上传
这样的坐标就是冇问题的啦!
总结
这个案例允许动态调试,没有root检查等等阻拦,是一个很好的实战例子。
在做这个逆向的事情的前提,我个人认为,你应该具备以下知识。
0:先学会开发!达到入门就OK。
1:Java基础扎实,有Android应用层开发的经验,能看懂SDK代码。
2:熟悉开发中使用到的第三方库,比如这里用到的gson,rxJava,高德定位SDK。
3:熟练使用jadx,apktool,AndroidKiller,Frida,Xposed等工具。
题外话,准备好AOSP系统代码,能修改系统且刷入手机,方便定位某些系统api,甚至定制接口。
夸张一点说十行代码搞定某定打卡(已实现了https://www.bilibili.com/video/BV1aK411Z7oQ)(这只是其中一个案例)
这一篇是通过hook不修改apk做的打卡,下一篇是改apk,直接插入需要的经纬度直接打卡。
个人博客地址:http://www.debuglive.cn/article/1059234217549889536
我现在工作是搞Android TV launcher开发的,偶尔也会做点盒子,手机的业务,算是有一点点开发经验。
在熟悉开发的前提下,去逆向会顺利很多 。
--来自业余逆向菜鸡的总结。