SJ F PRO v2.4.5无人机App分析

第一次尝试对无人机App分析,这个是纯对App进行分析,参考度较低,建议结合无人机漏洞挖掘学习食用。

1. 环境配置

无人机型号:F7 4K PRO

App:SJ F PRO v2.4.5

安卓手机要求:

  • Android系统4.4及以上,运行内存1G及以上
  • 拥有WIFI,GPS定位模块
  • 使用过程中不能开启VPN等网络管理工具,否则会导致手机与无人机之间不能通讯

两器一机开机步骤:

  1. 点按遥控器电源开关按键查看当前电量,再次点按开机。遥控器显示“CONNECTING_”,发出“嘀-,嘀嘀”声。
  2. 长按飞行器电源3s,发出“嘚嘚嘚,嘚-,嘚-”声,前后指示灯快闪 -> 红灯慢闪,进入对频状态。
  3. 遥控器发出“嘀”声,显示“GPS MODE”,后绿前白交替慢闪,对频成功。
  4. 开启手机WIFI功能,选择WIFI“SJ-F-PRO-*-BRG”,可能会显示“已连接,但无法访问互联网”,不用管。打开App,进入“控制”界面。
  5. 遥控器两边手杆推向内上,后绿前白快闪。App显示“进入罗盘校准”。
  6. 水平拿起飞行器转一圈,遥控器发出“嘀”声,后绿常亮。
  7. 拿起飞行器头朝上转一圈,遥控器发出“嘀”声,前红白灯慢闪。App显示“罗盘校准成功”。
  8. 将飞行器放置水平面,后绿前白慢闪,进入搜星状态。App显示“正在等待GPS信号”。后绿前白常亮,GPS搜星成功。
  9. 遥控器两边手杆推向外上,后蓝前白快闪,App显示“陀螺仪校准完成”。
  10. 遥控器两边手杆推向内下,电机自动解锁启动,直接推油门杆起飞。(取消电机解锁:两边手杆推向内下,电机停止工作。或解锁后无操作20s自动停止工作。)
  11. 后绿前白常亮,App显示“可以起飞”。

若GPS信号较差,在步骤8时一直处于搜星状态,此时如果想要起飞,长按遥控器上的“速度切换”功能键(左一)5s,关闭GPS功能。后绿闪烁前白常亮,无人机切换到姿态模式,可以起飞,但GPS所有功能关闭。

两器一机关机步骤:

  1. 长按飞行器电源3s关机。
  2. 点按遥控器电源,再长按3s关机。

2. 查看基本信息

首先来查看关于这个APK的基本信息。将该APK载入jadx查看它的包名与入口Activity。

启动frida-server,打开App,对该App进行objection。

1
objection -g com.vison.macrochip.sj.f.pro explore

查看该App所有的Activities:

1
android hooking list activities

已知入口Activity为com.vison.macrochip.sj.f.pro.activity.WelcomeActivity,我们可以尝试用intent进入其它Activity:

经过intent后大致知道了哪个Activity对应哪个界面,我们重点关注com.vison.macrochip.sj.gps.pro.activity.ControlFActivitycom.vison.macrochip.sj.gps.pro.activity.ControlHyActivity,因为它们是飞行器的两种飞行控制模式,一种是GPS模式,另一种是姿态模式。

可以通过hooking找到对应关系:

1
android hooking watch class com.vison.macrochip.sj.gps.pro.activity.ControlHyActivity  --dump-args --dump-backtrace --dump-return

点击“控制”按钮,界面左上方显示“搜星中…”,表示GPS模式。使用ControlHyActivity成功hook上。

此时可以知道com.vison.macrochip.sj.gps.pro.activity.ControlHyActivity是GPS模式,而com.vison.macrochip.sj.gps.pro.activity.ControlFActivity对应的是姿态模式。

3. WelcomeActivity.onCreate()

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
public void onCreate(Bundle bundle) {
ProtocolEnum protocolEnum;
TextView textView;
String str;
super.onCreate(bundle);
V();
setContentView(R.layout.activity_welcome);//给activity设置一个layout布局
ButterKnife.a(this);
this.productNameTv.setOnClickListener(this);//监听点击事件
this.learnBtn.setOnClickListener(this);
this.supportBtn.setOnClickListener(this);
this.videoEditorBtn.setOnClickListener(this);
c.g.a.h.f.B = n.d().b();//B是静态成员整型变量
c.g.a.h.f.R = Boolean.FALSE;//R是静态成员布尔变量
int i = c.g.a.h.f.B;
if (i == 2) {
this.productNameTv.setText(u[1]);
c.g.a.h.f.Q = ProtocolEnum.SJ;
c.g.a.h.f.R = Boolean.TRUE;
} else {
if (i == 3) {
textView = this.productNameTv;
str = u[2];
} else if (i == 5) {
textView = this.productNameTv;
str = u[3];
} else {
this.productNameTv.setText(u[0]);
protocolEnum = ProtocolEnum.SJ;
c.g.a.h.f.Q = protocolEnum;
}
textView.setText(str);
protocolEnum = ProtocolEnum.HACK_FLY;
c.g.a.h.f.Q = protocolEnum;
}
f0();
this.y.sendEmptyMessageDelayed(2017, 500L);
}

ProtocolEnum数据类型是一个枚举类型。

进入V()方法:

1
2
3
4
5
public void V() {
requestWindowFeature(1);//启用窗体的扩展特性,featrueId为1表示系统默认状态,一般不需要指定
getWindow().setFlags(1024, 1024);//设置窗体全屏
getWindow().addFlags(GLMapStaticValue.AN_MAP_CONTENT_SHOW_OPENLAYER);//增加窗体特性,打开地图内容显示?
}

Android开发中经常会在setContentView(R.layout.XXX);前设置requestWindowFeature(XXX);

ButterKnife.a()方法进去,大概了解它应该是一个主动调用自身Activity的ViewBinding类。

c.g.a.h.f.B存储的是无人机类型常量,初始值为0。由于它是静态的,所以在App界面选定其它类型常量后,重启App还是上一次选定其它无人机类型常量。

如果无人机类型常量为2,表示型号是F11s;常量为3,表示型号是F7;常量为5,表示型号为F7s;否则型号默认为F11。如果型号为F11,枚举协议为SJ;其余型号的枚举协议为HACK_FLY。

接下来进入f0()

f0()从内容布局中设置了一个监听长按事件。 setOnLongClickListener()中return值决定是否在长按后再加一个短按动作。true为不加短按,false为加入短按。

所以这里的意思是,在WelcomeActivity的任何地方(除了按钮),长按后会开启一个LogListActivity,返回值为false,即加入短按事件OnClick()。这个短按事件是什么意思呢?

f0()中的下面这条语句是跟“控制”按钮有关的。

1
c.d.a.b.a.a(this.controlBtn).y(1L, TimeUnit.SECONDS).u(new g());

进入c.d.a.b.a.a()方法,继续进入c.d.a.a.b.b()方法,发现它调用了requireNonNull()方法,如果对象为空则抛出空指针异常,否则返回该对象。

查看WelcomeActivity.g.g类发现一些android:permission属性,可能跟授予权限有关。

第一次点击控制按钮后,会出现需要授予权限的弹窗。

sendEmptyMessageDelayed()的意思是指定多少毫秒后发送空消息,一般做延时操作的时候会使用到。呃这个不知道什么意思,0.5s后发送空消息?

4. WelcomeActivity.onClick()

经过2的查看基本信息,可以将Button和TextView一一对应上。

view.getId()是取得R.id.xxx中的内容。R.id.xxx与Button、TextView的对应关系在WelcomeActivity_ViewBinding.class中。

在switch…case…语句中:

R.id.album_btn这个按钮并没有出现在WelcomeActivity界面,但如果初次触发了就会要求取得相应权限。但是我们在3中点击“控制”时就将读写权限允许了,所以就算触发了R.id.album_btn它也不会弹窗需要权限获取。

R.id.learn_btn是WelcomeActivity界面上的“视频宣传”,如果型号为F7s并且地区语言不是汉语则返回。

如果地区语言为汉语,不同型号返回不同设备型号的宣传视频,视频在youku平台上发布。如果地区语言不是汉语且无人机型号不是F7s,不同型号返回不同设备型号的宣传视频,视频在youtube平台上发布。

如果链接失效则返回。

1
2
3
4
com.vison.baselibrary.utils.g.g("http", str);
if (com.vison.baselibrary.utils.h.l(str)) {
return;
}

在App中启动一个Activity,这个Activity将打开对应的链接。

R.id.product_item乍一眼看以为也不在WelcomeActivity界面中,但从它字面意思就可以知道它是产品项目,结合语句中的PopupWindow可以知道它是悬浮框,也就是选择无人机型号的地方。后面语句无非是给c.g.a.h.f.Bc.g.a.h.f.Q赋值,在3时也说过。如果型号为F11,c.g.a.h.f.B赋值为1。只有型号为F11s时c.g.a.h.f.R的值才为true。

提交无人机型号,其它按钮点击后会显示对应型号的内容。

R.id.product_name_tv是设计悬浮框属性的。

LinearLayout可以控制组件横向排列或者纵向排列,内容不会换行,超出屏幕部分将不会显示出来等等属性。

我猜在WelcomeActivity界面中,点击下拉按钮是触发的R.id.product_name_tv,而选择型号是触发R.id.product_item

R.id.support_btn,点击它会去到SupportItemActivity.class

R.id.video_editor_btn是WelcomeActivity界面上的“视频编辑”,初次点击需要权限。同R.id.album_btn。猜测触发R.id.video_editor_btn后才能在相册中选择照片或视频,即而后才触发R.id.album_btn

5. ControlFActivity和ControlHyActivity

分析完WelcomeActivity.onClick()发现没有id为R.id.control_btn的case,回到WelcomeActivity.onCreate()发现并没有直接设置controlBtn的点击事件,当然不会在onClick()中出现。

我们记得在3时,点击“控制”按钮会出现授予权限的弹窗,说明点击“控制”按钮一定会经过f0(),所以controlBtn的点击事件监听器很有可能在f0()中。上次分析到requireNonNull()方法就结束了,但c.d.a.b.a.a()最后还调用了一个c.d.a.b.b.b()方法,controlBtn作为参数传入。

1
2
3
4
private void f0() {
this.contentLayout.setOnLongClickListener(new f());
c.d.a.b.a.a(this.controlBtn).y(1L, TimeUnit.SECONDS).u(new g());
}

y()方法传入了两个参数,一个是长整型,一个是时间单位。可以查到很有可能是boolean await(long time, TimeUnit unit)方法,使线程进入等待状态,直到被唤醒或中断,或到截止时间。这是有关Java线程的等待与唤醒机制的知识。

u()方法传入了一个g对象的实例,进去发现有onNext、onError等字眼,一查发现是在RxJava里的东西。

RxJava是 ReactiveX 在 Java 上的开源的实现。RxJava可以轻松处理不同运行环境下的后台线程或UI线程任务的框架。RxJava的异步实现,是通过一种扩展的观察者模式来实现的。

WelcomeActivity.g.gWelcomeActivity.g中,发现有个WelcomeActivity.g.a.accept()方法,就是用来跳转到ControlFActivity.classControlHyActivity.class页面的。

如果枚举协议为HACK_FLY,则跳转到搜星模式;否则跳转到姿态模式。c.g.a.h.f.Q的值默认为SJ。

那我们点击“控制”按钮的时候,是不是就经过了这个方法呢?又是怎么经过这个方法的呢?

先在WelcomeActivity中找到内部类WelcomeActivity.g.a

1
android hooking list classes com.vison.macrochip.sj.gps.pro.activity.WelcomeActivity

再hook这个类。

1
android hooking watch class com.vison.macrochip.sj.gps.pro.activity.WelcomeActivity$g$a

发现点击“控制”按钮时确实调用到了这个类的a()accept()方法。

hookaccept()方法查看它的参数、返回值和调用栈。

1
android hooking watch class_method  com.vison.macrochip.sj.gps.pro.activity.WelcomeActivity$g$a.accept --dump-args --dump-backtrace --dump-return

什么意思,不在onCreate()那里来的吗?还是我不会看调用栈?有点迷茫。先不管它怎么进来的吧,主要还是分析ControlFActivity.classControlHyActivity.class飞控相关的内容。

5.1 ControlHyActivity

在无设备连接的情况下,点击“控制”按钮会默认进入ControlHyActivity(GPS模式)。使用objection hook整个ControlHyActivity,点击其界面中的任何地方都可以清楚地看到哪个函数被调用到。

5.1.1 onCreate()

上面这些hook的方法都在onCreate()方法中被调用到了:

1
2
3
4
5
6
7
8
9
10
11
12
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
this.i0.setBackgroundResource(R.drawable.img_control_bg);
this.i0.getBackground().setAlpha(255);
setContentView(R.layout.activity_control_f);
ButterKnife.a(this);
this.X0 = new FunctionPopupWindow(U());
this.z1.start();
i1();
h1();
r1();
}

i1()是设置界面按钮与id之间的对应关系;h1()是监听各类点击事件;r1()是通过读取APK包信息设置下面那一栏的单位。

我在想能不能通过改变i的值从而改变单位呢?对h()进行一个简单的hook:

1
2
3
4
5
6
7
8
9
10
function main(){
Java.perform(function(){
Java.use("c.g.a.m.n").h.implementation = function(){
var result = this.h()
console.log("result", result)
return 5
}
})
}
setImmediate(main)

5.1.2 onClick()

在点击这些按钮时,会触发onClick()事件,由于没有连接无人机,会提示“设备未连接”。

那我们进入onClick()方法看看它做了什么。

R.id.album_btn是一个相册按钮,点击后会开启MediaActivity.class。可以hook一下查看是哪个按钮所为。

1
android hooking watch class com.photoalbum.activity.MediaActivity

R.id.audio_btn音频,显而易见,默认关闭。R.drawable.ic_audio_off是图标。

R.id.back_btn,返回按钮就是左上角的图标。

R.id.camera_shut是切换拍照和录像的按钮,根据选择的“拍照”或“录像”图标来选择资源。

R.id.function_btn是左侧操纵杆图标的按钮。t()方法点进去定义的是一个悬浮框。

1
2
3
4
   private final PopupWindow f5666a;
public void t(View view) {
this.f5666a.showAsDropDown(view, view.getWidth(), 0 - m.a(view.getContext(), 120.0f), 0);
}

R.id.go_home_btn是返航键,点击它如果检测不到设备会显示“设备未连接”。

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
case R.id.go_home_btn /* 2131230958 */:
if (!MyApplication.k0().X()) {
W(R.string.j0);
return;
} else if (!(!this.t0)) {
this.t0 = false;
this.goHomeBtn.setImageResource(R.drawable.ic_go_home_off);
M0();
((c.g.a.l.f) this.r0).g(1);
this.Q0.postDelayed(new Runnable() { // from class: com.vison.macrochip.sj.gps.pro.activity.j
{
ControlHyActivity.this = this;
}

@Override // java.lang.Runnable
public final void run() {
ControlHyActivity.this.o1();
}
}, 1000L);
return;
} else {
r rVar = new r(U());
rVar.b(R.drawable.ic_unlock_back);
rVar.g(R.string.return_title);
rVar.c(R.string.return_mesage);
rVar.e(R.string.return_slide);
rVar.d(new g(rVar));
rVar.h();
return;
}

检测到设备,如果想取消返航可再次点击返航键,否则会创建一个新的按钮和滑动解锁框,监听点击与解锁事件,向右滑动按钮确认返航。

R.id.more是右上角“…”按钮,具体再看吧。

R.id.ptz_down_btnR.id.ptz_up_btn是在开启角度调节功能后,界面会出现垂直滚动条,用来调节摄影角度。

R.id.sd_stream_tv,点进去J0()方法,发现与R.id.go_home_btn部分代码几乎一样,在/res/value-zh/strings.xml中果然能找到j_res_0x7f0e006b的内容。

这个就是界面上SD卡图标的按钮。

R.id.s1,进去K0()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void K0() {
if (!c.g.a.h.f.O().X()) {
W(c.g.a.e.j0);//设备未连接
} else if (this.j0.getLngLatList().isEmpty()) {
W(c.g.a.e.I);//没有航点
} else {
com.sj.baselibrary.view.r rVar = new com.sj.baselibrary.view.r(U());
rVar.b(c.g.a.a.G_res_0x7f07010a);//图标
rVar.g(c.g.a.e.O);//请确认是否航点飞行
rVar.c(c.g.a.e.M);//请确保航点位置安全,避免飞行器撞击建筑物,防止飞行器丢失
rVar.e(c.g.a.e.N);//向右滑动开始
rVar.d(new c(rVar));
rVar.h();
}
}

R.id.show_angle_btn,展示角度的按钮?

1
2
3
4
5
6
7
8
9
10
11
12
13
case R.id.show_angle_btn /* 2131231207 */:
if (m.d(this.H)) {
return;
}
this.H.setVisibility(0);
this.showAngleBtn.setVisibility(8);
if (((Integer) this.windowsBtn.getTag()).intValue() != 1) {
this.j0.setVisibility(8);
return;
}
this.i0.setVisibility(8);
m.b(this.cameraShut, this.I, this.albumBtn, this.audioBtn);
return;

R.id.shut_btn是快门键。

R.id.to_fly_btn是“自动起飞”按钮。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void I0() {
int i2;
if (!c.g.a.h.f.O().X()) {
W(c.g.a.e.j0);
return;
}
com.sj.baselibrary.view.r rVar = new com.sj.baselibrary.view.r(U());
if (F) {
rVar.b(c.g.a.a.F);
rVar.g(c.g.a.e.p0);//飞机确认下降
rVar.c(c.g.a.e.n0);//请确保飞行器降落位置安全,避免落入水中\n或者建筑物顶部,防止飞行器丢失
i2 = c.g.a.e.o0;//向右滑动下降
} else {
rVar.b(c.g.a.a.E);
rVar.g(c.g.a.e.m0);//请解锁后起飞
rVar.c(c.g.a.e.k0);//确认起飞后飞机自动上升到1.5米左右高度\n请远离人群或建筑物
i2 = c.g.a.e.l0;//向右滑动起飞
}
rVar.e(i2);
rVar.d(new m(rVar));
rVar.h();
}

R.id.windows_btn这个是什么按钮,怎么它的语句这么多?

5.2 ControlFActivity

也同样分析一下姿态模式的onCreate()onClick()。由于姿态模式与GPS模式差不多,只不过多了几个功能,所以只讲多的那些功能。

5.2.1 onCreate()

1
2
3
4
5
6
7
8
9
10
11
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
this.i0.setBackgroundResource(R.drawable.img_control_bg);
this.i0.getBackground().setAlpha(255);
setContentView(R.layout.activity_control_f);
ButterKnife.a(this);
this.W0 = new FunctionPopupWindow(U());
h1();
g1();
j1();
}

5.2.2 onClick()

这个也几乎一样。

6. 另一种思路

直接分析App可能不好找入手的地方,可以结合无人机上面的固件和App一起分析,看有没有telnet和ftp的空口令,比如捕获App发送起飞、降落、拍照命令时的数据包,通常是udp,看重放有没有效果。

如果分析无人机飞控相关,使用App代替遥控器进行操作,说明进行了远程控制,有可能是利用telnet协议进行的。如果分析无人机图传相关,飞行器拍下来的照片或录像保存到了手机上,有可能是利用ftp协议进行文件传输。

没有WiFi的情况下如何进行Android抓包?没有WiFi意味着Charles和fiddler都不能用了,因为它们必须要求手机与电脑在同一个局域网内。

但遇到物联网时,比如无人机,需要手机连接无人机设备的WiFi,此时电脑要想和手机处在一个局域网内,就必须要连接无人机设备的WiFi,但该WiFi不能联网,不能联网不可以使用Charles或fiddler抓包。

我目前试过可行的方法就是使用一台root好的手机,使用Android tcpdump工具将手机内的数据包dump下来。

通过USB连接手机与电脑,下载好Android tcpdump后将它push到手机中,授予777权限。将手机连接到无人机设备的WiFi,在手机终端界面执行以下命令进行抓包:

1
./tcpdump -i any -p -s 0 -w /sdcard/sjf/capture.pcap

Ctrl + C停止抓包,回到Kali终端执行以下命令将手机内的capture.pcap pull出来:

1
adb pull /sdcard/sjf/capture.pcap

一个简单的抓包过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌──(root㉿kali)-[~]
└─# adb shell
bullhead:/ $ su
bullhead:/ # cd /data/local/tmp
bullhead:/ # ./tcpdump -i any -p -s 0 -w /sdcard/sjf/capture.pcap
tcpdump: data link type LINUX_SLL2
tcpdump: listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
^C10270 packets captured
10370 packets received by filter
0 packets dropped by kernel
bullhead:/data/local/tmp # exit
bullhead:/ $ exit

┌──(root㉿kali)-[~]
└─# adb pull /sdcard/sjf/capture.pcap
/sdcard/capture.pcap: 1 file pulled, .... 12.9 MB/s (2595261 bytes in 0.191s)

抓包后再用Wireshark查看,发现捕获到大量的UDP和TCP等协议。

然而并分析不出什么,因为是厂商自定义的协议,Wireshark解析不了。看能不能定位到App发送TCP/UDP给无人机的那部分代码,或无人机里固件对应的代码。

使用网络通信协议分析这篇文章的hook脚本,可以知道该自定义协议本质上是用了Java层的socketRead0()socketWrite0()(TCP),还有sendtoBytes()recvfromBytes()(UDP)进行通信,查看调用栈定位到App中的代码。

TCP:

UDP:

6.1 TCP

经过分析Java代码发现并没有进行加解密算法,而是直接将数据进行发送和接收。由于主要分析App向无人机发送命令,可以以TCP的write()为例:

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
private final ArrayBlockingQueue<byte[]> f3987d = new ArrayBlockingQueue<>(f3985b);

class b extends Thread {
b() {
}

@Override // java.lang.Thread, java.lang.Runnable
public void run() {
super.run();
while (f.this.f3988e != null && f.this.f3988e.isConnected()) {
try {
if (!f.this.f3987d.isEmpty()) {
//不能深入到socketWrite0()就找谁调用了它
//发现是由java.net.SocketOutputStream.write(byte[])调用的
//也就是下面这个混淆过的函数
f.this.f.write((byte[]) f.this.f3987d.poll());
f.this.f.flush();
} else {
Thread.sleep(10L);
}
} catch (Exception unused) {
return;
}
}
f.this.f3987d.clear();
}
}

write()把字节数组通过TCP协议发了出去,所以这个字节数组是什么呢?ArrayBlockingQueue详解,它里面有几个重要的方法,比如poll()offer()offer()的功能是将数据加到 BlockingQueue 里,如果 BlockingQueue 可以容纳,则返回 true,否则返回 false。搜索一下这个阻塞式的队列offer()方法,果然是有的:

查看哪里调用了o()方法,有以下两处:

其中第二处的参数固定,并且有定时器,很有可能是充当心跳包。所以还是主要分析第一处。

第一处是被I()调用了,并且数据是I()的参数,所以继续查看哪里调用了I()方法:

6.1.1 当前经纬度数据

第一个,应该是控制无人机在设定好的经纬度飞行。

6.1.2 云台角度调节

第二个,看不出来什么。

查看哪里调用了d0(),发现恰好是控制界面中的onClick()调用了它:

点击其中一个,发现它是跟云台的角度调节有关的,所以当我们使用App调节云台角度时,它是通过这些代码来发送TCP数据包,让云台执行命令。

分析到这里,我们是不是可以使用hook d0()方法,先查看它的参数与返回值,在此基础上修改它的参数与返回值,从而达到不点击App上的按钮,就可以实现调节云台角度的功能呢?

查看参数与返回值,由于d0()方法没有返回值,所以只看参数就好了:

1
2
3
4
5
6
7
8
9
function hookd0(){
Java.perform(function(){
//静态处理
Java.use("c.g.a.h.f").d0.implementation = function(arg1){
console.log("arg1 => ", arg1);
this.d0(arg1);
}
})
}

经过实验,每按一次减号键参数递增5,每按一次加号键参数递减5,并且云台角度确实上下变换。

接下来直接在js代码中修改参数:

1
2
3
4
5
6
7
8
function hookd0(){
Java.perform(function(){
Java.use("c.g.a.h.f").d0.implementation = function(arg1){
console.log("arg1 => ", arg1);
this.d0(80);
}
})
}

发现不能自行改变云台角度,需要按加或减按钮一下才能执行到我们修改的参数的角度,再次按加或减按钮不改变角度,固定在了80。因为上面这个写法,是需要触发条件的,而触发条件就是点击按钮。要想绕过触发条件,就必须进行主动调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function hookd0(){
Java.perform(function(){

Java.use("c.g.a.h.f").d0.implementation = function(arg1){
console.log("arg1 => ",arg1);
}
})
}

function invoked0(){
Java.perform(function(){
//静态处理
Java.use("c.g.a.h.f").d0(80);
})
}
setImmediate(hookd0)
1
2
frida -FU -l d0.js
invoked0()

我主动调用了d0(),为什么打印不了上面的参数信息?疑惑。

主动调用还可以这样写:

1
2
3
4
5
6
7
8
function invoked0(){
Java.perform(function(){
//静态处理
Java.use("c.g.a.h.f").d0(80);
})
}
//脚本跑起来3s后执行函数invoked0
setTimeout(invoked0, 3000)
1
frida -FU -l d0.js

这样就可以实现无需点击按钮,发送云台调节命令的TCP数据包。

但是它发送的数据包具体内容是什么呢?仔细看d0()代码,发现要发送的数据包会对bArr字节数组的某些元素进行异或运算。再进入I()方法,里面有一个判断,如果i.m().p()为true则进入i.m().z(bArr)方法;否则直接将bArr这5或6字节数组通过TCP发送。

我们可以抓一下云台角度调节的正常包,验证我们的猜想。搜索d0()方法中传过去的字节数组,开头3字节固定为68 07 01

1
2
3
4
5
6
7
8
68 07 01 05 03
68 07 01 0a 0c
68 07 01 0f 09
68 07 01 14 12
68 07 01 19 1f
68 07 01 1e 18
68 07 01 23 25
68 07 01 28 2e

第4字节从代码中就能看出是传入的参数,也就是递增或递减5。最后发现第5字节是第2~4字节的异或运算,原来bArr字节数组就是传过去的数据本身。

进而尝试使用Python构造TCP包,重放看是否能调节云台角度。

1
2
3
4
5
6
7
8
9
10
import socket
from binascii import hexlify, unhexlify

payload = "680701"+"28"+"2e"
#payload = "680701"+"03"+"00"
payload = unhexlify(payload)
s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(('172.16.10.1',8888))
s.send(payload)
s.close()

没得问题,成功!重放的话,即使不是递增递减5,只要符合规则,也是可以调节角度的。

6.1.3 设备升级

第三第四个,查看哪里调用了b()c()

从文字分析应该是有关设备升级代码,可以不管。可以通过调用这个下载病毒或木马不?(bushi

6.1.4 设置中的参数栏

第五第六个非常相似,都是跟“设置”相关的。

从第六个调用地的变量j、a2、c2、e2可以知道,它们分别是“设置”中参数栏下的新手模式、总飞行距离、飞行高度、返航高度。

要想修改其中的数值,直接修改它们的返回值即可。要想知道数据包的内容,与6.1.2同样方法求得。(或许吧)

同样可以继续深入查看调用,其中发现姿态模式的控制页面调用了它。

而第五个应该是与初始化相关?

6.1.5 时间

第七第八个是发送与时间相关数据包:

顺着一路往上追可以追到快门键,感兴趣可以追一下。

6.1.6 航点

最后一个:

对列表中的每个元素进行遍历,每个循环中创建一个长度为16的字节数组,前3个字节固定值,转换成十六进制为68 04 0C。定义了一个LGFlyLineBean类,里面存的是每个元素的经纬度、元素下标、高度、速度和时间。再将这个LGFlyLineBean类通过convertFlyLine()这个Native方法转换为字节数组。arraycopy()方法将convertFlyLine字节数组从下标为0开始复制到bArr数组下标为3的地址,复制长度为convertFlyLine字节数组的长度,结合下面的异或运算可以猜测长度为12,bArr字节数组下标为15的元素是下标为1~14的异或结果。最后将bArr字节数组通过TCP发送到无人机。

继续不停往上查看调用,最后发现是R.id.s1按钮调用的它。

R.id.s1的解析可以看5.1.2,它应该是进入航线规划后的“GO”按钮。其中列表的每个元素就是每个设置好的航点。

6.2 UDP

UDP也是用阻塞式的队列offer()方法来将数据加入队列。这里面的参数非常明显了,字节数组、字节数组的长度、对端IP、对端端口。发现有两个调用:

先来看第一个。

这个应该是设置中继的IP和端口??

中继器(RP repeater)工作于OSI的物理层,是局域网上所有节点的中心,它的作用是放大信号,补偿信号衰减,支持远距离的通信。

继续看第二个,默认以端口8080发送UDP数据。

可以发现发送的数据很多都是以ff 53 54开头的,抓的包也有很多这些数据。我们先看。

6.2.1 c.g.a.h.f

c.g.a.h.f类中的,往上跟会发现大多是在进行初始化操作。

再看这个类中的N()U()e0()N()U()在同一个方法中被调用,只是所需的前提条件不一样,而且它们内部执行流程也不一样。

暂时还不知道有什么用,看不懂。e0()也看不出什么,真是醉了。

继续看这个类中的g0(),发现终于有点提示了。

查看g0()有两处调用,o.f5360a=128,o.f5361b=5,即每按一次左或右,在初始值128的基础上递增或递减5,区间范围在0~255之间。

抓包发现App向无人机发送数据,无人机返回同样的数据给App。

再看h0(),结合App中的信息,可以知道这个是“设置”中图像栏的SD卡分辨率,默认为4K,也就是常量3,常量1表示为2.7K。

6.2.2 c.g.a.l

c.g.a.l.d

c.g.a.l.e

c.g.a.l.f

c.g.a.l.g

c.g.a.l.h

c.g.a.l.i

6.2.3 c.g.a.m.q

这个类中的a()方法是删除SD卡中所有内容。

b()方法是获取SD卡的状态和容量。

6.2.4 c.i.a.f.b

B()方法,包中的数据以“D”开头:

1
2
3
public void B(String str) {
K(("D" + str).getBytes());
}

追踪到最后发现这个方法是设置国家码之类的。

F()G()H()是使用UDP发的来表示时间的方法。

g.run()方法,只发送了一个字节42(“B”),抓包时可以抓到这个包,但是使用jadx查看用例却溯源不了。暂时放弃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class g extends Thread {
g() {
}

@Override // java.lang.Thread, java.lang.Runnable
public void run() {
super.run();
int i = 5;
while (true) {
i--;
if (i > 0) {
b.this.K("B".getBytes());
try {
Thread.sleep(300L);
} catch (InterruptedException e2) {
e2.printStackTrace();
}
} else {
return;
}
}
}
}

k()方法也同样溯源不了,只发了一个字节47(“G”)。

l()方法,如果设备型号和设备ID都为空,发送一个字节0f

1
2
3
public void l() {
K(new byte[]{15});
}

o()方法发送一个字节28

p()方法发送一个字节2c

x()方法发送当前日期和时间。

1
2
3
4
public void x() {
String format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(System.currentTimeMillis()));
K(("date -s \"" + format + "\"").getBytes());
}

以上这些方法都追不到调用地,但抓包时都抓到了,很可能是初始化时发送的。

6.2.5 c.i.a.g.a

b.run()在判断数据流是JPEG格式还是H264格式。如果是H264格式需要解码。其中getOneFrame()decodeH264()都是JNI函数。发送一个字节27

6.2.6 c.i.a.g.e

a.run()跟进去发现是跟定时器有关的,而e.f3977a是一个没有初始化的字节数组,所以传的是什么数据?

6.2.7 c.i.a.k.a

C0121a.run()方法传入的也是5字节数组,这里可以很清晰看出是获得当前IP地址并加上某些数据得到的数据包。这里也是充当一个定时器。

6.2.8 com.sj.baselibrary.view.c

a.run(),很容易看出这个是在机器校准时进行的代码。

6.2.9 com.sj.baselibrary.view.g

g()h()i()中参数初始化值都为128。

看不懂这是哪里的拖动条,又是什么意思。

6.2.10 com.vison.macrochip.sj.gps.pro.activity.b

k()方法很容易知道是环绕飞行的操作。

7. 第三种思路

由于我们已经知道在App中进行操作所发送的TCP/UDP包是自定义协议,并且自定义协议是通过Java代码中一些字节数组实现,那我们是不是可以通过抓到的TCP/UDP包,来实现Java代码的定位呢?通过分析数据包发现,一般如果数据多于3个字节,那么数据的前3个字节是固定的。

依照这个思路,可以抓相关飞控、图传之类的数据包。

7.1 飞控

航线规划

兴趣点环绕(环绕半径)

GPS跟随

图像跟随

自动起飞

自动返航

7.2 图传

7.2.1 拍照

进行了两次拍照,一次使用App,一次使用遥控器。

1
2
3
4
public static void b() {
g.f("--获取状态和容量--");
f.O().K(new byte[]{-1, 83, 84, 0, 1});
}

这只是在拍照前发送获取状态和容量的数据包,然而拍照却不是这里。

可以尝试hook b()方法进行验证:

1
2
3
4
5
function hookb(){
Java.perform(function(){
Java.use("c.g.a.m.q").b();
})
}
1
hookb()

包抓到了,但是没进行拍照,可以确定这个只是单纯获取状态和容量的。

可以看到在快门键中有一个条件判断。查看变量B0的赋值:

A0()方法中,如果现在的状态是“拍照”,则返回true;如果是“录像”,则返回false。如果是“拍照”,执行D0()方法。

1
2
3
4
5
6
7
8
9
public void D0() {
//获取当前系统时间,以毫秒为单位
long currentTimeMillis = System.currentTimeMillis();
//Java中类成员变量初始值默认为0
if (currentTimeMillis - this.h0 > 800) {
this.h0 = currentTimeMillis;
c.i.a.i.a.b(this, MediaPixel.P_4K, new l());
}
}

尝试hook D0()方法,看是否经过了它:

1
2
3
4
5
6
7
8
9
function main(){
Java.perform(function(){
Java.use("c.g.a.h.d").D0.implementation = function(){
this.D0();
console.log("bye");
}
})
}
setImmediate(main)

实验证明确实是经过了。将this.D0()这一句注释掉后,发现按下快门键不拍照了,说明获取图片信息就在D0()方法中。D0()只调用了一个方法:c.i.a.i.a.b(),可以先看一下它的参数是什么。

1
2
3
4
5
6
7
8
9
10
function main(){
Java.perform(function(){
Java.use("c.i.a.i.a").b.implementation = function(arg1,arg2,arg3){
console.log("arg1,arg2,arg3 => ",arg1,arg2,JSON.stringify(arg3));
//this.b(arg1,arg2,arg3);
console.log("bye");
}
})
}
setImmediate(main)

new l() -> c.g.a.m.p.f()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
   public class l implements c.i.a.j.c {
l() {
}

@Override // c.i.a.j.c
public void b() {
c.g.a.m.p.f(d.this.U(), c.g.a.d.n);
}
}

//n=0x7F0D0025
public static final int n = 2131558437;

public class a extends c {
public Context U() {
return this;
}

public static void f(Context context, int i) {
if (f3899c) {
c().load(context, i, 1);
}
}

在AK中可以知道这个资源ID是照片。

网上对Context的理解:当前对象在程序中所处的一个环境,一个与系统交互的过程。 比如QQ和朋友聊天时,此时的Context是指的聊天界面以及相关的数据请求与传输,Context在加载资源、启动Activity、获取系统服务、创建View等操作都要参与。

c.g.a.m.p.f() -> c()

c()是SoundPool对象的一个实例,其中它的load()方法声明如下:

1
2
//通过resID从APK资源中载入
int load(Context context, int resId, int priority)

D0() -> c.i.a.i.a.b() -> c.i.a.i.a.c()

c.i.a.i.b.a()方法的功能是根据拍照质量选择对应的像素。

c.i.a.g.j.d.k().q()方法是设置水印。

c.i.a.g.j.d.k().n()方法传入l()的一个实例。

c.i.a.f.b.f3940c()是String型,查看哪里给它赋值了。

很明显就是在特定路径下创建了一个目录,并取绝对文件路径。

将这个文件路径作为参数传入com.vison.baselibrary.utils.c.a()方法:

1
2
3
4
public static File a(String str) {
String format = new SimpleDateFormat("yyyyMMdd_HHmmsss", Locale.ENGLISH).format(new Date());
return new File(str, "IMG_" + format + ".jpg");
}

在该目录下创建了一个名字以“IMG_”开头,“.jpg”结尾,中间是当前日期时间的文件。尝试hook这个a()方法可以得到照片在手机中的路径,最终返回一个File类的实例。

并将该实例作为参数传入e.g().o()方法。

1
2
3
4
5
6
public void o(File file) {
c.i.a.h.e.a aVar = this.f4105d;
if (aVar != null) {
aVar.a(file);
}
}

o()只调用了一个a()方法,所以直接hook a()方法看是否经过它。

1
2
3
4
5
6
7
8
9
10
function main(){
Java.perform(function(){
Java.use("c.i.a.h.e.a").a.implementation = function(arg1){
console.log("arg1=> ",arg1);
this.a(arg1);
console.log("bye");
}
})
}
setImmediate(main)

是经过了,但a()方法构造如下:

1
2
3
4
public void a(File file) {
this.f4123d = true;
this.f = file;
}

查看this.f在哪里被调用到:

那就直接hook l()方法:

1
2
3
4
5
6
7
8
9
10
function main(){
Java.perform(function(){
Java.use("c.i.a.g.j.d").l.implementation = function(arg1,arg2,arg3,arg4){
console.log("arg1,arg2,arg3,arg4=> ",arg1,arg2,arg3,arg4);
this.l(arg1,arg2,arg3,arg4);
console.log("bye");
}
})
}
setImmediate(main)

四个参数依次是File类引用、屏幕的分辨率17941080(把手机侧边的虚拟按键去掉就可以1920\1080)、true。true是什么意思呢?将它改为false发现照片上下翻转了,就是实现照片上下翻转功能。

l()方法最终调用m()方法:

直接hook f()方法试试吧。

1
2
3
4
5
6
7
8
9
function main(){
Java.perform(function(){
Java.use("c.i.a.g.j.b").f.implementation = function(){
this.f();
console.log("bye");
}
})
}
setImmediate(main)

同样注释掉this.f()拍不了照,继续看发现最终调用i()方法创建了一个新线程。

1
2
3
4
5
6
7
8
9
10
11
12
public void f() {
c cVar = this.f4019b;
if (cVar == null) {
com.vison.baselibrary.utils.g.a("未初始化");
} else {
cVar.i();
}
}

public void i() {
new Thread(this, c.class.getSimpleName()).start();
}

那照片是如何存到SD卡中呢?我们发现它不仅有.jpg格式的文件,还有同名的.thm文件,有些视频播放器除了需要MP4或者MPG格式的视频文件外,还需要一个THM格式的索引文件才能播放(所以关JPG格式什么事)。在jadx中搜索“thm”果然有相关信息。

根据上下文分析,这个run()方法的主要功能是将指定目录下的所有.jpg文件放入一个列表,再从该列表中依次取出元素,将每个元素的后缀名改为.thm,按指定格式写入当前目录。

(我在干嘛…不想做了)

拍照、录像、手势拍照、手势录像。


上面说到,如果把tcpdump放入手机中,只能抓手机与无人机通信的包,而将tcpdump放在无人机中,就可以同时抓到无人机与遥控器、无人机与手机通信的包了。如何将tcpdump放进去呢?使用tftp工具或SD卡都可以。tcpdump 4.99.1 / 1.10.1 版本太新,无人机系统不兼容,所以选择tcpdump 4.9.2 / 1.9.0。

在抓包前,查看当前环境:

  • 无人机:172.16.10.1
  • 遥控器:172.16.10.10
  • 手机:172.16.10.20
  • 物理机:172.16.10.21
  • 虚拟机:172.16.10.22

执行抓包命令就可以知道无人机在跟哪些IP通信。