【Android安全- 逆向某社交app:过检测、去混淆、登录协议】此文章归类为:Android安全。
记录一下分析的过程,文中如果有什么不足或是错误,还请各位看官老爷多加指导,十分感谢!
分析的过程中发现有对环境的检测,这里放到前面来说
用frida启动目标app
script.js:
1
2
3
4
5
|
function main(){
console.log(
"hello frida"
);
}
setImmediate(main)
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
frida
-
U
-
f cn.soulapp.android
-
l script.js
-
-
no
-
pause
____
/
_ | Frida
14.2
.
18
-
A world
-
class
dynamic instrumentation toolkit
| (_| |
> _ | Commands:
/
_
/
|_|
help
-
> Displays the
help
system
. . . .
object
?
-
> Display information about
'object'
. . . . exit
/
quit
-
> Exit
. . . .
. . . . More info at https:
/
/
frida.re
/
docs
/
home
/
Spawning `cn.soulapp.android`...
hello frida
Spawned `cn.soulapp.android`. Resuming main thread!
[AOSP on flame::cn.soulapp.android]
-
> Process terminated
[AOSP on flame::cn.soulapp.android]
-
>
|
目标app闪退
hook一下libc下的open、openat、access、pthread_create等函数,以及so的加载情况,看看检测大概在哪个so中
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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
|
function hook_libc(){
console.log(
"[*] start hook libc"
);
Interceptor.attach(Module.findExportByName(null,
'open'
), {
onEnter: function(args){
var file_name
=
ptr(args[
0
]).readCString();
console.log(
"[*] open => "
+
file_name);
console.log(
'\tret addr => '
+
this.context.lr);
/
/
return
address
},
onLeave: function(ret){}
});
Interceptor.attach(Module.findExportByName(null,
'openat'
), {
onEnter: function(args){
var file_name
=
ptr(args[
1
]).readCString();
console.log(
"[*] openat => "
+
file_name);
console.log(
'\tret addr => '
+
this.context.lr);
/
/
return
address
},
onLeave: function(ret){}
});
Interceptor.attach(Module.findExportByName(null,
'access'
), {
onEnter: function(args){
console.log(
"[*] access => "
+
ptr(args[
0
]).readCString());
},
onLeave: function(ret){}
});
Interceptor.attach(Module.findExportByName(null,
'pthread_create'
), {
onEnter: function(args){
console.log(
"[*] pthread_create => "
+
args[
2
]);
},
onLeave: function(ret){}
});
}
function hook_so_init_13() {
var linker
=
null;
if
(Process.pointerSize
=
=
4
) {
linker
=
Process.findModuleByName(
"linker"
);
}
else
{
linker
=
Process.findModuleByName(
"linker64"
);
}
var addr_call_function
=
null;
if
(linker
=
=
null){
console.error(
"not found linker"
);
return
;
}
var symbols
=
linker.enumerateSymbols();
for
(var i
=
0
; i < symbols.length; i
+
+
) {
var name
=
symbols[i].name;
if
(name.indexOf(
"_dl__ZL13call_functionPKcPFviPPcS2"
) >
=
0
){
addr_call_function
=
symbols[i].address;
console.log(
"[*] find call_function => "
, symbols[i].address.toString(
16
));
break
;
}
}
if
(addr_call_function
=
=
null){
console.log(
"not found call_function"
);
return
;
}
Interceptor.attach(addr_call_function,{
onEnter: function(args){
var func_addr
=
args[
1
];
if
(func_addr
=
=
0
){
return
;
}
var
type
=
ptr(args[
0
]).readCString();
var path
=
ptr(args[
2
]).readCString();
var strs
=
path.split(
"/"
);
var so_name
=
strs.pop();
var so
=
Process.findModuleByName(so_name);
var func_off
=
func_addr.sub(so.base);
console.log(
"[*] so init => type: "
+
type
+
", addr: "
+
func_off
+
", path: "
+
path
+
", base: "
+
so.base
+
", end: "
+
so.base.add(so.size));
},
onLeave: function(retval){
}
});
}
|
结果:
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
|
[
*
] so init
=
>
type
: DT_INIT, addr:
0x14400
, path:
/
data
/
app
/
~~zvKwORKKOdt_CSOvgvjWDw
=
=
/
cn.soulapp.android
-
RKWj
-
B0RHnBzFFGB2I8lzw
=
=
/
lib
/
arm64
/
libmsaoaidsec.so, base:
0x765ce54000
, end:
0x765cefd000
...
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7413
/
stat
ret addr
=
>
0x765ce6f81c
[
*
] openat
=
>
/
proc
/
self
/
task
/
7411
/
status
ret addr
=
>
0x765ce70088
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7414
/
stat
ret addr
=
>
0x765ce6f81c
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7420
/
stat
ret addr
=
>
0x765ce6f81c
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7421
/
stat
ret addr
=
>
0x765ce6f81c
[
*
] openat
=
>
/
proc
/
self
/
task
/
7416
/
status
ret addr
=
>
0x765ce70088
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7427
/
stat
ret addr
=
>
0x765ce6f81c
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7439
/
stat
ret addr
=
>
0x765ce6f81c
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7440
/
stat
ret addr
=
>
0x765ce6f81c
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7441
/
stat
ret addr
=
>
0x765ce6f81c
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7442
/
stat
ret addr
=
>
0x765ce6f81c
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7443
/
stat
ret addr
=
>
0x765ce6f81c
[
*
]
open
=
>
/
proc
/
7399
/
task
/
7444
/
stat
ret addr
=
>
0x765ce6f81c
|
可以看到是在libmsaoaidsec.so这个so加载时,开两个线程一直在访问 /proc/self/task/tid/status和/proc/pid/task/tid/stat
观察openat的返回地址和libmsaoaidsec.so的起始地址和结束地址,定位到libmsaoaidsec.so模块中的函数 sub_1B730 和 sub_1BFAC
sub_1B730:遍历线程的stat文件,如果状态为t则返回777
查看函数sub_1B730的交叉引用,跟踪到函数sub_1B8D4:
这个函数是一个死循环,不停的调用三个函数 sub_1AE48、sub_1AB54、sub_1B730,如果返回值满足条件则调用函数 sub_11FA4,这里一眼检测的形状,这个函数应该就是单独创建线程来执行的。
sub_1AE48:
返回/proc/pid/status中TracerPid的值
sub_1AB54:
打开sub_1AE48中返回的TracerPid进程的/proc/pid/status文件
查看PPid是否是自己,如果是则返回1,不是返回0,看来还有双进程保护?
回到sub_1B730,如果本进程的TracerPid不等于0且正在ptrace自己的这个进程的父进程不是自己,或者是线程的状态为t,则调用sub_11FA4,在sub_11FA4中调用了sub_234E0
sub_234E0:
动态解密执行代码退出进程
查看sub_1B8D4的交叉引用,发现是从函数sub_1B924创建线程而来:
总结:sub_1B8D4,检测进程status文件中的TracerPid字段和线程stat文件中是否有t字段
回到上面的frida hook的结果,另一个openat打开/proc/self/task/tid/status文件的地方是sub_1BFAC:
遍历线程的status文件,搜索字符串guum-js-loop gmain,这是frida创建的线程的名字,这里是在检测frida
查看sub_1BFAC的交叉引用是在函数sub_01C544内,存在死循环不停调用四个函数进行检测
来看其他三个
sub_1C158:检测fd文件夹下是否有到linjector的链接,linjector是一个lua注入工具
sub_1C26C:检测maps中是否有模块 _AGENT_1.0、frida-agent
sub_26334: 检测pthread_create是否被hook
0x58000051 和0x58000050是frida hook的特征,如果检测到也会通过在内存中动态解密syscall来退出进程
总结:sub_01C544,检测frida线程名字、maps中frida模块、fs文件夹下injector链接、pthread_create函数头hook
使用frida将上述的两个函数sub_01C544、sub_1B8D4动态patch掉,app就可以正常启动了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
function patch(){
/
/
sub_01C544、sub_1B8D4
var base
=
Module.findBaseAddress(
"libmsaoaidsec.so"
)
var add
=
ptr(base.add(
0x1B8D4
));
Memory.patchCode(add,
4
, code
=
> {
const cw
=
new Arm64Writer(code, { pc: add });
cw.putRet();
cw.flush();
});
add
=
ptr(base.add(
0x1C544
));
Memory.patchCode(add,
4
, code
=
> {
const cw
=
new Arm64Writer(code, { pc: add });
cw.putRet();
cw.flush();
});
}
|
从上面两个函数的交叉引用往上翻了一下,发现都是从.init_proc调用过来的,其中还发现一些其他没触发的检测:
本来是先抓包定位加密算法才发现so中有混淆的,这里放到前面来吧。
需要分析的函数是 libsoulpower!14C1F4,加了间接跳转类型的混淆,ida反编译没法看:
观察混淆的特征:
先调用了函数 sub_152B48,然后br跳转到寄存器x0
看sub_152B48:
从x0+w1*8的地址处取8字节给x0,而x0和x1都是在调用中转函数之前赋好值了(有第二种模式是x0在函数内赋值的)
也就是说,这个间接跳转就是跳转到base[index*8]存储的地址去,而且通过观察,同一个函数中,中转函数是同一个,那么这次可以使用idapython来找到一个指定中转函数的所有交叉引用,在从这个交叉引用地址开始向上搜索,找到base和index,计算跳转的地址,然后patch成b和b.cond。
中转函数存在两种情况,一种是8作为base,x0作为index,base的赋值在中转函数内,第二种是x0作为base,x1作为index,base的赋值在被混淆函数。
第一步 遍历被混淆函数函数的引用
1
2
3
4
5
6
|
def
fuck_jmpouts(jmp_func):
xrefs
=
sark.Line(jmp_func).xrefs_to
for
xref
in
xrefs:
if
xref.frm
+
4
!
=
jmp_func:
#排除掉地址挨着的前继
print
(
'handle =>'
+
hex
(xref.frm))
fuck_jmpout(xref.frm)
|
第二步 找base
从调用中转函数的地址开始找base的地址。
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
find_jmp_table_1(bl_addr):
find_add
=
False
base
=
0
offset
=
0
addr
=
bl_addr
-
4
while
True
:
ins
=
sark.Line(addr).insn
# print(line.disasm)
op1
=
ins.operands[
0
]
if
(op1.
type
.is_reg
=
=
True
)
and
op1.reg
=
=
'X0'
:
if
ins.mnem
=
=
'ADRL'
:
# ADRL X0, off_38CF80
return
ins.operands[
1
].imm
elif
(ins.mnem
=
=
'ADD'
)
and
(find_add
=
=
False
):
# ADD X0, X0, #off_38CF80@PAGEOFF
offset
=
ins.operands[
2
].imm
find_add
=
True
elif
(ins.mnem
=
=
'ADRP'
)
and
(find_add
=
=
True
):
return
offset
+
ins.operands[
1
].imm
addr
=
addr
-
4
def
find_jmp_table_2(bl_addr):
jmp_func_addr
=
sark.Line(bl_addr).insn.operands[
0
].addr
return
sark.Line(jmp_func_addr).insn.operands[
1
].imm
|
第三步 找index
有两种情况,第一种是单分支,那就直接是mov赋值。第二种是多分支,多分支有存在cinc和cesl两种情况。
cinc指令:条件成立,目标寄存器=源寄存器+1,否则目标寄存器=源寄存器。
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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
|
def
find_index(bl_addr):
type
=
get_jmp_func_type(bl_addr)
if
type
=
=
3
:
index_reg
=
'W0'
elif
type
=
=
2
:
index_reg
=
'W1'
else
:
assert
0
,
'unkown jmp type'
bl_addr
=
bl_addr
-
4
nop_addr
=
None
ret
=
None
while
True
:
ins
=
sark.Line(bl_addr).insn
op1
=
ins.operands[
0
]
if
op1.
type
.is_reg
=
=
True
and
op1.reg
=
=
index_reg:
if
ins.mnem
=
=
'MOV'
:
# MOV W1, #2
nop_addr
=
bl_addr
ret
=
list
([ins.operands[
1
].imm])
break
elif
ins.mnem
=
=
'CSEL'
:
# CSEL W1, W8, W11, EQ
# print(hex(line.ea), line.disasm)
nop_addr
=
bl_addr
ret
=
find_csel_index(bl_addr)
break
elif
(ins.mnem
=
=
'CINC'
):
nop_addr
=
bl_addr
ret
=
find_cinc_index(bl_addr)
break
else
:
assert
0
,
'unkown ins grant value to w1'
bl_addr
=
bl_addr
-
4
nop(nop_addr)
return
ret
def
find_csel_index(csel_addr):
csel_ins
=
sark.Line(csel_addr).insn
reg_yes
=
csel_ins.operands[
1
].reg
reg_no
=
csel_ins.operands[
2
].reg
find_reg_yes
=
False
find_reg_no
=
False
reg_yes_value
=
None
re_no_value
=
None
addr
=
csel_addr
-
4
while
True
:
ins
=
sark.Line(addr).insn
# print(hex(line.ea), line.disasm)
if
(
len
(ins.operands) >
0
)
and
ins.operands[
0
].
type
.is_reg:
if
(ins.operands[
0
].reg
=
=
reg_yes)
and
(find_reg_yes
=
=
False
):
# print(hex(line.ea), line.disasm)
reg_yes_value
=
ins.operands[
1
].imm
find_reg_yes
=
True
if
(ins.operands[
0
].reg
=
=
reg_no)
and
(find_reg_no
=
=
False
):
# print(hex(line.ea), line.disasm)
reg_no_value
=
ins.operands[
1
].imm
find_reg_no
=
True
if
(find_reg_no
=
=
True
)
and
(find_reg_yes
=
=
True
):
return
list
([reg_yes_value, reg_no_value, csel_ins.operands[
3
].text])
addr
=
addr
-
4
def
find_cinc_index(cinc_addr):
csel_ins
=
sark.Line(cinc_addr).insn
target_reg
=
csel_ins.operands[
1
].reg
cond
=
csel_ins.operands[
2
].text
addr
=
cinc_addr
-
4
while
True
:
ins
=
sark.Line(addr).insn
# print(hex(line.ea), line.disasm)
if
ins.operands[
0
].
type
.is_reg
and
(ins.operands[
0
].reg
=
=
target_reg):
return
list
([ins.operands[
1
].imm
+
1
, ins.operands[
1
].imm, cond])
addr
=
addr
-
4
|
第四步 计算目标地址并patch
对于patch,单分支好说,直接br x0,改成b imm即可。
如果是多分支,当bl 中转地址和br x0这两条指令是连着的,就可以直接在原地patch成b.con和b指令,如果不是连着的,那就需要跳板,在跳板上写b.con和b指令
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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
|
def
fuck_jmpout(bl_addr):
dword
=
idc.get_wide_dword(bl_addr)
if
dword
=
=
nop_dword:
# handled
return
base
=
find_jmp_table(bl_addr)
print
(
"base => "
,
hex
(base))
branch
=
find_index(bl_addr)
print
(
"branch => "
, branch)
if
len
(branch)
=
=
1
:
p_dest
=
branch[
0
]
*
8
+
base
dest
=
struct.unpack(
'<Q'
, idc.get_bytes(p_dest,
8
))[
0
]
br_addr
=
find_br(bl_addr)
patch_single_branch(bl_addr, br_addr, dest)
if
len
(branch)
=
=
3
:
p_dest1
=
branch[
0
]
*
8
+
base
# y
p_dest2
=
branch[
1
]
*
8
+
base
# n
dest1
=
struct.unpack(
'<Q'
, idc.get_bytes(p_dest1,
8
))[
0
]
dest2
=
struct.unpack(
'<Q'
, idc.get_bytes(p_dest2,
8
))[
0
]
br_addr
=
find_br(bl_addr)
patch_double_branch(bl_addr, br_addr, dest1, dest2, branch[
2
])
def
patch_single_branch(bl_addr, br_addr, dest_addr):
global
nop_dword, ks
patch_dword(bl_addr, nop_dword)
jmp_disam
=
'B '
+
hex
(dest_addr)
encodings
=
bytearray(ks.asm(jmp_disam, br_addr)[
0
])
jmp_dword
=
struct.unpack(
'<i'
, encodings)[
0
]
patch_dword(br_addr, jmp_dword)
def
patch_double_branch(bl_addr, br_addr, dest1, dest2, cond):
global
nop_dword, ks
if
br_addr
-
bl_addr
=
=
4
:
jmp_cond_disasm
=
'B.'
+
cond
+
' '
+
hex
(dest1)
jmp_disasm
=
'B '
+
hex
(dest2)
jmp_cond_enc
=
bytearray(ks.asm(jmp_cond_disasm, bl_addr)[
0
])
jmp_enc
=
bytearray(ks.asm(jmp_disasm, br_addr)[
0
])
jmp_cond_dword
=
struct.unpack(
'<i'
, jmp_cond_enc)[
0
]
jmp_dword
=
struct.unpack(
'<i'
, jmp_enc)[
0
]
patch_dword(bl_addr, jmp_cond_dword)
patch_dword(br_addr, jmp_dword)
else
:
nop_addr
=
find2nop()
assert
nop_addr,
'no nop found'
j2b_disasm
=
'B '
+
hex
(nop_addr)
jmp_cond_disasm
=
'B.'
+
cond
+
' '
+
hex
(dest1)
jmp_disasm
=
'B '
+
hex
(dest2)
j2b_enc
=
bytearray(ks.asm(j2b_disasm, br_addr)[
0
])
jmp_cond_enc
=
bytearray(ks.asm(jmp_cond_disasm, nop_addr)[
0
])
jmp_enc
=
bytearray(ks.asm(jmp_disasm, nop_addr
+
4
)[
0
])
j2b_dword
=
struct.unpack(
'<i'
, j2b_enc)[
0
]
jmp_cond_dword
=
struct.unpack(
'<i'
, jmp_cond_enc)[
0
]
jmp_dword
=
struct.unpack(
'<i'
, jmp_enc)[
0
]
patch_dword(bl_addr, nop_dword)
patch_dword(br_addr, j2b_dword)
patch_dword(nop_addr, jmp_cond_dword)
patch_dword(nop_addr
+
4
, jmp_dword)
|
在patch完之后,因为b指令跳转过去的地址已经被ida识别为了别的函数,所以还是会jumpout
让ida重新分析和对函数头按U、C、P键,也没用,可能是跳转过去的地址优先被识别为函数了,所以只要过去把那个函数给U、C,再对本函数按下alt+p重新识别函数,这一条分支就可以连过去了。但是,连过去的那个块还会出现这种情况,本来想着手动搞搞得了,后来发现一个函数几百上千个块,手按要按按死人,还是想想办法用idapython来间接一下把。
只要能找到idapython删除函数,创建函数等api就可以用脚本模拟这段操作了,查了很久资料才找到:
步骤:
代码:
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
make_function(start):
while
True
:
all_lines
=
sark.Function(start).lines
jmp_list
=
[]
for
line
in
all_lines:
if
line.insn.mnem.lower().startswith(
'b'
)
and
not
line.insn.mnem.lower().startswith(
'bl'
):
jmp_list.append(line)
# print(line)
has_jmpout
=
False
for
line
in
jmp_list:
dest
=
line.insn.operands[
0
].addr
try
:
func
=
sark.Function(dest)
except
:
print
(
'no function in dest'
)
else
:
if
sark.Function(dest).start_ea !
=
start:
print
(line,
hex
(dest))
idaapi.del_func(sark.Function(dest).start_ea)
has_jmpout
=
True
if
has_jmpout
=
=
False
:
break
idaapi.del_func(start)
ida_funcs.add_func(start,
-
1
)
ida_auto.auto_wait()
# ida重新解析函数需要时间,太快遍历不到新的jmpout分支
|
下面这段代码,BL sub_1098B4 单独被分了一个块,和下面的代码割裂开了。
按道理来说,bl不应该分块了,这段代码应该是一个块才对,被分成两个快导致去掉混淆之后ida还是无法正确的反编译。
搞了很久都没有搞定????,最后再群里问,@2beNo2大佬说是因为函数sub_1098B4被识别为noreturn了,进去一看果然是这样的,把这个函数函数声明修改一下就可以了。
去混淆前
去混淆后,还有一层非常标准的ollvm,已经可以分析了,不管它
目标app没有ssl pinning,直接配置好抓包环境就可以直接抓到包。
抓几次账号密码登陆包对比
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
POST
/
v7
/
account
/
login?bi
=
%
5B
%
2218e3539dd13
%
22
%
2C
-
1
%
2C
%
22google
%
22
%
2C
%
22Android
%
22
%
2C33
%
2C13
%
2C
%
22Pixel6
%
22
%
2C
%
22Google
%
22
%
2C420
%
2C
%
221080
*
2209
%
22
%
2C
%
22wandoujia
%
22
%
2C
%
22WIFI
%
22
%
2C
%
22zh_CN_
%
23Hans
%
22
%
5D
&bik
=
32755
&pageId
=
LoginRegeister_PhoneNumEnter HTTP
/
1.1
Content
-
Encoding: gzip
Connection: Keep
-
Alive
di: Ze8pytVH7K4DAFYobs8O7c3q
sdi: UGl4ZWwgNjfOSAZOJavNeg__dbb862142da74372e7a6ff5c794f2539
aid:
10000003
av:
5.13
.
0
avc:
24013008
at:
18e3539dd18
os: android
slb: NDYxaTJ5VG03WFdlMzIvZ2t4UFUzKzFvemlxSmpMcmxWeWV2NW4ycWVJSUtCMXdlZ2JVME9nPT0
=
User
-
Agent: cn.soulapp.android
/
b25cff Mozilla
/
5.0
(Linux; Android
13
; Pixel
6
Build
/
TQ3A.
230805.001
; wv) AppleWebKit
/
537.36
(KHTML, like Gecko) Version
/
4.0
Chrome
/
109.0
.
5414.123
Mobile Safari
/
537.36
SoulBegin
-
Android
-
5.13
.
0
-
wifi
-
SoulEnd
cs:
028feb05d4348df6bd0f77837266e05677ac
Content
-
Type
: application
/
x
-
www
-
form
-
urlencoded
Content
-
Length:
162
Host: api
-
account.soulapp.cn
Accept
-
Encoding: gzip
area
=
86
&phone
=
UGFCelBEVmpXdxxxxUVZGZVRmUT09&password
=
fcea920xxxxule0cf42b8c93759&sMDeviceId
=
202403120012596f17ca7f48c8soula807a24df106ba1301e17b596c0955ed
|
可以发现除了时间戳之外只有cs字段在一直变动,这个应该就是签名字段了
jadx打开apk,搜索v7/account/login,搜到了loginByPwd,这是接口中的方法
接着搜索loginByPwd
在cn.soulapp.android.square.a.l方法中调用了loginByPwd,很明显是在检查输入的手机号和密码,frida hook一下发现点击登录之后确实走了这里,手机号和密码也是在这里加密的。
手机号:des加密后做两次base64
密码:md5之后转成十六进制字符串
cs是在http头部的字段中,反编译apk,在文件夹下搜索addHeader,定位到cn.soulapp.android.soulpower.SoulPowerful.l
l调用h,h是native函数:
1
|
public static native String h(Context context,
int
i11, String
str
, String str2);
|
先hook看一下参数和返回值:
1
2
3
4
5
6
7
8
9
10
11
12
|
function hook_SoulPowerful_h(){
console.log(
"hooking SoulPowerful_h"
);
Java.perform(function(){
let SoulPowerful
=
Java.use(
"cn.soulapp.android.soulpower.SoulPowerful"
);
SoulPowerful[
"h"
].implementation
=
function (context, i11,
str
, str2) {
console.log(`SoulPowerful.h
is
called: context
=
${context}, i11
=
${i11},
str
=
${
str
}, str2
=
${str2}`);
let result
=
this[
"h"
](context, i11,
str
, str2);
console.log(`SoulPowerful.h result
=
${result}`);
return
result;
};
});
}
|
输出:
1
2
3
4
5
6
7
8
9
|
SoulPowerful.h
is
called:
context
=
cn.soulapp.android.SoulApp@
95743cb
,
i11
=
1710625687
,
str
=
/
v7
/
account
/
login?area
=
86
&bi
=
[
"18e493c8640"
,
-
1
,
"google"
,
"Android"
,
33
,
13
,
"Pixel6"
,
"Google"
,
420
,
"1080*2209"
,
"wandoujia"
,
"WIFI"
,
"zh_CN_#Hans"
]&bik
=
32755
&pageId
=
LoginRegeister_PhoneNumEnter&password
=
275xxxxb187c530bd6f5ae6ef1228f03
&phone
=
OWl3OFM0RxxxpU2lBNkF0cEJwWlVLUT09&sMDeviceId
=
202403120012596f17ca7f48c842a1a807a24df106ba1301e17b596c0955ed
,
str2
=
cn.soulapp.android
/
b25cff Mozilla
/
5.0
(Linux; Android
13
; Pixel
6
Build
/
TQ3A.
230805.001
; wv) AppleWebKit
/
537.36
(KHTML, like Gecko) Version
/
4.0
Chrome
/
122.0
.
6261.105
Mobile Safari
/
537.36
SoulBegin
-
Android
-
5.13
.
0
-
wifi
-
SoulEnd1000000318e493c86435.
13.0ZfPksF6VbXADAKTYemBqdxToUGl4ZWwgNjfOSAZOJavNeg__dbb862142da74372e7a6ff5c794f2539
SoulPowerful.h result
=
02af2965e993f416997f7743b8a022a7778c
|
结合jdadx反编译结果,可以看出参数是:
返回值就是登录包中的cs字段
h可以确定就是cs的生成函数了
用frida hook RegisterNatives,看一下对应的so以及偏移在哪里
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 find_RegisterNatives(params) {
let symbols
=
Module.enumerateSymbolsSync(
"libart.so"
);
let addrRegisterNatives
=
null;
for
(let i
=
0
; i < symbols.length; i
+
+
) {
let symbol
=
symbols[i];
/
/
_ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi
if
(symbol.name.indexOf(
"GLOBAL"
) <
0
&&
symbol.name.indexOf(
"art"
) >
=
0
&&
symbol.name.indexOf(
"JNI"
) >
=
0
&&
symbol.name.indexOf(
"RegisterNatives"
) >
=
0
) {
addrRegisterNatives
=
symbol.address;
console.log(
"RegisterNatives is at "
, symbol.address, symbol.name);
hook_RegisterNatives(addrRegisterNatives)
}
}
}
function hook_RegisterNatives(addrRegisterNatives) {
if
(addrRegisterNatives !
=
null) {
Interceptor.attach(addrRegisterNatives, {
onEnter: function (args) {
console.log(
"[RegisterNatives] method_count:"
, args[
3
]);
let java_class
=
args[
1
];
let class_name
=
Java.vm.tryGetEnv().getClassName(java_class);
/
/
console.log(class_name);
let methods_ptr
=
ptr(args[
2
]);
let method_count
=
parseInt(args[
3
]);
for
(let i
=
0
; i < method_count; i
+
+
) {
let name_ptr
=
Memory.readPointer(methods_ptr.add(i
*
Process.pointerSize
*
3
));
let sig_ptr
=
Memory.readPointer(methods_ptr.add(i
*
Process.pointerSize
*
3
+
Process.pointerSize));
let fnPtr_ptr
=
Memory.readPointer(methods_ptr.add(i
*
Process.pointerSize
*
3
+
Process.pointerSize
*
2
));
let name
=
Memory.readCString(name_ptr);
let sig
=
Memory.readCString(sig_ptr);
let symbol
=
DebugSymbol.fromAddress(fnPtr_ptr)
console.log(
"[RegisterNatives] java_class:"
, class_name,
"name:"
, name,
"sig:"
, sig,
"fnPtr:"
, fnPtr_ptr,
" fnOffset:"
, symbol,
" callee:"
, DebugSymbol.fromAddress(this.returnAddress));
}
}
});
}
}
|
发现是在libsoulpower!14C1F4,此函数加了混淆,用上面说的脚本可以去掉,去掉之后还剩下控制流平坦化、虚假控制流,直接ida f5静态看+动态调试就可以分析了,为什么不用frida trace呢,因为老是在调用jni函数的时候崩溃,就直接动态调了,函数的总体流程如下:
观察返回值可知,结果是一个0x36字节的字符串
一共0x36字节,感觉还是挺麻烦的
使用python模拟手机号、密码的加密、cs字段的生成,发送数据包进行登录,如果能成功收到返登录成功的返回包,那说明cs字段没算错
SoulPowerful_h.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
|
import
binascii
import
random
import
struct
import
time
import
hashlib
def
get_check_status():
return
0xaf
def
reverseint2bytes(num):
barr
=
struct.pack(
'>i'
, num)
return
barr
def
enc_timestamp(hex_str, table):
enc
=
[
'0'
]
*
len
(table)
for
i
in
range
(
len
(hex_str)):
enc[i]
=
hex_str[table[i]
-
1
]
enc_str
=
''.join(enc)
# print(enc_str)
timestamp_enc
=
int
(enc_str,
16
)
return
reverseint2bytes(timestamp_enc)
def
get_md5(barr):
md5
=
hashlib.md5()
md5.update(barr)
return
md5.digest()
def
get_infogather_aa():
return
'77'
.encode(
'utf-8'
)
def
get_libIncite_bb():
return
'77'
.encode(
'utf-8'
)
def
get_contain_str():
# 是否有model|app ver|deviceId,权重分别是 1、2、4
# 登录协议固定为07
return
'03'
.encode(
'utf-8'
)
def
sign(timestamp, str1, str2):
# 1. 第一个字节固定为2
sign
=
[
0
]
*
36
sign[
0
]
=
2
# 2. 第二个字节是状态检查参数 固定为0xaf,加密时间戳
sign[
1
]
=
get_check_status()
timestamp_hex
=
'%08x'
%
(timestamp)
# print("timestamp: ", timestamp_hex)
enc_table1
=
[
4
,
2
,
7
,
6
,
5
,
1
,
8
,
3
]
enc_table2
=
[
2
,
5
,
3
,
8
,
7
,
1
,
6
,
4
]
timestamp_enc1
=
enc_timestamp(timestamp_hex, enc_table1)
# print("timestamp_enc1: ", binascii.hexlify(timestamp_enc1))
timestamp_enc2
=
enc_timestamp(timestamp_hex, enc_table2)
# print("timestamp_enc2: ", binascii.hexlify(timestamp_enc2))
# 3. 输入字符串2,拼接SoulPowerful
str2_soulpowerful
=
str2
+
'SoulPowerful'
md5
=
hashlib.md5()
md5.update(str2_soulpowerful.encode(
'utf-8'
))
str2_soulpowerful_md5
=
md5.digest()
# print(binascii.hexlify( str2_soulpowerful_md5))
# 4. 使用填充str2_soulpowerful_md5的前4字节和timestamp_enc1填充 2~9字节
sign[
2
]
=
str2_soulpowerful_md5[
0
]
sign[
4
]
=
str2_soulpowerful_md5[
1
]
sign[
6
]
=
str2_soulpowerful_md5[
2
]
sign[
8
]
=
str2_soulpowerful_md5[
3
]
sign[
3
]
=
timestamp_enc1[
0
]
sign[
5
]
=
timestamp_enc1[
1
]
sign[
7
]
=
timestamp_enc1[
2
]
sign[
9
]
=
timestamp_enc1[
3
]
# 5. 将加密时间戳2转换成十六进制字符串
# print(timestamp_enc2)
# 0x12 0x34 0x56 0x78 => '12345678'
timestamp_enc2_hex
=
binascii.b2a_hex(timestamp_enc2).decode(
'utf-8'
)
# print(timestamp_enc2_hex)
# 6. 输入字符串1 + 加密时间戳2字符串 + 固定字符串
str1_timestamp_enc2_hex_magic
=
str1
+
timestamp_enc2_hex
+
'kG@yGB9'
# print("str1_timestamp_enc2_hex_magic: " + str1_timestamp_enc2_hex_magic)
md52
=
get_md5(str1_timestamp_enc2_hex_magic.encode(
'utf-8'
))
# 7. md5前5字节写入sign 12 ~ 15 字节
for
i
in
range
(
4
):
sign[
12
+
i]
=
md52[i]
# 8. md5转换成十六进制字符串,写入0~31
hex_str
=
binascii.hexlify(bytearray(sign[
0
:
16
]))
# print(hex_str, len(hex_str))
for
i
in
range
(
len
(hex_str)):
sign[i]
=
hex_str[i]
# 9. infogather_bb的值转换成十六进制字符串,写入20 21
bb
=
get_libIncite_bb()
sign[
20
]
=
bb[
0
]
sign[
21
]
=
bb[
1
]
# 9. get_contain_str的值转换成十六进制字符串,第二字节写入23
con
=
get_contain_str()
sign[
23
]
=
con[
1
]
aa
=
get_infogather_aa()
sign[
32
]
=
aa[
0
]
sign[
33
]
=
aa[
1
]
status_xor_hex
=
hex
(get_check_status() ^
0x23
)[
2
:].encode(
'utf-8'
)
sign[
34
]
=
status_xor_hex[
0
]
sign[
35
]
=
status_xor_hex[
1
]
# 这个字节看起来跟栈地址有关,给个随机的吧
sign[
22
]
=
ord
(
str
(random.randint(
0
,
9
)))
return
bytearray(sign).decode(
'utf-8'
)
if
__name__
=
=
'__main__'
:
str1
=
"hello"
str2
=
"123456"
timestamp
=
1710285686
sign
=
sign(timestamp, str1, str2)
print
(sign.decode(
'utf-8'
))
for
i
in
range
(
36
):
print
(
"%d: %s"
%
(i,
hex
(sign[i])), end
=
', '
)
|
login.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
|
import
base64
import
binascii
import
hashlib
import
time
import
httpx
import
SoulPowerful_h
from
Crypto.Cipher
import
DES
def
des_cbc_encode(key, data):
# key: 8个字节
# data: 明文数据,utf-8格式
des
=
DES.new(key.encode(
'utf-8'
), mode
=
DES.MODE_ECB)
# # 需要加密的数据必须是16的倍数
# # 填充规则: 缺少数据量的个数 * chr(缺少数据量个数)
pad_len
=
8
-
len
(data)
%
8
data
+
=
pad_len
*
chr
(pad_len)
return
des.encrypt(data.encode(
'utf-8'
))
def
enc_phone(phone):
secret_key
=
'789!@#xs'
return
base64.b64encode(base64.b64encode(des_cbc_encode(secret_key, phone))).decode(
'utf-8'
)
def
enc_pwd(pwd):
md5
=
hashlib.md5()
md5.update(pwd.encode(
'utf-8'
))
return
binascii.b2a_hex(md5.digest()).decode(
'utf-8'
)
phone
=
'12345678910'
pwd
=
'66666666'
phone_enc
=
enc_phone(phone)
pwd_enc
=
enc_pwd(pwd)
url
=
'https://api-account.soulapp.cn/v7/account/login'
timestamp
=
int
(
round
(time.time()
*
1000
))
# 毫秒级时间戳
bi
=
'["%x",-1,"Android","Android",29,10,"AOSPonflame","Google",440,"1080*2148","wandoujia","WIFI","zh_CN_#Hans"]'
%
timestamp
params
=
{
'bi'
: bi,
'bik'
:
'32755'
,
'pageId'
:
'LoginRegeister_PhoneNumEnter'
}
timestamp_str
=
"%x"
%
int
(
round
(time.time()
*
1000
))
timestamp
=
round
(time.time()
*
1000
)
ua
=
'cn.soulapp.android/b25cff Mozilla/5.0 (Linux; Android 10; AOSP on flame Build/QQ3A.200805.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/74.0.3729.186 Mobile Safari/537.36 SoulBegin-Android-5.13.0-wifi-SoulEnd'
aid
=
'10000003'
av
=
'5.13.0'
di
=
'Ze5JV5EDMJcDAHbW5otov3u8'
sdi
=
'QU9TUCBvbiBgmzIGgQeGXw__7d2e7b615d2a9e41510d122b8f7bd525'
str2
=
ua
+
aid
+
timestamp_str
+
av
+
di
+
sdi
str1
=
'/v7/account/login?area=86&bi=["%s",-1,"Android","Android",29,10,"AOSPonflame","Google",440,"1080*2148","wandoujia","WIFI","zh_CN_#Hans"]&bik=32755&pageId=LoginRegeister_PhoneNumEnter&password=%s&phone=%s&sMDeviceId=20240224191019989b3xxxxxfc54f09de74b6b8121180170b92f8c9984a3'
%
(timestamp_str, pwd_enc, phone_enc)
sign
=
SoulPowerful_h.sign(
int
(timestamp
/
1000
), str1,str2)
headers
=
{
'content-encoding'
:
'gzip'
,
'di'
:
'Ze5JV5EDMJcDAHbW5otov3u8'
,
'sdi'
: sdi,
'aid'
: aid,
'av'
: av,
'avc'
:
'24013008'
,
'at'
: timestamp_str,
'os'
:
'android'
,
'user-agent'
: ua,
'cs'
: sign,
'content-type'
:
'application/x-www-form-urlencoded'
,
'accept-encoding'
:
'gzip'
}
data
=
{
'area'
:
86
,
'phone'
: phone_enc,
'password'
: pwd_enc,
'sMDeviceId'
:
'20240224191019989b3fxxxxxcfc54f09de74b6b8121180170b92f8c9984a3'
}
client
=
httpx.Client(http2
=
True
)
response
=
client.post(url, headers
=
headers, params
=
params, data
=
data)
print
(response.text)
|
结果:
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
|
{
"code"
:
10001
,
"message"
:
"success"
,
"data"
: {
"userId"
:
-
1
,
"birthday"
:
"9530xxx00000"
,
"userCreateTime"
:
1672648399000
,
"role"
:
0
,
"gender"
:
"MALE"
,
"signature"
:
"xxxx"
,
"constellation"
:
"双鱼座"
,
"userIdEcpt"
:
"aVp4R3diSmxxxxxvZ29OU1VteVFpUT09"
,
"avatarColor"
:
"HeaderColor_Default"
,
"avatarUrl"
: null,
"token"
:
"CMxxb20n/3ExxxxxCF1J/Q0VxxxGpnI4"
,
"avatarName"
:
"avatar-1638xxxxxx424-03339"
,
"isBirthday"
: false,
"isMatch"
: null,
"bindMail"
: "",
"pushReceiveScope"
:
1
,
"loginSuccess"
: true,
"loginFailInfo"
: null,
"register"
: null,
"bindPhone"
: null
},
"success"
: true
}
|
完结,撒花。
后续可能会去分析一下那个虚拟化后的函数,还没分析过vm,挑战一下
更多【Android安全- 逆向某社交app:过检测、去混淆、登录协议】相关视频教程:www.yxfzedu.com