【知识】【python】【adb】uiautomator2 包使用指南

python 通过 uiautomator2 包操作安卓设备界面的各种方法

安装及 init

1
pip install uiautomator2
1
2
3
4
5
6
7
8
9
10
import uiautomator2 as ui2

# 1. wifi 连
d = ui2.connect('10.0.0.1')
# 2. usb 连
d = ui2.connect('123456f')
# 3. adb wifi 连
d = u2.connect_adb_wifi("10.0.0.1:5555")

print(d.info())

API 合集

APP 操作

  1. 安装 app
    d.app_install('http://some-domain.com/some.apk')

  2. 启动 app

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 默认的这种方法是先通过atx-agent解析apk包的mainActivity,然后调用am start -n $package/$activity启动
    d.app_start("com.example.hello_world")

    # 使用 monkey -p com.example.hello_world -c android.intent.category.LAUNCHER 1 启动
    # 这种方法有个副作用,它自动会将手机的旋转锁定给关掉
    d.app_start("com.example.hello_world", use_monkey=True) # start with package name

    # 通过指定main activity的方式启动应用,等价于调用am start -n com.example.hello_world/.MainActivity
    d.app_start("com.example.hello_world", ".MainActivity")
  3. 停止 app 运行

  • 单个 app

    1
    2
    3
    4
    # equivalent to `am force-stop`, thus you could lose data
    d.app_stop("com.example.hello_world")
    # equivalent to `pm clear`
    d.app_clear('com.example.hello_world')
  • 所有 app

    1
    2
    3
    4
    # stop all
    d.app_stop_all()
    # stop all app except for com.examples.demo
    d.app_stop_all(excludes=['com.examples.demo'])
  1. 获取 app 信息
  • 指定 app

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    d.app_info("com.examples.demo")
    # expect output
    #{
    # "mainActivity": "com.github.uiautomator.MainActivity",
    # "label": "ATX",
    # "versionName": "1.1.7",
    # "versionCode": 1001007,
    # "size":1760809
    #}

    # save app icon
    img = d.app_icon("com.examples.demo")
    img.save("icon.png")
  • 正在运行的 app

    1
    2
    3
    d.app_list_running()
    # expect output
    # ["com.xxxx.xxxx", "com.github.uiautomator", "xxxx"]
1
2
3
4
5
6
7
8
pid = d.app_wait("com.example.android") # 等待应用运行, return pid(int)
if not pid:
print("com.example.android is not running")
else:
print("com.example.android pid is %d" % pid)

d.app_wait("com.example.android", front=True) # 等待应用前台运行
d.app_wait("com.example.android", timeout=20.0) # 最长等待时间20s(默认)

基本操作

  1. 文件传输
    push

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # push to a folder
    d.push("foo.txt", "/sdcard/")
    # push and rename
    d.push("foo.txt", "/sdcard/bar.txt")
    # push fileobj
    with open("foo.txt", 'rb') as f:
    d.push(f, "/sdcard/")
    # push and change file access mode
    d.push("foo.sh", "/data/local/tmp/", mode=0o755)

    pull

    1
    2
    3
    4
    d.pull("/sdcard/tmp.txt", "tmp.txt")

    # FileNotFoundError will raise if the file is not found on the device
    d.pull("/sdcard/some-file-not-exists.txt", "tmp.txt")
  2. shell

    1
    2
    3
    4
    5
    6
    7
    8
    output, exit_code = d.shell("pwd", timeout=60) # timeout 60s (Default)
    # output: "/\n", exit_code: 0
    # Similar to command: adb shell pwd

    # Since `shell` function return type is `namedtuple("ShellResponse", ("output", "exit_code"))`
    # so we can do some tricks
    output = d.shell("pwd").output
    exit_code = d.shell("pwd").exit_code
1
2
3
4
5
6
7
8
9
10
r = d.shell("logcat", stream=True)
# r: requests.models.Response
deadline = time.time() + 10 # run maxium 10s
try:
for line in r.iter_lines(): # r.iter_lines(chunk_size=512, decode_unicode=None, delimiter=None)
if time.time() > deadline:
break
print("Read:", line.decode('utf-8'))
finally:
r.close() # this method must be called
  1. session
    代表一个 app 的生命周期,可以用来启动、停用 app,检测 app crash

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    sess = d.session("com.netease.cloudmusic") # start 网易云音乐
    sess.close() # 停止网易云音乐
    sess.restart() # 冷启动网易云音乐

    with d.session("com.netease.cloudmusic") as sess:
    sess(text="Play").click()

    # launch app if not running, skip launch if already running
    sess = d.session("com.netease.cloudmusic", attach=True)

    # raise SessionBrokenError if not running
    sess = d.session("com.netease.cloudmusic", attach=True, strict=True)

    # When app is still running
    sess(text="Music").click() # operation goes normal

    # If app crash or quit
    sess(text="Music").click() # raise SessionBrokenError
    # other function calls under session will raise SessionBrokenError too

    # check if session is ok.
    # Warning: function name may change in the future
    sess.running() # True or False
  2. 设备信息

    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
    d.info
    # {
    # u'displayRotation': 0,
    # u'displaySizeDpY': 640,
    # u'displaySizeDpX': 360,
    # u'currentPackageName': u'com.android.launcher',
    # u'productName': u'takju',
    # u'displayWidth': 720,
    # u'sdkInt': 18,
    # u'displayHeight': 1184,
    # u'naturalOrientation': True
    #}

    print(d.window_size())
    # device upright output example: (1080, 1920)
    # device horizontal output example: (1920, 1080)

    print(d.app_current())
    # Output example 1: {'activity': '.Client', 'package': 'com.netease.example', 'pid': 23710}
    # Output example 2: {'activity': '.Client', 'package': 'com.netease.example'}
    # Output example 3: {'activity': None, 'package': None}

    d.wait_activity(".ApiDemos", timeout=10) # default timeout 10.0 seconds
    # Output: true of false

    print(d.serial)
    # output example: 74aAEDR428Z9

    print(d.wlan_ip)
    # output example: 10.0.0.1

    print(d.device_info)
    # {'udid': '3578298f-b4:0b:44:e6:1f:90-OD103',
    # 'version': '7.1.1',
    # 'serial': '3578298f',
    # 'brand': 'SMARTISAN',
    # 'model': 'OD103',
    # 'hwaddr': 'b4:0b:44:e6:1f:90',
    # 'port': 7912,
    # 'sdk': 25,
    # 'agentVersion': 'dev',
    # 'display': {'width': 1080, 'height': 1920},
    # 'battery': {'acPowered': False,
    # 'usbPowered': False,
    # 'wirelessPowered': False,
    # 'status': 3,
    # 'health': 0,
    # 'present': True,
    # 'level': 99,
    # 'scale': 100,
    # 'voltage': 4316,
    # 'temperature': 272,
    # 'technology': 'Li-ion'},
    # 'memory': {'total': 3690280, 'around': '4 GB'},
    # 'cpu': {'cores': 8, 'hardware': 'Qualcomm Technologies, Inc MSM8953Pro'},
    # 'presenceChangedAt': '0001-01-01T00:00:00Z',
    # 'usingBeganAt': '0001-01-01T00:00:00Z'
    # }
  3. 剪切板

    1
    2
    d.set_clipboard('text', 'label')
    print(d.clipboard)
  4. 事件

    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
    d.screen_on() # turn on the screen
    d.screen_off() # turn off the screen

    d.info.get('screenOn') # require Android >= 4.4

    d.press("home") # press the home key, with key name
    d.press("back") # press the back key, with key name
    d.press(0x07, 0x02) # press keycode 0x07('0') with META ALT(0x02)

    # These key names are currently supported:
    # home
    # back
    # left
    # right
    # up
    # down
    # center
    # menu
    # search
    # enter
    # delete ( or del)
    # recent (recent apps)
    # volume_up
    # volume_down
    # volume_mute
    # camera
    # power
  • 手势操作

    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
    d.unlock()
    # This is equivalent to
    # 1. launch activity: com.github.uiautomator.ACTION_IDENTIFY
    # 2. press the "home" key

    d.click(x, y)

    d.double_click(x, y)
    d.double_click(x, y, 0.1) # default duration between two click is 0.1s

    d.long_click(x, y)
    d.long_click(x, y, 0.5) # long click 0.5s (default)

    d.swipe(sx, sy, ex, ey)
    d.swipe(sx, sy, ex, ey, 0.5) # swipe for 0.5s(default)

    d.swipe_ext("right") # 手指右滑,4选1 "left", "right", "up", "down"
    d.swipe_ext("right", scale=0.9) # 默认0.9, 滑动距离为屏幕宽度的90%
    d.swipe_ext("right", box=(0, 0, 100, 100)) # 在 (0,0) -> (100, 100) 这个区域做滑动

    # 实践发现上滑或下滑的时候,从中点开始滑动成功率会高一些
    d.swipe_ext("up", scale=0.8) # 代码会vkk
    # 还可以使用Direction作为参数
    from uiautomator2 import Direction
    d.swipe_ext(Direction.FORWARD) # 页面下翻, 等价于 d.swipe_ext("up"), 只是更好理解
    d.swipe_ext(Direction.BACKWARD) # 页面上翻
    d.swipe_ext(Direction.HORIZ_FORWARD) # 页面水平右翻
    d.swipe_ext(Direction.HORIZ_BACKWARD) # 页面水平左翻

    d.drag(sx, sy, ex, ey)
    d.drag(sx, sy, ex, ey, 0.5) # swipe for 0.5s(default)

    # swipe from point(x0, y0) to point(x1, y1) then to point(x2, y2)
    # time will speed 0.2s bwtween two points
    d.swipe_points([(x0, y0), (x1, y1), (x2, y2)], 0.2)

    d.touch.down(10, 10) # 模拟按下
    time.sleep(.01) # down 和 move 之间的延迟,自己控制
    d.touch.move(15, 15) # 模拟移动
    d.touch.up() # 模拟抬起
  • 屏幕相关

    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
    # retrieve orientation. the output could be "natural" or "left" or "right" or "upsidedown"
    orientation = d.orientation
    # WARNING: not pass testing in my TT-M1
    # set orientation and freeze rotation.
    # notes: setting "upsidedown" requires Android>=4.3.
    d.set_orientation('l') # or "left"
    d.set_orientation("l") # or "left"
    d.set_orientation("r") # or "right"
    d.set_orientation("n") # or "natural"

    # freeze rotation
    d.freeze_rotation()
    # un-freeze rotation
    d.freeze_rotation(False)

    # take screenshot and save to a file on the computer, require Android>=4.2.
    d.screenshot("home.jpg")
    # get PIL.Image formatted images. Naturally, you need pillow installed first
    image = d.screenshot() # default format="pillow"
    image.save("home.jpg") # or home.png. Currently, only png and jpg are supported
    # get opencv formatted images. Naturally, you need numpy and cv2 installed first
    import cv2
    image = d.screenshot(format='opencv')
    cv2.imwrite('home.jpg', image)
    # get raw jpeg data
    imagebin = d.screenshot(format='raw')
    open("some.jpg", "wb").write(imagebin)

    # get the UI hierarchy dump content (unicoded).
    xml = d.dump_hierarchy()

    d.open_notification()
    d.open_quick_settings()
  • 选择器(selector)

    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
    # Select the object with text 'Clock' and its className is 'android.widget.TextView'
    d(text='Clock', className='android.widget.TextView')

    # Selector supports below parameters. Refer to UiSelector Java doc for detailed information.
    # text, textContains, textMatches, textStartsWith
    # className, classNameMatches
    # description, descriptionContains, descriptionMatches, descriptionStartsWith
    # checkable, checked, clickable, longClickable
    # scrollable, enabled,focusable, focused, selected
    # packageName, packageNameMatches
    # resourceId, resourceIdMatches
    # index, instance

    # get the children or grandchildren
    d(className="android.widget.ListView").child(text="Bluetooth")

    # get siblings
    d(text="Google").sibling(className="android.widget.ImageView")

    # get the child matching the condition className="android.widget.LinearLayout"
    # and also its children or grandchildren with text "Bluetooth"
    d(className="android.widget.ListView", resourceId="android:id/list") \
    .child_by_text("Bluetooth", className="android.widget.LinearLayout")
    # get children by allowing scroll search
    d(className="android.widget.ListView", resourceId="android:id/list") \
    .child_by_text(
    "Bluetooth",
    allow_scroll_search=True,
    className="android.widget.LinearLayout"
    )

    ## select "switch" on the right side of "Wi‑Fi"
    d(text="Wi‑Fi").right(className="android.widget.Switch").click()
    d(text="Add new", instance=0) # which means the first instance with text "Add new"

    # get the count of views with text "Add new" on current screen
    d(text="Add new").count
    # same as count property
    len(d(text="Add new"))
    # get the instance via index
    d(text="Add new")[0]
    d(text="Add new")[1]
    ...
    # iterator
    for view in d(text="Add new"):
    view.info # ...

获取到目标元素后查看状态与信息

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
d(text="Settings").exists # True if exists, else False
d.exists(text="Settings") # alias of above property.

# advanced usage
d(text="Settings").exists(timeout=3) # wait Settings appear in 3s, same as .wait(3)

d(text="Settings").info

d(text="Settings").get_text() # get widget text
d(text="Settings").set_text("My text...") # set the text
d(text="Settings").clear_text() # clear the text

x, y = d(text="Settings").center()
# x, y = d(text="Settings").center(offset=(0, 0)) # left-top x, y

im = d(text="Settings").screenshot()
im.save("settings.jpg")

# click on the center of the specific ui object
d(text="Settings").click()

# wait element to appear for at most 10 seconds and then click
d(text="Settings").click(timeout=10)

# click with offset(x_offset, y_offset)
# click_x = x_offset * width + x_left_top
# click_y = y_offset * height + y_left_top
d(text="Settings").click(offset=(0.5, 0.5)) # Default center
d(text="Settings").click(offset=(0, 0)) # click left-top
d(text="Settings").click(offset=(1, 1)) # click right-bottom

# click when exists in 10s, default timeout 0s
clicked = d(text='Skip').click_exists(timeout=10.0)

# click until element gone, return bool
is_gone = d(text="Skip").click_gone(maxretry=10, interval=1.0) # maxretry default 10, interval default 1.0

# long click on the center of the specific UI object
d(text="Settings").long_click()

# notes : drag can not be used for Android<4.3.
# drag the UI object to a screen point (x, y), in 0.5 second
d(text="Settings").drag_to(x, y, duration=0.5)
# drag the UI object to (the center position of) another UI object, in 0.25 second
d(text="Settings").drag_to(text="Clock", duration=0.25)

d(text="Settings").swipe("right")
d(text="Settings").swipe("left", steps=10)
d(text="Settings").swipe("up", steps=20) # 1 steps is about 5ms, so 20 steps is about 0.1s
d(text="Settings").swipe("down", steps=20)

d(text="Settings").gesture((sx1, sy1), (sx2, sy2), (ex1, ey1), (ex2, ey2))

# notes : pinch can not be set until Android 4.3.
# from edge to center. here is "In" not "in"
d(text="Settings").pinch_in(percent=100, steps=10)
# from center to edge
d(text="Settings").pinch_out()

# wait until the ui object appears
d(text="Settings").wait(timeout=3.0) # return bool
# wait until the ui object gone
d(text="Settings").wait_gone(timeout=1.0)

# fling forward(default) vertically(default)
d(scrollable=True).fling()
# fling forward horizontally
d(scrollable=True).fling.horiz.forward()
# fling backward vertically
d(scrollable=True).fling.vert.backward()
# fling to beginning horizontally
d(scrollable=True).fling.horiz.toBeginning(max_swipes=1000)
# fling to end vertically
d(scrollable=True).fling.toEnd()

# scroll forward(default) vertically(default)
d(scrollable=True).scroll(steps=10)
# scroll forward horizontally
d(scrollable=True).scroll.horiz.forward(steps=100)
# scroll backward vertically
d(scrollable=True).scroll.vert.backward()
# scroll to beginning horizontally
d(scrollable=True).scroll.horiz.toBeginning(steps=100, max_swipes=1000)
# scroll to end vertically
d(scrollable=True).scroll.toEnd()
# scroll forward vertically until specific ui object appears
d(scrollable=True).scroll.to(text="Security")
  1. WatchContext
    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
    with d.watch_context() as ctx:
    ctx.when("^立即(下载|更新)").when("取消").click() # 当同时出现 (立即安装 或 立即取消)和 取消 按钮的时候,点击取消
    ctx.when("同意").click()
    ctx.when("确定").click()
    # 上面三行代码是立即执行完的,不会有什么等待

    ctx.wait_stable() # 开启弹窗监控,并等待界面稳定(两个弹窗检查周期内没有弹窗代表稳定)

    # 使用call函数来触发函数回调
    # call 支持两个参数,d和el,不区分参数位置,可以不传参,如果传参变量名不能写错
    # eg: 当有元素匹配仲夏之夜,点击返回按钮
    ctx.when("仲夏之夜").call(lambda d: d.press("back"))
    ctx.when("确定").call(lambda el: el.click())

    # 其他操作

    # 为了方便也可以使用代码中默认的弹窗监控逻辑
    # 下面是目前内置的默认逻辑,可以加群at群主,增加新的逻辑,或者直接提pr
    # when("继续使用").click()
    # when("移入管控").when("取消").click()
    # when("^立即(下载|更新)").when("取消").click()
    # when("同意").click()
    # when("^(好的|确定)").click()
    with d.watch_context(builtin=True) as ctx:
    # 在已有的基础上增加
    ctx.when("@tb:id/jview_view").when('//*[@content-desc="图片"]').click()

    # 其他脚本逻辑
1
2
3
4
5
ctx = d.watch_context()
ctx.when("设置").click()
ctx.wait_stable() # 等待界面不在有弹窗了

ctx.close()
  1. 全局设置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    d.HTTP_TIMEOUT = 60 # 默认值60s, http默认请求超时时间

    # 当设备掉线时,等待设备在线时长,仅当TMQ=true时有效,支持通过环境变量 WAIT_FOR_DEVICE_TIMEOUT 设置
    d.WAIT_FOR_DEVICE_TIMEOUT = 70

    print(d.settings)
    {'operation_delay': (0, 0),
    'operation_delay_methods': ['click', 'swipe'],
    'wait_timeout': 20.0,
    'xpath_debug': False}

    # 配置点击前延时0.5s,点击后延时1s
    d.settings['operation_delay'] = (.5, 1)

    # 修改延迟生效的方法
    # 其中 double_click, long_click 都对应click
    d.settings['operation_delay_methods'] = ['click', 'swipe', 'drag', 'press']

    d.settings['xpath_debug'] = True # 开启xpath插件的调试日志
    d.settings['wait_timeout'] = 20.0 # 默认控件等待时间(原生操作,xpath插件的等待时间)

    >>> d.settings['click_before_delay'] = 1
    [W 200514 14:55:59 settings:72] d.settings[click_before_delay] deprecated: Use operation_delay instead
  2. 输入

    1
    2
    3
    4
    5
    d.set_fastinput_ime(True) # 切换成FastInputIME输入法
    d.send_keys("你好123abcEFG") # adb广播输入
    d.clear_text() # 清除输入框所有内容(Require android-uiautomator.apk version >= 1.0.7)
    d.set_fastinput_ime(False) # 切换成正常的输入法
    d.send_action("search") # 模拟输入法的搜索
  3. Toast

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    d.toast.show("Hello world")
    d.toast.show("Hello world", 1.0) # show for 1.0s, default 1.0s

    # [Args]
    # 5.0: max wait timeout. Default 10.0
    # 10.0: cache time. return cache toast if already toast already show up in recent 10 seconds. Default 10.0 (Maybe change in the furture)
    # "default message": return if no toast finally get. Default None
    d.toast.get_message(5.0, 10.0, "default message")
    # common usage
    assert "Short message" in d.toast.get_message(5.0, default="")
    # clear cached toast
    d.toast.reset()
    # Now d.toast.get_message(0) is None
  4. XPath

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    # wait exists 10s
    d.xpath("//android.widget.TextView").wait(10.0)
    # find and click
    d.xpath("//*[@content-desc='分享']").click()
    # check exists
    if d.xpath("//android.widget.TextView[contains(@text, 'Se')]").exists:
    print("exists")
    # get all text-view text, attrib and center point
    for elem in d.xpath("//android.widget.TextView").all():
    print("Text:", elem.text)
    # Dictionary eg:
    # {'index': '1', 'text': '999+', 'resource-id': 'com.netease.cloudmusic:id/qb', 'package': 'com.netease.cloudmusic', 'content-desc': '', 'checkable': 'false', 'checked': 'false', 'clickable': 'false', 'enabled': 'true', 'focusable': 'false', 'focused': 'false','scrollable': 'false', 'long-clickable': 'false', 'password': 'false', 'selected': 'false', 'visible-to-user': 'true', 'bounds': '[661,1444][718,1478]'}
    print("Attrib:", elem.attrib)
    # Coordinate eg: (100, 200)
    print("Position:", elem.center())
  5. Screenrecord

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    d.screenrecord('output.mp4')

    time.sleep(10)
    # or do something else
    d.screenrecord.stop() # 停止录制后,output.mp4文件才能打开

    imdata = "target.png" # 也可以是URL, PIL.Image或OpenCV打开的图像

    d.image.match(imdata)
    # 匹配待查找的图片,立刻返回一个结果
    # 返回一个dict, eg: {"similarity": 0.9, "point": [200, 300]}

    d.image.click(imdata, timeout=20.0)
    # 在20s的时间内调用match轮询查找图片,当similarity>0.9时,执行点击操作

【知识】【安卓】JsBridge

学习一个 H5 与安卓原生的交互方法 JsBridge

Js 调 Java

Java 创建 Handler

1
2
3
4
5
6
7
webView.registerHandler("hello", new BridgeHandler() {
@Override
public void handler(String data, CallBackFunction function) {
CusToast.showToast("hello:" + data);
function.onCallBack("hi there");
}
});

Js 指定 handler

1
2
3
4
5
6
7
8
9
10
11
function test() {
var str = "world";

//send message to native
var data = {str};
window.WebViewJavascriptBridge.callHandler('hello'
, {'param': data}
, function(responseData) {
document.getElementById("show").innerHTML = responseData;
});
}

另一种不需要指定 Hanlder 的处理模式
Java 创建默认 Handler

1
2
3
4
5
6
7
webView.setDefaultHandler(new BridgeHandler() {
@Override
public void handler(String data, CallBackFunction function) {
CusToast.showToast("receive data:" + data);
function.onCallBack("okay");
}
});

Js 发消息

1
2
3
4
5
6
7
8
9
function test() {
var data = "";
window.WebViewJavascriptBridge.send(
data
, function(responseData) {
document.getElementById("show").innerHTML = "repsonseData from java, data = " + responseData
}
);
}

Java 调 Js

Js 创建 Hanlder

1
2
3
4
5
6
7
8
9
connectWebViewJavascriptBridge(function(bridge) {
bridge.registerHandler("Jsfunc", function(data, responseCallback) {
document.getElementById("init").innerHTML = ("data: " + data);
if (responseCallback) {
var responseData = "okay";
responseCallback(responseData);
}
});
})

Java 指定 Handler

1
2
3
4
5
6
7
8
9
10
11
12
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
webView.callHandler("Jsfunc", "test data", new CallBackFunction() {

@Override
public void onCallBack(String data) {
CusToast.showToast("js resp: " + data);
}
});
}
});

同样有不指定 Hanlder 的模式
Js 创建

1
2
3
4
5
6
7
8
connectWebViewJavascriptBridge(function(bridge) {
bridge.init(function(message, responseCallback) {
console.log('message:', message);
var data = "got it";
console.log('JS resp: ', data);
responseCallback(data);
});
})

Java 层

1
2
3
4
5
6
webView.send("message from Java", new CallBackFunction() {
@Override
public void onCallBack(String data) {
CusToast.showToast("Js resp: " + data);
}
});

【技术】【安卓隐私合规】policyLint 工具原理解析

最近在了解一些安卓隐私合规工作,主要想了解工作是如何基于 NLP 进行隐私政策文本处理的,因此记录一下经典之作 PolicyLint 的原理

概述

本文是发表于 Sec19 的PolicyLint: Investigating Internal Privacy Policy Contradictions on Google Play
这篇文章具体研究问题是,隐私政策文本内的一些表述矛盾,如同时在隐私政策内声明 我们不会收集您的个人信息我们将收集您的手机号用于xxx ,这种情况被作者认为是隐私政策内部矛盾。

Challenge

  • 对信息的引用以不同的语义层次表达

    • 先前工作的术语假定关系需要指定,缺乏全面性及可扩展性
    • 没有捕获推理隐私政策中提到的数据类型和实体所需的所有特定关系
  • 隐私政策包括负面分享和收集声明

    • 先前工作忽略负面声明
    • 粒度粗无法处理复杂的陈述

Insight

  1. 句子结构 informs 语义
    共享和收集语句通常遵循一组可学习的模板。PolicyLint 使用这些模板从这些语句中提取一个四元组:(参与者、动作、数据对象、实体)。
    例如,我们 [Actor] 与广告商 [entity] 共享 [action] 个人信息 [data object]。
    句子结构还可以更深入地了解更复杂的负面共享。
    例如,“我们与广告商共享您的电子邮件地址以外的个人信息。”
    PolicyLint 通过建立在现有的词性和依赖解析器之上,从隐私政策语句中提取此类语义。

  2. 隐私政策编码本体
    由于隐私政策的法律性质,一般术语通常根据示例或其组成部分来定义。虽然可能不会为政策中使用的所有术语定义语义关系,但这些关系应该存在于我们数据集中的其他一些政策中。通过处理大量隐私政策,PolicyLint 自动生成特定于政策的本体(一个用于数据对象,一个用于实体)。PolicyLint 使用赫斯特模式 [16] 提取术语定义,我们已经将其扩展到我们的领域。

Design

工具框架

  1. Ontology Generation
    目标:在隐私政策中定义术语之间的推定(“is-a”)关系,以允许对不同粒度的语言进行推理
    Example 1. We may share demographic information, such as your age and gender, with advertisers.
    PolicyLint 使用这样的句子来自动发现大量隐私策略中的推定关系。 它侧重于数据对象和接收数据的实体。
    PolicyLint 使用半自动化和数据驱动的技术来生成本体。它将本体生成分为三个主要部分。

NER domain adaption

PolicyLint 对现有的基于统计的命名实体识别 (NER) 模型进行 domain 适应。NER 用于标记句子中的数据对象和实体,不仅捕获术语,还捕获句子中的上下文。
为了识别数据对象及实体之间的假定关系,PL 需要识别词元代表的数据对象或实体。
例如上面的例子,[demographic information, age, gender] 会被识别为数据对象,[we, advertisers] 会被识别为实体。
NER(named-entity recognition):基于统计的一种技术,用于标记句子中的数据对象及实体。

Subsumptive Relationship Extraction

PolicyLint 通过使用一组 11(??在后续给出的表中只有九种,文章在这一节的开头似乎写错了) 种具有强制命名实体标签约束的词汇句法模式来学习标记数据对象和实体的假定关系。

PolicyLint 使用一组 9 种词汇句法模式来发现句子中的推定关系
九种句式
对每组 data object 和 entity。PolicyLint 通过对文本进行词形还原并用它们的同义词替换术语来规范化关系。

同义词识别

人工做的。按照 data object 出现的频率输出,频率最高的词,如果其他 data object 包含他,这些 data object 一律标注。然后结合领域知识,将同义不同词的单词也标记。

1
2
如,发现 location 这个词出现频率最高,那么其余任意包含 location 的词,输出,人工阅读,并将他们统一标记为 geographic location。
然后利用领域知识得出 latitude and longitude 之类的表述也被标记为 geographic location

Ontology Construction

PolicyLint 将一组种子词作为输入,并使用在上一步中发现的假定关系生成数据对象/实体本体。 它迭代地将关系添加到本体,直到达到一个固定点。

一组 seed,找 seed 相关的 relationships,在这些 relationships 中继续找是否有其他表述的 data object,继续找这些 data object 相关的 relationships,直到没有新的。
seed

  1. Policy Statement Extraction
    将一条 policy statement 表示为 (actor, action, data object, entitiy)
    1
    如,we will share your personal information with advertisers --> (we, share, personal information, advertisers)

DED(Data and Entity Dependency) Tree Construction

PolicyLint 合并名词短语并迭代句子标记以标记 SoC 动词。确保词元的词性(part-of-speech)标签是动词并且动词的引理在 PolicyLint 的手动管理的术语列表中
SoC Verbs
PolicyLint 然后提取句子的基于依存关系的解析树,其节点用数据对象、实体和 SoC 动词标签进行标记。
DED 树会删除与数据对象、实体或 SoC 动词无关的节点和路径,并执行一组简化以概括表示。

DED Tree

  • 负面表达 :PolicyLint 通过检查基于依赖的分析树中的否定修饰符来识别否定动词。如果动词被否定,PolicyLint 将节点标记为负面情绪。 PolicyLint 在三种情况下将负面情绪传播到后代动词节点。
    • 如果后代动词是带有否定动词的连词动词短语的一部分,则会传播负面情绪。 “We do not sell, rent, or trade your personal information,” means “not sell,” “not rent,” and “not trade.”
    • 如果后裔动词对否定动词有一个开放式从句补语,就会传播负面情绪。“We do not require you to disclose any personal information,” initially has “require” marked with negative sentiment. Since “disclose” is an open clausal complement to “require,” it is marked with negative sentiment.
    • 如果后代动词是否定动词的状语从句修饰语,则会传播负面情绪。 例如,“我们不会收集您的信息以与广告商分享”,最初的“收集”标记为负面情绪。 由于“share”是“collect”的状语从句修饰语,“share”被标记为负面情绪。
  • 例外表述 :含如 except、unless、aside、apart from、besides、without、not including 这类表例外情况的句子。
    • 对于每个已识别的异常子句,PolicyLint 从异常子句向下遍历分析树,以识别与该异常相关的动词短语(主语-动词-宾语)和名词短语。
    • PolicyLint 然后从异常项向上遍历以识别最近的动词节点,并将在向下遍历中识别的名词短语和动词短语列表附加为节点属性。
    • 在某些情况下,该术语可能没有子树。例如,例外项可能是引入从句的标记。在“我们不会共享您的个人信息,除非得到同意”这句话中,除非”一词是引入从句“您的同意已获得”的标记。 对于空子树,PolicyLint 尝试从其父节点向下遍历。

SoC(sharing or collection) Sentence Identification

PolicyLint 遍历每个句子。如果句子包含至少一个 SoC 动词和数据对象(由 NER 标记),则 构建 DED 树,将句子的 DED 树与每个已知模式的 DED 树进行比较。
(1)句子的 DED 树的标签类型等同于已知模式的 DED 树的标签类型(例如,{entity, SoC_verb, data})
(2)已知模式的 DED 树是句子的 DED 树的子树

【技术】【adb】python 脚本在 cmd 控制 adb

由于最近需要写脚本,控制 cmd 执行 adb 指令,但是 Android 环境是 magisk root 的 Android 10,所以直接在 cmd 无法用 adb 执行需要 root 权限的指令(adbd cannot run as root in production builds),试了改 ro.debuggable 和 ro.secure 都不行,于是找到了如下解决方案。实现 __python 脚本在 cmd 执行 adb 那些需要 root 的指令的方法__。

法1 脚本在 cmd 执行 adb 普通指令

如,直接脚本运行 adb shell logcat | grep xxx 会报错 grep,可以借助

1
2
3
import os
adbCmd = 'adb shell "logcat | grep xxx"'
os.popen(adbCmd)

法2 脚本在 cmd 执行 adb root 指令

上述有些指令需要 adb root,但是 adb shell 只能在进入后依靠 su 进入 root,要靠如下指令实现

1
2
3
import os
adbCmd = 'adb shell "su -c am start -n xxx.xx/.xActivity"'
os.popen(adbCmd)

法3 脚本在 cmd 执行多行 adb 指令/进入 adb shell

有时执行一条指令不够

法3.1

通过 && 连接多个 cmd 语句,从而达到执行多行 adb 语句

1
adbCmd = 'adb shell "logcat | grep xxx" && adb shell "su -c am start -n xxx/.xActivity"'

法3.2

靠脚本控制 cmd 进入 adb shell,体验感与直接在 cmd 进入 adb shell 最相似的方案

1
2
3
4
5
6
7
import subprocess
handler = subprocess.Popen("adb shell", stdout = subprocess.PIPE, stdin = subprocess.PIPE)
handler.stdin.write(b"su\n")
handler.stdin.write(b"am start xxx.xx/xActivity")
hanlder.stdin.close()
for output_line in handler.stdout.readlines():
print(output_line)

【技术】【小程序】wxUnpacker 工具“SyntaxError: Illegal return statement” 错误

前言

之前提到的微信小程序解包工具,在部分小程序上无法正常反编译,表现在输出结果上为得不到页面 wxml 及 wxss 文件。

Error 解决

描述

脚本解包过程中断,控制台输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vm.js:519

return function(env,dd,global){$gwxc=0;var root={"tag":"wx-page"};root.children=[]

^^^^^^

SyntaxError: Illegal return statement
at VMScript.compile (/home/wq57885/wxappUnpacker/node_modules/vm2/lib/main.js:80:20)
at VM.run (/home/wq57885/wxappUnpacker/node_modules/vm2/lib/main.js:215:10)
at z (/home/wq57885/wxappUnpacker/wuWxml.js:366:7)
at z (/home/wq57885/wxappUnpacker/wuRestoreZ.js:244:17)
at catchZGroup (/home/wq57885/wxappUnpacker/wuRestoreZ.js:15:2)
at catchZ (/home/wq57885/wxappUnpacker/wuRestoreZ.js:19:29)
at getZ (/home/wq57885/wxappUnpacker/wuRestoreZ.js:244:2)
at wu.get.code (/home/wq57885/wxappUnpacker/wuWxml.js:354:3)
at ioLimit.runWithCb (/home/wq57885/wxappUnpacker/wuLib.js:80:8)
at agent (/home/wq57885/wxappUnpacker/wuLib.js:54:14)

解决方案

总的来说这个问题是由于小程序包结构随版本更新有变化,导致前一位大佬的 wxUnpacker 没法正确解析 wxml 及 wxss 文件,为此 19 年另一位大佬提供了解决工具larack8

原因分析

拜读了大佬解决问题的逻辑,主要如下

原工具解析思路

所有在 wxapkg 包中的 html 文件都调用了setCssToHead函数,其代码如下

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
var setCssToHead = function(file, _xcInvalid) {
var Ca = {};
var _C = [...arrays...];
function makeup(file, suffix) {
var _n = typeof file === "number";
if (_n && Ca.hasOwnProperty(file)) return "";
if (_n) Ca[file] = 1;
var ex = _n ? _C[file] : file;
var res = "";
for (var i = ex.length - 1; i >= 0; i--) {
var content = ex[i];
if (typeof content === "object") {
var op = content[0];
if (op == 0) res = transformRPX(content[1]) + "px" + res; else if (op == 1) res = suffix + res; else if (op == 2) res = makeup(content[1], suffix) + res;
} else res = content + res;
}
return res;
}
return function(suffix, opt) {
if (typeof suffix === "undefined") suffix = "";
if (opt && opt.allowIllegalSelector != undefined && _xcInvalid != undefined) {
if (opt.allowIllegalSelector) console.warn("For developer:" + _xcInvalid); else {
console.error(_xcInvalid + "This wxss file is ignored.");
return;
}
}
Ca = {};
css = makeup(file, suffix);
var style = document.createElement("style");
var head = document.head || document.getElementsByTagName("head")[0];
style.type = "text/css";
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}
head.appendChild(style);
};
};

阅读这段代码可知,它把 wxss 代码拆分成几段数组,数组中的内容可以是一段将要作为 css 文件的字符串,也可以是一个表示 这里要添加一个公共后缀 或 这里要包含另一段代码 或 要将以 wxss 专供的 rpx 单位表达的数字换算成能由浏览器渲染的 px 单位所对应的数字 的数组。

同时,它还将所有被@import引用的 wxss 文件所对应的数组内嵌在该函数中的 _C 变量中。

我们可以修改setCssToHead,然后执行所有的setCssToHead,第一遍先判断出 _C 变量中所有的内容是哪个要被引用的 wxss 提供的,第二遍还原所有的 wxss。值得注意的是,可能出于兼容性原因,微信为很多属性自动补上含有-webkit-开头的版本,另外几乎所有的 tag 都加上了wx-前缀,并将page变成了body。通过一些 CSS 的 AST ,例如 CSSTree,我们可以去掉这些东西。

wxs
在 page-frame.html ( 或 app-wxss.js ) 中,我们找到了这样的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
f_['a/comm.wxs'] = nv_require("p_a/comm.wxs");
function np_0(){var nv_module={nv_exports:{}};nv_module.nv_exports = ({nv_bar:nv_some_msg,});return nv_module.nv_exports;}

f_['b/comm.wxs'] = nv_require("p_b/comm.wxs");
function np_1(){var nv_module={nv_exports:{}};nv_module.nv_exports = ({nv_bar:nv_some_msg,});return nv_module.nv_exports;}

f_['b/index.wxml']={};
f_['b/index.wxml']['foo'] =nv_require("m_b/index.wxml:foo");
function np_2(){var nv_module={nv_exports:{}};var nv_some_msg = "hello world";nv_module.nv_exports = ({nv_msg:nv_some_msg,});return nv_module.nv_exports;}
f_['b/index.wxml']['some_comms'] =f_['b/comm.wxs'] || nv_require("p_b/comm.wxs");
f_['b/index.wxml']['some_comms']();
f_['b/index.wxml']['some_commsb'] =f_['a/comm.wxs'] || nv_require("p_a/comm.wxs");
f_['b/index.wxml']['some_commsb']();

可以看出微信将内嵌和外置的 wxs 都转译成np_%d函数,并由f_数组来描述他们。转译的主要变换是调用的函数名称都加上了nv_前缀。在不严谨的场合,我们可以直接通过文本替换去除这些前缀。

wxml
相比其他内容,这一段比较复杂,因为微信将原本 类 xml 格式的 wxml 文件直接编译成了 js 代码放入 page-frame.html ( 或 app-wxss.js ) 中,之后通过调用这些代码来构造 virtual-dom,进而渲染网页。 首先,微信将所有要动态计算的变量放在了一个由函数构造的z数组中,构造部分代码如下:

1
2
3
4
(function(z){var a=11;function Z(ops){z.push(ops)}
Z([3,'index']);
Z([[8],'text',[[4],[[5],[[5],[[5],[1,1]],[1,2]],[1,3]]]]);
})(z);

其实可以将[[id],xxx,yyy]看作由指令与操作数的组合。注意每个这样的数组作为指令所产生的结果会作为外层数组中的操作数,这样可以构成一个树形结构。通过将递归计算的过程改成拼接源代码字符串的过程,我们可以还原出每个数组所对应的实际内容(值得注意的是,由于微信的Token解析程序采用了贪心算法,我们必须将连续的}翻译为} }而非}},否则会被误认为是Mustache的结束符)。下文中,将这个数组中记为z。

然后,对于 wxml 文件的结构,可以将每种可能的 js 语句拆分成 指令 来分析,这里可以用到 Esprima 这样的 js 的 AST 来简化识别操作,可以很容易分析出以下内容,例如:

  • var {name}=_n(‘{tag}’) 创建名称为{name}, tag 为{tag}的节点。
  • _r({name},’{attrName}’,{id},e,s,gg) 将{name}的{attrName}属性修改为z[{id}]的值。
  • _({parName},{name}) 将{name}作为{parName}的子节点。
  • var {name}=_o({id},..,..,..) 创建名称为{name},内容为z[{id}]的文本节点。
  • var {name}=_v() 创建名称为{name}的虚节点( wxml 里恰好提供了功能相当的虚结点block, 这句话相当于var {name}=_n(‘block’))。
  • var {name}=_m(‘{tag}’,[‘{attrName1}’,{id1},’{attrName2}’,{id2},…],[],..,..,..) 创建名称为{name}, tag 为{tag}的节点,同时将{attrNameX}属性修改为z[f({idX})]的值(f定义为{idX}与{base}的和;{base}初始为0,f返回的第一个正值后{base}即改为该返回值;若返回负值,表示该属性无值)。
  • return {name} 名称为{name}的节点设为主节点。
  • cs.*** 调试用语句,无视之。

此外wx:if结构和wx:for可做递归处理。例如,对于如下wx:if结构:

1
2
3
4
5
6
7
8
9
10
11
var {name}=_v()
_({parName},{name})
if(_o({id1},e,s,gg)){oD.wxVkey=1
//content1
}
else if(_o({id2},e,s,gg)){oD.wxVkey=2
//content2
}
else{oD.wxVkey=3
//content3
}

相当于将以下节点放入{parName}节点下(z[{id1}]应替换为对应的z数组中的值):

1
2
3
4
5
6
7
8
9
<block wx:if="z[{id1}]">
<!--content1-->
</block>
<block wx:elif="z[{id2}]">
<!--content2-->
</block>
<block wx:else>
<!--content3-->
</block>

具体实现中可以将递归时创建好多个block,调用子函数时指明将放入{name}下(_({name},{son}))识别为放入对应{block}下。wx:for也可类似处理,例如:

1
具体实现中可以将递归时创建好多个block,调用子函数时指明将放入{name}下(_({name},{son}))识别为放入对应{block}下。wx:for也可类似处理,例如:

对应(z[{id1}]应替换为对应的z数组中的值):

1
2
3
<view wx:for="{z[{id}]}" wx:for-item="{item}" wx:for-index="{index}" wx:key="{key}">
<!--content-->
</view>

调用子函数时指明将放入{fakeRoot}下(_({fakeRoot},{son}))识别为放入{name}下。除此之外,有时我们还要将一组代码标记为一个指令,例如下面:

1
2
3
4
5
6
7
8
9
10
11
12
var lK=_v()
_({parName},lK)
var aL=_o({isId},e,s,gg)
var tM=_gd(x[0],aL,e_,d_)
if(tM){
var eN=_1({dataId},e,s,gg) || {}
var cur_globalf=gg.f
lK.wxXCkey=3
tM(eN,eN,lK,gg)
gg.f=cur_globalf
}
else _w(aL,x[0],11,26)

对应于{parName}下添加如下节点:

1
<template is="z[{isId}]" data="z[{dataId}]"></template>

还有import和include的代码比较分散,但其实只要抓住重点的一句话就可以了,例如:

1
2
3
4
5
var {name}=e_[x[{to}]].i
//Other code
_ai({name},x[{from}],e_,x[{to}],..,..)
//Other code
{name}.pop()

对应与(其中的x是直接定义在 page-frame.html ( 或 app-wxss.js ) 中的字符串数组):

1
<import src="x[{from}]" />

而include类似:

1
2
3
4
5
var {name}=e_[x[0]].j
//Other code
_ic(x[{from}],e_,x[{to}],..,..,..,..);
//Other code
{name}.pop()

对应与:

1
<include src="x[{from}]" />

可以看到我们可以在处理时忽略前后两句话,把中间的_ic和_ai处理好就行了。通过解析 js 把 wxml 大概结构还原后,可能相比编译前的 wxml 显得臃肿,可以考虑自动简化,例如:

1
2
3
4
5
<block wx:if="xxx">
<view>
<!--content-->
</view>
</block>

可简化为:

1
2
3
<view wx:if="xxx">
<!--content-->
</view>

更新后

wcc-v0.5vv_20180626_syb_zp后通过只加载z数组中需要的部分来提高小程序运行速度,这也会导致仅考虑到上述内容的解包程序解包失败,这一更新的主要内容如下:

  • 增加z数组的函数:_rz _2z _mz _1z _oz
  • 在每个函数头部增加了var z=gz$gwx_{$id}(),来标识使用的z数组id
  • 原有的z数组不再存在
  • z数组已以下固定格式出现:
1
2
3
4
5
6
7
8
9
function gz$gwx_{$id}(){
if( __WXML_GLOBAL__.ops_cached.$gwx_{$id})return __WXML_GLOBAL__.ops_cached.$gwx_{$id}
__WXML_GLOBAL__.ops_cached.$gwx_{$id}=[];
(function(z){var a=11;function Z(ops){z.push(ops)}

//... (Z({$content}))

})(__WXML_GLOBAL__.ops_cached.$gwx_{$id});return __WXML_GLOBAL__.ops_cached.$gwx_{$id}
}

对于上述变更,将获取z数组处修改并添加对_rz _2z _mz _1z _oz的支持即可。需要注意的是开发版的z数组转为如下结构:

1
2
3
(function(z){var a=11;function Z(ops,debugLine){z.push(['11182016',ops,debugLine])}
//...
})//...

探测到为开发版后应将获取到的z数组仅保留数组中的第二项。以及含分包的子包采用 gz$gwx{$subPackageId}_{$id} 命名,其中{$subPackageId}是一个数字。另外还需要注意,template的 var z=gz$gwx_{$id} 在try块外。

其他

其余一些博客提到利用 larack8 大佬的工具解决问题后,还存在找不到 node 模块的问题,他们给的解决方案相同,

1
2
3
npm install -g n
n latest
npm update -g

然后再把所有需要的包 install 一遍。

这边用这个方法并没有解决问题,其次私以为该错误的根本问题还是 node 环境本身,问题在nodejs怎么查找模块上。

  • 首先,要知道 npm 全局安装到底把模块安装到了哪个目录下面。在终端运行npm prefix -g命令会打印出安装路径。而nodejs查找模块是在module.paths目录列表下面查找的。
  • 所以,一种解决方案是在程序中将npm全局安装路径添加到module.paths中。module.paths.push(‘全局安装路径’)。然后再测试可行。这种方案只对当前js有效。
  • 另一种是添加环境变量NODE_PATH,值就设置成全局安装路径。如图中所示,添加后测试可行。
  • 其实,添加环境变量NODE_PATH后,我们再去查看module.paths时会发现环境变量中的路径也已经在module.paths中了。所以,最方便的解决办法就是:npm prefix -g 找到全局安装的路径,然后添加到环境变量NODE_PATH中。

解决 require 问题之后再统一安装 wxUnpacker-larack8 ver 需要的模块即可

1
2
3
4
5
6
7
8
9
npm install uglify-es --save
npm install esprima --save
npm install css-tree --save
npm install cssbeautify --save
npm install vm2 --save
npm install uglify-es --save
npm install js-beautify --save
npm install escodegen --save
npm install cheerio --save

参考资料
https://github.com/larack8/wxappUnpacker
https://blog.csdn.net/wq57885/article/details/101113017
https://jingyan.baidu.com/article/2d5afd6937ad7785a2e28e98.html

【技术】【小程序】Apinat 工具原理解析

前言

最近需要研究小程序 API 与调用到的 Android API 映射的问题,因此研究文章CCS20 Haoran LuApinat 工具原理,并在微信 7.0.20 版本上实现。

Apinat

目标

在小程序中调用小程序 API,给出为实现该小程序 API,Java 层实现所用到的 Android API。作者开发本工具的目的是为了找到没有被微信小程序权限保护(scope.xxx)但是底层实现的 Android API被 AOSP dangerous permission 保护的那些小程序 API,这些小程序 API 被称为 Encapsulated API。详见前文paper summary CCS20

实现

背景知识

Dispatch function(wx api)线程不直接调用 Android API,而是触发新的线程来调用 Android API,这么实现可能是出于不阻塞 dispatch 线程的考虑。
这就导致 dispatch 在 Thread1,实际执行的 Android APIs 在 Thread2。

问题

因此这就需要解决一个问题,就是处理多线程环境,将相关 threads 联系起来。

解决思路

通常 dispatch thread 会使用 Handler 机制(微信是这样,其他小程序平台如支付宝可能用的其他机制)来触发多线程。因此只需要基于 Handler 机制的推送及处理消息的原理就可以将线程联系起来。

  • Triggering Thread:有 handler 实例,通过调用 handler.post(runnable) 来向消息队列提交一个 runnable
  • Handler thread:将 runnable 从队列拿出,并执行
  • 如果 handler thread 中处理的 runnable 与 triggering thread 添加到队列的 runnable 相同,则可以将两个线程连接。

实现

本实现基于前面提到的 Apinat 提供的参考代码有修改,因为作者给出的代码没有指明在何版本微信上运行,因此笔者在参考工具框架基础上,结合微信 7.0.20 版本的小程序 API 调用的 Handler 实现而修改开发了适用 7.0.20 版本微信的 Apinat 工具,后续 8.x.x 版本应该也可用,只要 Handler 实现原理不变就适用。

  1. 工具代码中 hook 的 apk 版本未给出,因此需要根据实际测试的微信 apk 版本修改。具体修改项:Xp_demo.java 中的 “com.tencent.mm.plugin.appbrand.jsapi.d” 类中被 hook 的是 “y” 方法,改方法是逆向对应的 7.0.20 中小程序 API invokeHandler 处理后必调方法。(原本这里是“n”,对应的是作者分析的未知版本的微信 apk)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    XposedHelpers.findAndHookMethod(
    //7.0.20 d 类的 y 方法
    "com.tencent.mm.plugin.appbrand.jsapi.d", lpparam.classLoader, "y", String.class, String.class, int.class,// 被Hook函数的名称
    new XC_MethodHook() {
    @Override
    protected void afterHookedMethod(MethodHookParam param)
    throws Throwable {
    String tag = "[Dump Stack]";
    String msg = "=====miniAppApi" + String.valueOf(param.args[0]) + "*******";
    printLog(tag,msg);

    }
    });
  2. hook 到小程序 thread 的 handler post
    原代码是 hook android.os.Handler.getPostMessage,可能是微信 apk 版本不同,7.0.20 中是通过 handler.post(runnable),因此 hook 作者给出的 getPostMessage 拿不到任何调用信息,hook handler.post 方法有信息,并且检查调用栈可以找到上述 jsapi.d.a 方法。通过 hook 并进行调用栈筛选可以拿到 __实现小程序 API 的 runnable 实例__。

  3. hook handler 的消息处理函数
    作者 hook 的是 handleCallback,笔者认为应该是基于 handler.dispatchMessage 中下列代码的考虑

    1
    2
    3
    4
    // dispatchMessage 内部
    if(message.callback){
    handleCallback(message);
    }

    因此作者的 hook 代码为

    1
    2
    3
    // 作者 hook 并做了 message.what != 0 的过滤
    // hook android.os.Handler.handleCallback
    if(message.what == 0) return;

    但是这里 hook 不到任何东西,因为这里想 hook 的消息处理逻辑跟前面的 getPostMessage 是对应的,因为在那种情况下 message.what 是对应的处理线程 id,但是如果是用 handler.post 的话会直接回调运行 runnable.run 代码,因此 message.what 不是 threadId,而是 0(调度线程id),所以即使 hook 到也会被第二行 if(message.what == 0) 判断过滤掉,所以笔者修改 hook 函数为为 android.os.Handler.dispatchMessage,修改筛选条件为

    1
    2
    3
    4
    // hook android.os.Handler.dispatchMessage
    if(message.callback == null){
    return
    }
  4. hook Android API
    这个就不多说了,正常 hook 系统 API 并打印

  5. Log 处理
    Hook 到的以上信息都打印在 log 中,还需要后续追加对 log 处理,但是逻辑很简单,就是提取上述四个信息,构建(小程序 API thread —- runnable 实例 name —- 处理含 runnable 实例的 Handler thread —- 该 Handler Thread 的 Android API 调用),即可得到 __小程序 API —- Android API Mapping__。

完整代码

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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
package com.example.a91377.xpdemo;

import android.app.Application;
import android.content.Context;
import android.location.Location;
import android.location.LocationManager;
import android.net.wifi.WifiManager;
import android.os.Message;
import android.telephony.TelephonyManager;
import android.util.Log;

import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XC_MethodReplacement;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;

import static de.robv.android.xposed.XposedHelpers.findClass;

public class Xp_Demo implements IXposedHookLoadPackage { //1.实现接口

@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable { //1.实现接口方法
//com.example.a91377.myapplication;
//com.tencent.mm
if (lpparam.packageName.equals("com.tencent.mm")) //进入其他应用的进程 -参数:包名
{
try{
XposedHelpers.findAndHookMethod(Application.class,
"attach",
Context.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
Context context = (Context)param.args[0];
ClassLoader classLoader = context.getClassLoader();
HookLocation(classLoader);
}
});
}catch (Throwable e){
XposedBridge.log(e);
}



}
}

private static void HookLocation(ClassLoader classLoader) throws ClassNotFoundException{
Class<?> runnable = findClass("java.lang.Runnable", classLoader);
// hook android.os.Handler.post
XposedHelpers.findAndHookMethod(
"android.os.Handler", classLoader, "post", runnable,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param)
throws Throwable {
boolean flag2 = false;
long threadId = 00000;
for (Map.Entry<Thread, StackTraceElement[]> stackTrace : Thread.getAllStackTraces().entrySet()) {
Thread thread = (Thread) stackTrace.getKey();
StackTraceElement[] stack = (StackTraceElement[]) stackTrace.getValue();

// 进行过滤
if (!thread.equals(Thread.currentThread())) {
continue;
}

for (StackTraceElement stackTraceElement : stack) {
if (stackTraceElement.getClassName().equals("com.tencent.mm.plugin.appbrand.jsapi.d") && stackTraceElement.getMethodName().equals("a")) {
flag2 = true;
break;
}
}
if (!flag2) {
break;
}
threadId = thread.getId();
Log.i("hookgetPostMessage", String.valueOf(param.args[0]) + "********" + thread.getName() + "-------" + thread.getId());

}
if (flag2) {
Message m = (Message) param.getResult();
m.what = (int) threadId;
param.setResult(m);

}
}
});

// hook android.os.Handler.dispatchMessage
Class<?> message = findClass("android.os.Message", classLoader);
XposedHelpers.findAndHookMethod(
"android.os.Handler", classLoader, "dispatchMessage", message,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param)
throws Throwable {
Message m = (Message) param.args[0];
Runnable r = m.getCallback();
int relatedThreadId = m.what;
if(r == null){
return;
}
String tag = "=========handleCallback";
String msg = "runnable: " + String.valueOf(r) + "********" + "relatedThreadid: " + String.valueOf(relatedThreadId) + "---" + "currentId: ";
printLog(tag,msg);

}
});

// hook invokeHandler 打印出线程ID和第一个参数, 即小程序的api
XposedHelpers.findAndHookMethod(
//7.0.20 d 类的 y 方法
"com.tencent.mm.plugin.appbrand.jsapi.d", classLoader, "y", String.class, String.class, int.class, // 被Hook函数的名称
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param)
throws Throwable {
String tag = "[Dump Stack]";
String msg = "=====miniAppApi" + String.valueOf(param.args[0]) + "*******";
printLog(tag,msg);

}
});


//hook dangerous Android APi
String methodList[][] = com.example.a91377.xpdemo.Method.methodList;
for (int i = 0; i < methodList.length; i++) {
Class<?> clazz = findClass(methodList[i][0], classLoader);
for (Method method : clazz.getDeclaredMethods()) {
final String methodName = method.getName();
if (!methodName.equals(methodList[i][1])) {
continue;
}
if (!Modifier.isAbstract(method.getModifiers())) {

XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
String tag = "[hookWifi]";
String msg = "****" + methodName + "threadId";
printLog(tag,msg);

}
});
}
}
}
}

public static void printLog(String tag, String msg) {
for (Map.Entry<Thread, StackTraceElement[]> stackTrace : Thread.getAllStackTraces().entrySet()) {
Thread thread = (Thread) stackTrace.getKey();
StackTraceElement[] stack = (StackTraceElement[]) stackTrace.getValue();
// 进行过滤
if (!thread.equals(Thread.currentThread())) {
continue;
}
String threadName = thread.getName();
long threadId = thread.getId();
Log.d(tag, msg + threadId);

}
}

// IXposedHookLoadPackage.java
// 1. handleLoadPackage, 这个方法用于在加载应用程序包的时候执行用户操作
// 2. final LoadPackageParam lpparam 这个参数包含了加载应用程序的基本信息


// XposedHelpers.java
//findAndHookMethod 是一个辅助方法,可以静态导入使用
//参数: 1. 需要hook住的类名 2. 需要hook住的方法名 3.回调函数,参数集包含了(1. hook的目标方法的参数, 2 回调方法)


//XposedBridge.java
// 1. 无参方法:log 该方法可以将log信息以及Throwable 抛出的异常信息输出到标准的logcat以及/data/Xposed/debug.log这个文件中
// 2. 无参方法 hookAllMethods/hookAllConstructors 该方法可以用来hook住某个类中所有方法或者构造函数
}

【技术】【小程序】MiniScrawler 工具原理解析

之前用到了文章A Measurement Study of Wechat Mini-Apps提出的微信小程序半自动化收集工具MiniCrawler。最近深入看一下工具实现细节。

工具介绍

工具有两个功能,一是小程序元数据收集,另一个是小程序包下载。

小程序元数据收集

具体原理不难,就是拿到用户的请求参数,然后替换 request 中的 keyword 字段,批量发起请求。

  • 用户手动在小程序搜索页面输入关键词,工具会帮助拿到用户向服务七搜索请求的一些参数,主要是认证参数
  • 用户把这些参数和待搜索词列表放在指定位置
  • 工具即可用这些拿到的参数批量发起小程序搜索请求,拿到服务器返回的多个小程序的元数据(小程序名、小程序 AppId 等)

小程序包下载

要求

  1. 微信 App 得是 7.0.20 版本
  2. 编译并安装 XposedPlugin
  3. 打开任意小程序页面
  4. 运行 adb shell am broadcast -a android.intent.myper --es appid "{待下载小程序的appid}",工具会自动下载链接及相关信息到 /sdcard/apps.txt

逆向 XposedPlugin

看了 XposedPlugin 源码,注册了个 android.intent.myper 的 receiver 用来接收待下载小程序的 appid。

Q:具体如何起到启动目标小程序的作用?
A:com.example.vsa.xposedutility.tests.WechatMiniAppsDownloader 中,工具接了 appid 之后 invoke 了小程序 API(代码如下)。通过调起 wx.navigateToMiniProgram,appid 作为参数传进去,达到从当前小程序(任意的,但需要是小程序的 context)跳转到目标小程序的目的。

1
2
Method method = next.getClass().getMethod("invokeHandler", String.class, String.class, Integer.TYPE);
method.invoke(next, "navigateToMiniProgram", "{\"appId\":\"" + stringExtra + "\",\"extraData\":\"\",\"envVersion\":\"release\",\"scene\":1037,\"sceneNote\":\"\"}", 9999);

Q:如何拿到下载链接及包的相关信息?
A:com.example.vsa.xposedutility.tests.Wechat7020 类中 hookAll 中可以看出(如下图),关注的是微信 App 里的两个类:com.tencent.mm.plugin.appbrand.appcache.bacom.tencent.mm.plugin.appbrand.jsapi.l

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void beforeHookedMethod(XC_MethodHook.MethodHookParam methodHookParam) throws Throwable {
Log.d(Wechat7020.TAG, "---->>>:" + methodHookParam.method.getName());
Utilities.printParameter(Wechat7020.TAG, methodHookParam);
if (methodHookParam.method.getName().equals("invokeHandler") && !Wechat7020.did) {
Wechat7020.did = true;
methodHookParam.thisObject.getClass().getMethod("invokeHandler", String.class, String.class, Integer.TYPE);
}
if (methodHookParam.method.getDeclaringClass().getName().equals("com.tencent.mm.plugin.appbrand.appcache.ba") && (methodHookParam.method instanceof Constructor) && ((Constructor) methodHookParam.method).getParameterTypes().length == 4) {
Utilities.writeToFile("/sdcard/apps.txt", (methodHookParam.args[0] + BuildConfig.FLAVOR) + " " + (methodHookParam.args[2] + BuildConfig.FLAVOR) + " " + (methodHookParam.args[3] + BuildConfig.FLAVOR) + "\n");
}
if (methodHookParam.method.getDeclaringClass().getName().equals("com.tencent.mm.plugin.appbrand.jsapi.l") && !WechatMiniAppsDownloader.wvs.contains(methodHookParam.thisObject)) {
WechatMiniAppsDownloader.wvs.add(methodHookParam.thisObject);
}
}

逆向微信

com.tencent.mm.plugin.appbrand.appcache.ba
根据 XposedPlugin 代码行为,应该在 hook 到该类之后写数据(包下载 url)到 /sdcard/apps.txt,但实测后没有该文件。只有在初始,需要下载微信小程序 jssdk 包的时候会调用一下,但下载普通小程序根本不走这。
自己又静态分析了一阵,找到了 com.tencent.mm.plugin.appbrand.appcache.bt.a(str,...) 方法,参数 str 就是下载链接。不知道为什么在我这边是 .bt 类,而作者那边是 .ba 类,apk 版本是一样的。

com.tencent.mm.plugin.appbrand.jsapi.l
里面 invokeHandler 是调起小程序 api 的方法

1
public final String invokeHandler(String str, String str2, int i)

里面 str 就是 invoke 的小程序方法名,本场景下是 navigateToMiniProgram,str2 是传给这个小程序方法的参数,本场景下是 JSON,包括 appId 等信息。
函数体内有一句String y = dVar.y(str, str2, i);,这里 trace 到后面是一个抽象类,该抽象类由多个子类实现。这些子类与 wx.{subAPI} 一一对应。navigateToMiniprogramcom.tencent.mm.plugin.appbrand.jsapi.miniprogram_navigator.g

【技术】【JS 静态分析】Javascript 分析工具 Esprima

之前分析 JS 用到的工具,用于生成 JS 的 AST,马克一下工具用法。

Esprima

官网:https://docs.esprima.org/en/stable/getting-started.html
体验网址:https://esprima.org/demo/parse.html#
参考资料:https://www.jianshu.com/p/47d9b2a365c5

基本用法

是一个用于对 JS 代码做词法或者语法分析的工具,只支持js,不支持 flow 或者 typescript 格式。语法格式:

1
2
esprima.parseScript(input, config, delegate)
esprima.parseModule(input, config, delegate)

input 代表原始 js 字符串;config 是如下的配置对象:
配置对象

语法树解析

  • 语法树的总体结构就两种
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    interface Program {
    type: 'Program';
    sourceType: 'script';
    body: StatementListItem[];
    }
    interface Program {
    type: 'Program';
    sourceType: 'module';
    body: ModuleItem[];
    }

    // 变量声明及执行语句
    type StatementListItem = Declaration | Statement;
    // 变量声明、执行语句、导入导出模块
    type ModuleItem = ImportDeclaration | ExportDeclaration | StatementListItem;

_Declaration

1
type Declaration = ClassDeclaration | FunctionDeclaration |  VariableDeclaration;

_Statement

1
2
3
4
5
6
7
type Statement = BlockStatement | BreakStatement | ContinueStatement |
DebuggerStatement | DoWhileStatement | EmptyStatement |
ExpressionStatement | ForStatement | ForInStatement |
ForOfStatement | FunctionDeclaration | IfStatement |
LabeledStatement | ReturnStatement | SwitchStatement |
ThrowStatement | TryStatement | VariableDeclaration |
WhileStatement | WithStatement;

_其中 ExpressionStatement

1
2
3
4
5
6
7
8
9
10
11
12
interface ExpressionStatement {
type: 'ExpressionStatement';
expression: Expression;
directive?: string;
}
// Expression 类型
type Expression = ThisExpression | Identifier | Literal |
ArrayExpression | ObjectExpression | FunctionExpression | ArrowFunctionExpression | ClassExpression |
TaggedTemplateExpression | MemberExpression | Super | MetaProperty |
NewExpression | CallExpression | UpdateExpression | AwaitExpression | UnaryExpression |
BinaryExpression | LogicalExpression | ConditionalExpression |
YieldExpression | AssignmentExpression | SequenceExpression;

【BUG 体质康复日记】尝试在 Android 10 安装 Xposed 踩的坑

由于需要在新测试机(Android 10)上使用 Xposed,记录一下踩坑心得(苦涩.jpg)

踩到的坑

Xposed 报错“下载 http://dl.xposed.info/repo/full.xml.gz 失败”

错误原因:用了 http 协议,应该是 https 协议
解决方法:改 apk 包,对应字段的 http 改成 https 就好(苦涩.jpg)

框架无响应

无报错,并且安装成功后,Xposed 无法正常使用,表现为操作无响应
原因应该是 Xposed 框架很久不更新了,无法兼容较新的系统版本。

Android 10 上 Xposed 的正确打开方式

最终为了快速解决问题上手干活用了 LSposed

原理

Android Zygote进程

Zygote 进程是 Android 系统第一个拥有 Java 运行环境的进程,由用户空间第一个进程 init 进程通过 init.rc 文件创建,从 init 进程(pid 为 1) fork 而来。所有 APP 进程都是由 Zygote fork 出来,Zygote 进程在启动过程将创建 Java ART 虚拟机,预加载一个 Java 进程需要的所有资源,子进程被创建后就可以直接使用这些资源。

Xposed 原理

Xposed 会替换 Zygote 进程达到控制所有 APP 进程的目的。
Xposed 通过修改 ART/Dalvik 虚拟机,注册需要 hook 的函数为 Native 函数,这样执行到相应函数时,虚拟机优先执行 Narive 函数,然后执行 Java 函数。

基于 Dalvik 的 Hook

将被 Hook 方法修改为一个 JNI 方法,然后绑定一个 Xposed 自定义处理方法逻辑的函数上。

被 Hook 方法调用过程

  • Dalvik 将会进行代码的解释执行,Java 方法进入 Dalvik 虚拟机中会被转化为一个 Method 对象
  • 虚拟机判断这个方法如果是一个 JNI 方法,就会直接调用它绑定的的 nativeFunc 函数
  • 来到 Xposed 处理 Hook 的函数中,这个函数将这个被 Hook 方法的参数进行转发,让 Xposed 模块提供的处理 Hook 的回调方法来接管原来的逻辑,获得新的返回值返回给被 Hook 方法,即可完成整个 Hook 操作

基于 ART 的 Hook

需要重新修改编译 ART 虚拟机的源码,重新编译出 ART 虚拟机的可执行文件 libart.so,替换 Android 系统中的 ART 虚拟机。核心原理就是直接修改一个方法对应的汇编代码的地址,让方法直接跳转到指定地址执行,然后就可以执行自定义的逻辑进行 Hook 处理。

被 Hook 方法调用过程

  • ART 对方法代码进行执行,首先这个 Java 方法在 ART 虚拟机中将使用一个 ArtMethod 对象表示
  • 进入 ART 的 Java 方法执行函数中,会跳入一段蹦床代码中进行执行,这段蹦床代码又会跳入这个 ArtMethod 对象设置的汇编代码地址处
  • 进而执行到 Xposed 用于处理 Hook 的代码中,之后完成 Hook 逻辑

参考资料
https://www.jianshu.com/p/6b4a80654d4e
https://blog.csdn.net/weixin_47883636/article/details/109018440

【paper summary】Demystifying Resource Management Risks in Emerging Mobile App-in-App Ecosystems

梳理小程序相关工作中碰到的比较有价值的一篇文章,整理并总结一下。

概要

文章信息

文章内容

  1. 研究什么?
    作者分析小程序框架安全,提出三类小程序生态系统中,由于资源管理不完善引起的三类漏洞。对三类漏洞进行阐述、攻击验证、提出检测方法及缓解措施

  2. 作者贡献?

  • 提出现有 app-in-app 生态系统中资源管理方式存在的安全性问题。提出三种攻击方式,并简单验证。个人认为三种攻击影响确实蛮大的,后面具体介绍。
  • 对自己发现的问题进行一个 measurement study,其中借助了自己开发的自动化检测工具,叫 apinat。个人认为这个工具贡献技术上贡献不太强,主要为检测两类漏洞辅助。(为什么是检测两类漏洞,不是检测三类漏洞,因为第三个漏洞确实不需要特别检测,后续具体介绍。
  • 总结一些经验教训与问题相应的缓解措施.

三类漏洞

敏感信息泄露

描述

本质就是不一致问题。
小程序宿主 APP 对小程序 API 有权限管理,系统对系统 API 有权限管理,同时小程序 API 本质还是在 APP Native 层开发,必然涉及到一些系统 API。
因此小程序 API 实际由一批系统 API 实现,因此可以通过这些系统 API 映射到系统权限,即可以通过映射获取小程序 API 对应哪些系统权限,同时平台又对小程序开发者要求小程序 API 应当与何权限映射,此时存在两种映射关系,这两种映射的不一致将导致敏感资源泄露的问题。
如果一个小程序 API 没有被小程序平台严格约束,但底层实现的代码调用了涉及敏感资源的 API,则该小程序 API 将造成敏感资源泄露。作者将之命名为 Encapsulated API。

检测方法

  1. 关注没有被小程序平台列为涉及敏感资源的小程序 API
  2. 构造(小程序 API - 系统 APIs - 系统权限)这样一种映射
  3. 若某 API 映射的系统权限敏感,则检测到

Q:作者如何构造这样一种映射?
A:

  1. 使用 funfuzz 工具 invoke 几乎每一个小程序 API,如 invoke 失败则手动构造 input。由此得到小程序 API - 系统 APIs 的映射
  2. 由于作者认为,现有的系统 API 到系统权限的映射不够精确,因此作者选择人工构造 系统 API - 系统权限的映射
  3. 由此得到完整的(小程序 API - 系统 APIs - 系统权限)映射

窗口欺骗

描述

类似钓鱼。针对 web app 这类平台(safari、chrome 等),存在伪造登录页面的机会;针对微信、支付宝这类平台,伪造小程序付款页面。

检测方法

  1. 仍然借助 funfuzz 将每个小程序 API invoke 一遍
  2. 对比调用 API 前后的截图内容,识别是否存在敏感字段,从而判断是否存在窗口欺骗的潜在风险。

Q:敏感字段哪里来?如何定义?
A:参考前人工作 NDSS18 提供的敏感字段列表

小程序伪装

描述

仅针对安卓系统,因为 iOS 系统对小程序的页面管理略不同。具体来说,iOS 系统不会在“最近使用”页面显示单个小程序,而安卓系统会将小程序与 APP 并列显示,这就为攻击者提供伪造小程序的机会。
作者观察到一个事实,每个小程序平台 APP 都有一个magic number,用于管理显示小程序数量。一旦打开的小程序数量超过这个数字,APP 将在后台静默关闭第一个打开的小程序。由于用户并不知道这样一个事实,因此在 APP 静默关闭第一个小程序后,攻击者可以通过伪造 UI 装作是第一个小程序仍然打开,欺骗用户进行敏感操作。

检测方法

由于 magic number 及每个 APP 会静默关闭第一个小程序这个事实确然存在,因此无需检测,这些平台 APP 都存在被攻击者使用 UI 欺骗窃取用户敏感信息的风险。

评价

这篇文章第一个研究小程序框架安全,研究对象涵盖海内外各大 Super APP。作者验证了三类漏洞确实存在,且对小程序用户影响深远,研究意义重大。
在技术方面,作者没有突出的贡献,但是设计的 Apinat 能够检测上述漏洞,且作者开源了一些对后续工作有帮助的资源或代码参考,还是为小程序安全研究做了很大贡献的。