新生Lab-1 逆向分析+程序分析基础

安卓基础知识

安卓系统架构

Android 是一个由 Google 主导开发的 基于 Linux 内核的开源操作系统,最初是为移动设备(如智能手机和平板电脑)设计的,现在也被广泛用于 智能电视、可穿戴设备、汽车系统(Android Auto)、IoT设备等

Android系统架构共分四层,从下往上分别是:Linux内核层系统运行库层(程序库+android运行库)、应用程序框架层应用层

Llnux内核层: Android 的 Linux 内核层是整个系统的基础,负责管理底层硬件资源和系统服务,包括进程管理、内存管理、电源管理、网络通信和文件系统等,同时还集成了 Android 特有的模块如 Binder(用于进程间通信)、Ashmem(匿名共享内存)和 Wakelocks(电源控制),为上层应用提供一个稳定、高效且安全的运行环境。

系统运行库层:Android 的系统运行库层包括两部分:原生程序库(Native Libraries)Android 运行时(ART)。原生库由 C/C++ 编写,提供底层功能支持,如图形渲染(OpenGL ES)、数据库(SQLite)、音视频处理(Media)、网页渲染(WebKit)等,是系统和应用正常运行的基础;而 Android 运行时用于执行应用的 Java/Kotlin 代码,自 Android 5.0 起使用 ART(替代 Dalvik 虚拟机,后面会单独介绍),采用 AOT 编译方式提升性能,并提供核心 Java 类库支持应用运行。两者共同为上层框架和应用提供关键的功能支撑与执行环境。

应用程序框架层:应用程序框架层是 Android 架构中连接系统功能与应用开发的桥梁,向开发者提供了丰富的 API 接口和核心组件,使应用可以调用系统服务并实现复杂功能。它封装了常用系统功能模块,如 Activity 管理(ActivityManager)、窗口管理(WindowManager)、包管理(PackageManager)、内容提供(ContentProvider)、资源管理(ResourceManager)等,开发者通过调用这些框架类,无需关注底层实现即可构建功能完整的应用,实际上就是一些安卓api。这一层确保了应用的统一行为和系统的可管理性。

应用层:应用层是 Android 架构中最上层的一层,包含所有系统预装应用(如电话、短信、设置)和用户安装的第三方应用(如微信、抖音等)。这些应用通过调用下层的应用程序框架接口与系统交互,完成界面展示、数据处理和用户操作响应。每个应用都运行在独立的进程中,拥有独立的虚拟机环境,打包为 APK 文件进行分发和安装。应用层是用户与 Android 系统交互的直接窗口,体现了 Android 系统的功能和用户体验。

Dalvik虚拟机与ART虚拟机

Dalvik虚拟机

Dalvik 虚拟机是 Android 系统早期用于运行应用程序的虚拟机,它将 Java 字节码转换为一种名为 .dex(Dalvik Executable) 的格式,以优化内存使用和多进程运行性能,Dalvik虚拟机每次应用运行的时候,将代码编译成机器语言执行。与标准的 Java 虚拟机(JVM)不同,Dalvik 采用的是 寄存器架构(而非 JVM 的栈架构),更适合在资源受限的移动设备上运行。此外,Dalvik 支持多个虚拟机实例同时运行,确保每个应用都在独立进程和虚拟机中运行,从而提升了系统的稳定性和安全性。自 Android 5.0 起,Dalvik 被性能更高的 ART(Android Runtime)所取代。

DVM与JVM有很多不同之处:

  • JVM运行的是Java字节码,DM运行的是 Dalvik字节码。传统java程序经过编译,生成java字节码保存在class文件中,java虚拟机通过解码class文件来运行程序,而 Dalvik虚拟机运行的是Dalvik字节码,所有的Dalvik字节码都由java字节码转换而来,并打包到一个dex可执行文件(.dex),Dalvik虚拟机通过解释DEX文件来执行字节码
  • DVM是基于寄存器的虚拟机 而JVM执行是基于虚拟栈的虚拟机。寄存器存取速度比栈快的多,dvm可以根据硬件实现最大的优化,比较适合移动设备。且对于相同的代码生成的字节码,Dalvik字节码也会比java字节码更简单,指令更少。
  • dvm执行的是.dex格式文件,jvm执行的是.class文件。.dex文件,是DVM独有的执行文件格式,体积更小,速度跟快,占用空间更少。
  • 运行环境的区别。Dalvik 经过优化,允许在有限的内存中同时运行多个虚拟机的实例,并且每一个Dalvik 应用作为一个独立的Linux 进程执行。独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。

ART虚拟机

就Dalvik虚拟机而言,我们每用手机运行一个程序,都会对应的产生一个互不影响的实例,这就导致可能会占用过多的资源,不够优雅,因此促成了ART虚拟机的诞生。

ART(Android Runtime)是 Android 5.0 及以上版本中替代 Dalvik 的应用运行环境,它的核心特点是采用 AOT(Ahead-Of-Time)编译,即在应用安装时就将字节码编译为本地机器码,从而提升运行效率、减少启动时间、降低电量消耗。与 Dalvik 的 JIT(即时编译)不同,ART 提供更好的性能和更低的内存开销。ART 还支持运行时优化、垃圾回收(GC)改进、调试和性能分析工具等功能,是现代 Android 应用高性能运行的关键组成部分。

虽然ART替换了Dalvik虚拟机,但这并不意味着应用程序开发上也要发生改变,像android应用程序安装包(apk)中,仍然还是可执行的.dex文件。DVM是在每当程序运行时,通过解释器来执行Davlik字节码,进而转化成快速运行的机器码;而ART是在程序安装时将字节码预先编译成机器码,这样运行程序就不用再次编译,因此运行速度也会快的多。作为性能优化的代价,相对而言ART安装程序耗时会长一点,由于预编译机器码也导致所占的存储空间也会更大一点,不过ART是支持64位并且兼容32位CPU的,而DVM只是为32位CPU设计的。

Android应用程序架构

安卓应用程序使用JAVA语言编写。安卓的SDK工具负责将你编写的代码,用到的数据和资源文件编译进APK文件中,APK:android package(应用程序安装包),包含了一个安卓应用程序的所有内容,并且被安卓设备用来安装应用程序。而apk实际上就是一个标准的zip格式,修改后缀名,进行解压就可以看到内部结构(类似于war或者jar,都是可以直接解压的):

  • assets文件夹: 保存一些额外的资源文件,如游戏的声音文件,字体文件等等,在代码中可以用AssetManager获取assets文件夹的资源。
  • lib文件夹: 存放用C/C++编写的,用NDK编译生成的so文件,供java端调用。
  • META-INF文件夹: 存放apk签名信息,用来保证apk包的完整性和系统的安全。在IDE编译生成一个apk包时,会对里面所有的文件做一个校验计算,并把计算结果存放在META-INF文件夹内,apk在安装的时候,系统会按照同样的算法对apk包里面的文件做校验,如果结果与META-INF里面的值不一样,系统就不会安装这个apk,这就保证了apk包里的文件不能被随意替换。比如拿到一个apk包后,如果想要替换里面的一幅图片,一段代码, 或一段版权信息,想直接解压缩、替换再重新打包,基本是不可能的。如此一来就给病毒感染和恶意修改增加了难度,有助于保护系统的安全。
  • res文件夹: 存放资源文件,包括icon,xml文件
  • res/layout/: 存放被编译为屏幕布局(或屏幕的一部分)的XML文件
  • res/values/: 存放可以被编译成很多类型的资源文件
  • array.xml: 定义数组
  • string.xml: 定义字符串(string)值
  • AndroidManifest.xml文件: 应用程序配置文件,每个应用都必须定义和包含的,它描述了应用的名字、版本、权限、引用的库文件等信息。
  • classes.dex文件: 传统 Class 文件是由一个 Java 源码文件生成的 .Class 文件,而 Android 是把所有 Class 文件进行合并优化,然后生成一个最终的 class.dex 文件。它包含 APK 的可执行代码,是分析 Android 软件时最常见的目标。由于dex文件很难看懂,可通过apktool反编译得到.smali文件,smali文件是对Dalvik虚拟机字节码的一种解释(也可以说是翻译),并非一种官方标准语言。通过对smali文件的解读可以获取源码的信息。
  • resources.arsc文件: 二进制资源文件,包括字符串等。
  • smali: smali是将Android字节码用可阅读的字符串形式表现出来的一种语言,可以称之为Android字节码的反汇编语言。利用apktool或者Android Killer,反编classes.dex文件,就可以得到以smali为后缀的文件,这些smali文件就是Dalvik的寄存器语言。

Android应用编译流程

Android应用编译流程就是一个app应用的生成过程。在应用程序上架的时候都需要程序经过编译、签名 、生成一个后缀为apk的文件才能发布到应用市场,大致流程如下:

  • 打包资源文件,生成R.java文件。通过利用aapt资源打包工具,将文件目录中的Resource文件(就是工程中res中的文件)、Assets文件、AndroidManifest.xml文件、Android基础类库(Android.jar文件)进行打包,生成R.java
  • aidl生成Java文件。AIDL是Android Interface Definition Language的简称, 是Android跨进程通讯的一种方式。 检索工程里所有的aidl文件,并转换为对应的Java文件
  • 编译Java文件,生成对应的.class文件。将R.java、aidl生成的Java文件、Java源文件通过JDK携带的Javac编译生成.class文件
  • 把.class文件转化成Davik VM支持的.dex文件。通过dx工具将.class文件生成为classes.dex
  • 打包生成未签名的.apk文件。利用apkbuilder工具,将resources.arsc、res目录、AndroidManifest.xml、assets目录、dex文件打包成未签名的apk
  • 对未签名.apk文件进行签名。使用apksigner为安装包添加签名信息。
  • 对签名后的.apk文件进行对齐处理。使用zipalign工具对签名包进行内存对齐操作, 即优化安装包的结构。

Android四大组件

Android 的四大组件是构成应用程序的核心模块:

  • Activity(活动)。表示应用的一个单一界面,负责与用户进行交互。例如,一个登录页面或设置页面就是一个 Activity。
  • Service(服务)。在后台运行的组件,用于执行长时间运行的操作,如播放音乐、下载文件等,不提供用户界面。
  • BroadcastReceiver(广播接收器)。用于接收并响应系统或应用发出的广播消息,例如接收短信到达、电量变化、网络状态改变等事件。
  • ContentProvider(内容提供器)。用于在不同应用间共享数据,提供统一的数据访问接口,比如系统的联系人、图库等数据都可以通过 ContentProvider 被其他应用访问。

这四大组件共同构成 Android 应用的基本结构,每一种都通过 AndroidManifest.xml 注册,协同完成应用的功能实现。

Android应用反编译流程

Android应用反编译流程很显然,就是app生成过程的逆过程,对于最左边的目标——未签名apk,有两条反编译路线:

反编译方式一:图上面这条路线是通过利用apktool这款工具,可直接对目标apk直接进行反编译,可以看到除apk本身的资源文件,还生成了smali文件,而smali文件里面包含的都是程序执行的核心代码。我们可以通过直接分析.smali文件中的smali代码,进而修改代码,改变其运行逻辑。

下载地址:https://bitbucket.org/iBotPeaches/apktool/downloads/

使用方法:

apktool d target.apk

反编译完成后会生成文件夹:

反编译方式二:图中下面这条路线是通过修改apk后缀名,对其解压后,将文件夹中的资源文件,通过利用AXMLPrinter2进行分析,进而反编译,而对于解压后的class.dex文件,利用Dex2jar反编译工具对其进行反编译得到jar文件。接着将生成的jar文件直接拖到JD-GUI文件中,可自动生成源码,我们可通过分析其源码,了解其程序的运行逻辑,但我们修改逻辑,最终还是需要在smali文件中进行修改。

dex2jar下载地址:https://sourceforge.net/projects/dex2jar/files/

dex2jar.bat classes.dex

然后可以看到生成了一个jar文件:

我们可以直接把jar文件拖到jd-gui里进行反编译:

jd-gui下载地址:http://java-decompiler.github.io/

除此之外,也可以使用一把梭工具Android killer下载地址:https://down.52pojie.cn/Tools/Android_Tools/AndroidKiller_v1.3.1.zip

Android killerApk反编译Apk打包Apk签名编码互转ADB通信(应用安装-卸载-运行-设备文件管理)、源码查看等特色功能于一身,支持logcat日志输出,语法高亮,基于关键字(支持单行代码或多行代码段)项目内搜索,可自定义外部工具;吸收融汇多种工具功能与特点,是一款可视化的安卓应用逆向工具。

除此之外还有jadx-gui,这是一款可以将apk,dex,aar 和 zip 文件将 Dalvik 字节码反编译为 Java 类的JAVA反编译工具。而它是支持apk、dex、aar、zip等格式,所以我们在使用过程中,可直接将待测apk拖入的工具中,会自动执行反编译:

jadx-gui下载地址:https://down.52pojie.cn/Tools/Android_Tools/jadx_v1.3.4.zip

参考

https://www.anquanke.com/post/id/273348

安卓Lab

提前安装Android Studio,地址:https://developer.android.com/studio

首先新建项目:

创建好之后可能会因为Importing ‘Lab’ Gradle Project卡一会儿,然后创建一个activity:

这里我们创建一个虚拟设备:

我选择的这个Pixel 9 Pro:

接着运行这个设备:

改一下AndroidManifest.xml,不然运行会报Error running ‘app’ Default Activity not found

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.smali.secretchallenge">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Lab"
        tools:targetApi="31">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>

</manifest>

现在我们就在本地简单的运行了我们的app,下面开始正式完成任务。

Step 1. Android Programming

Task 1. What’s in Background

任务要求:

  • 在com.smali.secretchallenge包中创建⼀个名为SecretBootReceiver的新接收器类,让应⽤程序在Android系统启动时⾃动启动,⽆需任何⽤⼾操作即可在后台启动服务。
  • SecretBootReceiver启动的服务应命名为SecretService ,并且位于com.smali.secretchallenge包中,该服务的功能必须获取此设备的GPS 位置,每 3 秒在屏幕上显⽰⼀次纬度和经度。

首先在AndroidManifest.xml里做几个改动,下面是完整代码:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.smali.secretchallenge">

    <!-- Required permissions -->
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Lab"
        tools:targetApi="31">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- SecretBootReceiver for auto-start on boot -->
        <receiver
            android:name=".SecretBootReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </receiver>

        <!-- SecretService for background location tracking -->
        <service
            android:name=".SecretService"
            android:enabled="true"
            android:exported="false" />

    </application>

</manifest>

这里我们做了两个操作,首先是申请权限:

权限作用是否敏感权限
RECEIVE_BOOT_COMPLETED允许 App 在设备启动完成后收到 BOOT_COMPLETED 广播,一般用于开机自启动服务或任务。是(不需要用户手动授予)
ACCESS_FINE_LOCATION精确定位权限,使用 GPS 定位,精度在几十米以内。是(危险权限,Android 6.0+ 需要运行时授权)
ACCESS_COARSE_LOCATION粗略定位权限,使用基站或 WiFi 定位,精度在几百米左右。是(危险权限)
FOREGROUND_SERVICE允许 App 启动前台服务(Foreground Service),服务会有通知栏提示,通常用于持续任务(如位置跟踪)。否(Android 9+)
FOREGROUND_SERVICE_LOCATION启动专用于“持续定位”的前台服务权限(Android 10+)。是(需要和 FOREGROUND_SERVICE 配合使用)
<!-- Required permissions -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

接着是声明了一个广播接收器,首先指定了这个 Receiver 的类名,BOOT_COMPLETED 是系统在设备开机完成后发送的广播,只有拥有 RECEIVE_BOOT_COMPLETED 权限的 App 可以接收到。android:exported="true" 表示这个接收器可以接收来自系统或其他应用的广播:

<receiver
    android:name=".SecretBootReceiver"
    android:enabled="true"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
        <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
</receiver>

然后是创建SecretBootReceiver.java,作用是一个开机广播接收器(BroadcastReceiver),它的功能是监听 Android 系统的设备启动完成事件,然后自动启动一个后台服务 SecretService

package com.smali.secretchallenge;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;

public class SecretBootReceiver extends BroadcastReceiver {
    private static final String TAG = "SecretBootReceiver";

    @Override
    public void onReceive(Context context, Intent intent) {
        if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
            Log.d(TAG, "Boot completed received, starting SecretService");
            
            // Start the SecretService
            Intent serviceIntent = new Intent(context, SecretService.class);
            context.startService(serviceIntent);
        }
    }
} 

接着创建SecretService.java,这段 SecretService 是一个后台定位服务,会在后台周期性获取并显示当前位置,主要通过 Android 的 LocationManager 来获取 GPS 数据,并每隔 3 秒用 Toast 和日志输出一次位置信息:

package com.smali.secretchallenge;

import android.Manifest;
import android.app.Service;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.util.Log;
import android.widget.Toast;

import androidx.core.app.ActivityCompat;

public class SecretService extends Service {
    private static final String TAG = "SecretService";
    private LocationManager locationManager;
    private Handler handler;
    private Runnable locationRunnable;
    private Location currentLocation;

    @Override
    public void onCreate() {
        super.onCreate();
        Log.d(TAG, "SecretService created");
        
        locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
        handler = new Handler(Looper.getMainLooper());
        
        // Create runnable to show location toast every 3 seconds
        locationRunnable = new Runnable() {
            @Override
            public void run() {
                showLocationToast();
                handler.postDelayed(this, 3000); // Repeat every 3 seconds
            }
        };
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Log.d(TAG, "SecretService started");
        
        // Start location updates
        startLocationUpdates();
        
        // Start the periodic toast display
        handler.post(locationRunnable);
        
        return START_STICKY; // Restart service if killed
    }

    private void startLocationUpdates() {
        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
            ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            Log.e(TAG, "Location permission not granted");
            return;
        }

        LocationListener locationListener = new LocationListener() {
            @Override
            public void onLocationChanged(Location location) {
                currentLocation = location;
                Log.d(TAG, "Location updated: " + location.getLatitude() + ", " + location.getLongitude());
            }

            @Override
            public void onStatusChanged(String provider, int status, Bundle extras) {}

            @Override
            public void onProviderEnabled(String provider) {}

            @Override
            public void onProviderDisabled(String provider) {}
        };

        // Request location updates from GPS provider
        locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000, 0, locationListener);
        
        // Also try to get last known location
        Location lastKnownLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
        if (lastKnownLocation != null) {
            currentLocation = lastKnownLocation;
        }
    }

    private void showLocationToast() {
        if (currentLocation != null) {
            String message = "Latitude: " + currentLocation.getLatitude() + 
                           ", Longitude: " + currentLocation.getLongitude();
            Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
            Log.d(TAG, "Showing toast: " + message);
        } else {
            Toast.makeText(this, "Location not available", Toast.LENGTH_SHORT).show();
            Log.d(TAG, "Location not available");
        }
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "SecretService destroyed");
        
        // Remove callbacks
        if (handler != null && locationRunnable != null) {
            handler.removeCallbacks(locationRunnable);
        }
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
} 

最后是MainActivity.java,动态申请定位权限,并在权限通过后启动 SecretService 后台服务:

package com.smali.secretchallenge;

import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.util.Log;
import android.widget.TextView;
import android.widget.Toast;

import androidx.activity.EdgeToEdge;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;

public class MainActivity extends AppCompatActivity {
    private static final String TAG = "MainActivity";
    private static final int LOCATION_PERMISSION_REQUEST_CODE = 1001;

    private ActivityResultLauncher<String[]> locationPermissionRequest;
    private TextView statusText;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
        setContentView(R.layout.activity_main);
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });

        statusText = findViewById(R.id.statusText);

        // Initialize permission launcher
        locationPermissionRequest = registerForActivityResult(
            new ActivityResultContracts.RequestMultiplePermissions(),
            result -> {
                Boolean fineLocationGranted = result.getOrDefault(
                    Manifest.permission.ACCESS_FINE_LOCATION, false);
                Boolean coarseLocationGranted = result.getOrDefault(
                    Manifest.permission.ACCESS_COARSE_LOCATION, false);
                
                if (fineLocationGranted != null && fineLocationGranted) {
                    Log.d(TAG, "Fine location permission granted");
                    statusText.setText("Status: Fine location permission granted");
                    startSecretService();
                } else if (coarseLocationGranted != null && coarseLocationGranted) {
                    Log.d(TAG, "Coarse location permission granted");
                    statusText.setText("Status: Coarse location permission granted");
                    startSecretService();
                } else {
                    Log.d(TAG, "Location permission denied");
                    statusText.setText("Status: Location permission denied");
                    Toast.makeText(this, "Location permission is required for this app", Toast.LENGTH_LONG).show();
                }
            }
        );

        // Request location permissions
        requestLocationPermissions();
    }

    private void requestLocationPermissions() {
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
            ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
            Log.d(TAG, "Location permission already granted");
            statusText.setText("Status: Location permission already granted");
            startSecretService();
        } else {
            Log.d(TAG, "Requesting location permissions");
            statusText.setText("Status: Requesting location permissions...");
            locationPermissionRequest.launch(new String[]{
                Manifest.permission.ACCESS_FINE_LOCATION,
                Manifest.permission.ACCESS_COARSE_LOCATION
            });
        }
    }

    private void startSecretService() {
        Log.d(TAG, "Starting SecretService");
        Intent serviceIntent = new Intent(this, SecretService.class);
        startService(serviceIntent);
        Toast.makeText(this, "SecretService started", Toast.LENGTH_SHORT).show();
    }
}

最后的效果:

Task 2.Change UI in the thread

任务要求:

  • 要创建⼀个对话框,当按钮被点击时,显⽰你刚刚在⽂本中输⼊的内容。
  • 在这个任务中,你需要在⼀个线程中更改 UI。实际上,我们不允许在另⼀个线程中更改 UI。我们只能在主线程中更改它。所以你需要想办法解决这个问题。

由于我们不能直接在子线程中更新 UI(否则会崩溃),所以必须使用 Android 的异步消息传递机制(Looper + Message + Handler)实现子线程与主线程之间的通信,这里实现的几个关键点如下:

  • 使用子线程:new Thread(() -> { ... }).start();
  • 使用 Handler 通知主线程:mainHandler.sendMessage(message);
  • Message 携带数据:message.setData(bundle);
  • 主线程响应消息并更新 UI:Handler.handleMessage()showDialog(dialogMessage)

首先初始化 Handler(关联主线程),Looper.getMainLooper()用于让这个 Handler 与主线程绑定;handleMessage()保证当其他线程发送消息时,这里就会在主线程执行;showDialog()用于弹出对话框:

mainHandler = new Handler(Looper.getMainLooper()) {
    @Override
    public void handleMessage(Message msg) {
        if (msg.what == MSG_SHOW_DIALOG) {
            String dialogMessage = msg.getData().getString(KEY_DIALOG_MESSAGE);
            showDialog(dialogMessage);
        }
    }
};

点击按钮后读取用户输入,开启一个后台线程处理:

showDialogButton.setOnClickListener(v -> {
    String userInput = inputText.getText().toString().trim();
    if (userInput.isEmpty()) {
        Toast.makeText(this, "请输入文本", Toast.LENGTH_SHORT).show();
        return;
    }
    startBackgroundThread(userInput);
});

子线程模拟异步工作并发送 Message:

private void startBackgroundThread(String userInput) {
    new Thread(() -> {
        try {
            Thread.sleep(1000); // 模拟耗时操作

            // 构造并发送消息到主线程
            Message message = mainHandler.obtainMessage(MSG_SHOW_DIALOG);
            Bundle bundle = new Bundle();
            bundle.putString(KEY_DIALOG_MESSAGE, userInput);
            message.setData(bundle);
            mainHandler.sendMessage(message);

        } catch (InterruptedException e) {
            Log.e(TAG, "Background thread interrupted", e);
        }
    }).start();
}

最后主线程收到消息后弹出对话框:

private void showDialog(String message) {
    new AlertDialog.Builder(this)
        .setTitle("后台线程消息")
        .setMessage("你输入的内容: " + message)
        .setPositiveButton("确定", null)
        .show();
}

现在的MainActivity.java:

package com.smali.secretchallenge;

import android.Manifest;
import android.app.AlertDialog;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;

import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;

public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";

// Task 2: Handler constants
private static final int MSG_SHOW_DIALOG = 1;
private static final String KEY_DIALOG_MESSAGE = "dialog_message";

private ActivityResultLauncher<String[]> locationPermissionRequest;
private TextView statusText;
private EditText inputText;
private Button showDialogButton;

// Task 2: Handler for UI thread communication
private Handler mainHandler;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// Initialize UI elements
statusText = findViewById(R.id.statusText);
inputText = findViewById(R.id.inputText);
showDialogButton = findViewById(R.id.showDialogButton);

// Task 2: Initialize Handler with main thread Looper
mainHandler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
if (msg.what == MSG_SHOW_DIALOG) {
String dialogMessage = msg.getData().getString(KEY_DIALOG_MESSAGE);
showDialog(dialogMessage);
}
}
};

// Task 2: Set button click listener
showDialogButton.setOnClickListener(v -> {
String userInput = inputText.getText().toString().trim();
if (userInput.isEmpty()) {
Toast.makeText(this, "请输入文本", Toast.LENGTH_SHORT).show();
return;
}

// Start background thread
startBackgroundThread(userInput);
});

// Initialize permission launcher
locationPermissionRequest = registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(),
result -> {
Boolean fineLocationGranted = result.getOrDefault(
Manifest.permission.ACCESS_FINE_LOCATION, false);
Boolean coarseLocationGranted = result.getOrDefault(
Manifest.permission.ACCESS_COARSE_LOCATION, false);

if (fineLocationGranted != null && fineLocationGranted) {
Log.d(TAG, "Fine location permission granted");
statusText.setText("Status: Fine location permission granted");
startSecretService();
} else if (coarseLocationGranted != null && coarseLocationGranted) {
Log.d(TAG, "Coarse location permission granted");
statusText.setText("Status: Coarse location permission granted");
startSecretService();
} else {
Log.d(TAG, "Location permission denied");
statusText.setText("Status: Location permission denied");
Toast.makeText(this, "Location permission is required for this app", Toast.LENGTH_LONG).show();
}
}
);

// Request location permissions
requestLocationPermissions();
}

// Task 2: Start background thread
private void startBackgroundThread(String userInput) {
new Thread(() -> {
try {
// Simulate background work
Thread.sleep(1000);

// Send message to main thread to show dialog
Message message = mainHandler.obtainMessage(MSG_SHOW_DIALOG);
Bundle bundle = new Bundle();
bundle.putString(KEY_DIALOG_MESSAGE, userInput);
message.setData(bundle);
mainHandler.sendMessage(message);

} catch (InterruptedException e) {
Log.e(TAG, "Background thread interrupted", e);
}
}).start();
}

// Task 2: Show dialog from main thread
private void showDialog(String message) {
new AlertDialog.Builder(this)
.setTitle("后台线程消息")
.setMessage("你输入的内容: " + message)
.setPositiveButton("确定", null)
.show();
}

private void requestLocationPermissions() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Location permission already granted");
statusText.setText("Status: Location permission already granted");
startSecretService();
} else {
Log.d(TAG, "Requesting location permissions");
statusText.setText("Status: Requesting location permissions...");
locationPermissionRequest.launch(new String[]{
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
});
}
}

private void startSecretService() {
Log.d(TAG, "Starting SecretService");
Intent serviceIntent = new Intent(this, SecretService.class);
startService(serviceIntent);
Toast.makeText(this, "SecretService started", Toast.LENGTH_SHORT).show();
}
}

Task 3. How to call that method?

任务要求:

  • 获取 classes.jar 的 PoRELab 类中名为 curStr 的私有字段的值
  • 调⽤ classes.jar 的 PoRELab 类中名为 privateMethod 的私有⽅法

在app目录下创建一个lib,把这个jar放进去:

这是这个jar的具体内容:

主要是在MainActivity.java加一点反射调用的内容:

private void demonstrateReflection() {
        try {
            Log.d(TAG, "=== Task 3: Java Reflection Demo ===");
            
            // Get the PoRELab class from jar file
            Class<?> poRELabClass = Class.forName("com.pore.mylibrary.PoRELab");
            Log.d(TAG, "Successfully loaded PoRELab class from jar");
            
            // Get private static field 'curStr' using reflection
            Field curStrField = poRELabClass.getDeclaredField("curStr");
            curStrField.setAccessible(true); // Make private field accessible
            String fieldValue = (String) curStrField.get(null); // null for static field
            Log.d(TAG, "Private static field 'curStr' value: " + fieldValue);
            
            // Invoke private static method 'privateMethod' using reflection
            Method privateMethod = poRELabClass.getDeclaredMethod("privateMethod", String.class, String.class);
            privateMethod.setAccessible(true); // Make private method accessible
            privateMethod.invoke(null, "Hello from reflection!", "Secret message"); // null for static method
            Log.d(TAG, "Private static method 'privateMethod' invoked successfully");
            
            Log.d(TAG, "=== Java Reflection Demo Completed ===");
            
        } catch (ClassNotFoundException e) {
            Log.e(TAG, "PoRELab class not found: " + e.getMessage(), e);
        } catch (NoSuchFieldException e) {
            Log.e(TAG, "Field 'curStr' not found: " + e.getMessage(), e);
        } catch (NoSuchMethodException e) {
            Log.e(TAG, "Method 'privateMethod' not found: " + e.getMessage(), e);
        } catch (Exception e) {
            Log.e(TAG, "Reflection error: " + e.getMessage(), e);
        }
    }

然后在build.gradle.kts里引入这个jar依赖:

plugins {
    alias(libs.plugins.android.application)
}

android {
    namespace = "com.smali.secretchallenge"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.smali.secretchallenge"
        minSdk = 24
        targetSdk = 35
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
}

dependencies {

    implementation(libs.appcompat)
    implementation(libs.material)
    implementation(libs.activity)
    implementation(libs.constraintlayout)
    
    // Task 3: Add jar file dependency
    implementation(files("libs/Step1_Task3_classes.jar"))
    
    testImplementation(libs.junit)
    androidTestImplementation(libs.ext.junit)
    androidTestImplementation(libs.espresso.core)
}

Task 4. Generate Signed Application

任务要求:

  • 顾名思义,创建一个签好名的apk

最后生成成功:

Step 2. Smali2Java

首先使用smali.jar将 smali 文件编译为 dex 文件

java -jar smali.jar assemble  src.smali -o src.dex

smali.jar 下载地址:https://github.com/baksmali/smali/releases/download/v2.2.2/smali-2.2.2.jar,或者其实Android Studio默认就安装了这个东西:

D:\Android\Android Studio\plugins\smali\lib\Step2_smali_code_files\smali>java -jar smali-2.2.2.jar assemble -o output.dex .

然后使用adb连接我们的安卓虚拟机,这个adb是直接Android studio默认安装的:

然后把我们的dex文件传上去:

D:\Android\SDK\platform-tools>adb push output.dex /tmp
output.dex: 1 file pushed, 0 skipped. 13.1 MB/s (4876 bytes in 0.000s)

D:\Android\SDK\platform-tools>adb shell
emu64xa:/ $ cd /tmp
emu64xa:/tmp $ ls -a
.  ..  CheckBox.dex  Checker.dex  Encoder.dex  output.dex

CheckBox.java的逻辑:

package JDBC.lab;

import java.util.Scanner;

public class CheckBox {
    private String rawInfo;
    Encoder encoder = new Encoder();
    Checker checker = new Checker();

    public boolean checkEncP(String str) {
        return this.encoder.check(this.rawInfo, str);
    }

    public String getEncP() {
        return this.encoder.encoding(this.rawInfo);
    }

    private boolean checkInput(String str) {
        return this.checker.check(str);
    }

    public static void main(String[] strArr) {
        String str;
        CheckBox checkBox = new CheckBox();
        System.out.print("input: ");
        String nextLine = new Scanner(System.in).nextLine();
        checkBox.rawInfo = nextLine;
        if (strArr.length == 0 && nextLine.length() == 11) {
            str = "Task 2: (Encoded msg) " + checkBox.getEncP();
        } else if (strArr.length != 1) {
            str = "Task 1: " + checkBox.checkInput(nextLine);
        } else {
            str = "Task 2: " + checkBox.checkEncP(strArr[0]);
        }
        System.out.println(str);
    }
}

Task 1. Checker

任务要求:

  • 编译并运⾏给出的 smali 代码,实现输⼊⼀个字符串,CheckBox 将检查该字符串是否通过 Checker 类中的检查,并相应地输出 true/false
  • 编写Java代码实现Checker.java中功能,完成 Java 代码后,您可以通过运⾏ CheckBox.java 中的 Main ⽅法并将 Java 中的输出与 smali 中的输出进⾏⽐较来检查代码是否正确运⾏

按照smali代码推断,符合要求的字符串要求如下:

  • 长度 12~16
  • 前10位 substring(0, 10),统计字符 ‘1’ 的个数,必须等于3
  • checkStr1(substring(0, 10)) 必须为 true 前10位有两个 ‘x’,且它们间隔4位 第一个字符是 ‘0’ 最后一个字符是 ‘9’
  • 第一个 ‘x’ 之前的字符串包含 “key”
  • int num = Integer.parseInt(s.substring(10, s.length() – 1)); 这部分必须能转成整数
  • func(count) == num 其中 count 是前10位 ‘1’ 的个数(即3),func(3)=6

一个符合要求的字符串为:

0keyx111x961

代码如下:

package JDBC.lab;

public class Checker {
    private String secret = "key";

    private boolean checkStr1(String str) {
        char[] charArray = str.toCharArray();
        int i = 0;
        int i2 = 0;
        int i3 = 0;
        for (int i4 = 0; i4 < charArray.length; i4++) {
            if (charArray[i4] == 'x') {
                i++;
                if (i == 1) {
                    i3 = i4;
                }
                if (i == 2) {
                    i2 = i4;
                }
            }
        }
        return i == 2 && i2 - i3 == 4 && charArray[0] == '0' && charArray[charArray.length + (-1)] == '9' && str.substring(0, i3).contains(this.secret);
    }

    private int func(int i) {
        if (i <= 1) {
            return 1;
        }
        return func(i - 1) * i;
    }

    private int count(String str) {
        int i = 0;
        char[] charArray = str.toCharArray();
        int i2 = 0;
        while (true) {
            int i3 = i;
            if (i2 < str.length()) {
                i = charArray[i2] == '1' ? i3 + 1 : i3;
                i2++;
            } else {
                return i3;
            }
        }
    }

    public boolean check(String str) {
        if (str.length() < 12 || str.length() > 16) {
            return false;
        }
        String substring = str.substring(0, 10);
        int count = count(str.substring(10, str.length()));
        return func(count) == count && checkStr1(substring);
    }
}

Task 2. Encoder

任务要求:

  • 按照Task1的⽅式编译并运⾏smali代码,输⼊你的学号,CheckBox会输出编码后的消息,使⽤ 1.1 中编码的消息作为 CheckBox 的参数并输⼊您的学⽣ ID,CheckBox 将检查消息是否通过Encoder 类中的检查并相应地输出 true/false。
  • 编写Java代码实现Encoder.java中功能,完成 Java 代码后,您可以通过运⾏ CheckBox.java 中的 Main ⽅法并将 Java 中的输出与 smali 中的输出进⾏⽐较来检查代码是否正确运⾏。
emu64xa:/tmp $ dalvikvm -cp output.dex CheckBox
input: 18307130154
Task 2: (Encoded msg) 71921970cc1381400f40760011501bc06218214a1980131a
emu64xa:/tmp $ dalvikvm -cp output.dex CheckBox 71921970cc1381400f40760011501bc06218214a1980131a
input: 18307130154
Task 2: true

代码如下:

package JDBC.lab;

import java.security.MessageDigest;
import java.util.Random;

public class Encoder {
    private final String[] hexDigits = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"};
    private String algorithm = "MD5";
    private String charSet = "utf-8";

    public String encoding(String str) {
        String result = "";
        String salt = getSalt();
        String combined = str + salt;
        try {
            MessageDigest md = MessageDigest.getInstance(this.algorithm);
            byte[] digestBytes = md.digest(combined.getBytes(this.charSet));
            String hex = byteArrayToHexString(digestBytes);

            char[] cArr = new char[48];
            for (int i = 0; i < 16; i++) {
                cArr[i * 3] = hex.charAt(i * 2);
                cArr[i * 3 + 1] = salt.charAt(i);
                cArr[i * 3 + 2] = hex.charAt(i * 2 + 1);
            }
            result = new String(cArr);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }


    private String getSalt() {
        Random random = new Random();
        StringBuilder sb = new StringBuilder(16);
        for (int i = 0; i < 16; i++) {
            if (random.nextBoolean()) {
                sb.append("1");
            } else {
                sb.append("0");
            }
        }
        return sb.toString();
    }

    private String byteArrayToHexString(byte[] bArr) {
        StringBuffer stringBuffer = new StringBuffer();
        for (byte b : bArr) {
            stringBuffer.append(byteToHexString(b));
        }
        return stringBuffer.toString();
    }

    private String byteToHexString(byte b) {
        int unsignedByte = b & 0xff;
        int high = (unsignedByte >> 4) & 0x0f;
        int low = unsignedByte & 0x0f;
        return hexDigits[high] + hexDigits[low];
    }

    public boolean check(String str, String str2) {
        char[] cArr = new char[32];
        char[] cArr2 = new char[16];
        if (str2.length() != 48) {
            return false;
        }
        for (int i = 0; i < 48; i += 3) {
            cArr[(i / 3) * 2] = str2.charAt(i);
            cArr[((i / 3) * 2) + 1] = str2.charAt(i + 2);
            cArr2[i / 3] = str2.charAt(i + 1);
        }
        String str3 = str + new String(cArr2);
        try {
            return ("" + byteArrayToHexString(MessageDigest.getInstance(this.algorithm).digest(str3.getBytes(this.charSet)))).equals(new String(cArr));
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
}

然后配一下传入的参数:

Step 3. Reversing and Repacking

Task 1 Knock the door

任务要求:

  • 在这个任务中,你会得到一个简单测试的apk。只有通过这个测试,你才能完成这个任务。不过,如果不作弊的话,很难通过。

这里的按键逻辑其实只要把l1改成0就行:

public void buttonClick(View view) {
        int i = this.l0;
        if (i != this.l1) {
            int i2 = i + 1;
            this.l0 = i2;
            this.t2.setText(String.format("%d / %d", Integer.valueOf(i2), Integer.valueOf(this.l1)));
            return;
        }
        this.t2.setText(R.string.success1);
        this.t3.setText(PlayGame.getFlag(this.te.getText().toString(), this.ctx));
    }

下载地址:https://github.com/iBotPeaches/Apktool/releases/download/v2.11.0/apktool_2.11.0.jar

这里用apktool解包一下apk:

java -jar apktool_2.11.1.jar d Step3_Task123_lab.apk

解包之后搜了一下,直接找到这个l1对应的v0

改成0x0:

然后重新打包成apk:

java -jar apktool_2.11.1.jar b Step3_Task123_lab_new -o Step3_Task123_lab_new.apk

签个名,我这里用的之前android Studio生成的签名,这里注意jdk环境需要和Android studio的一样:

java -jar "D:\Android\SDK\build-tools\35.0.0\lib\apksigner.jar" sign --ks D:\Android\fushuling.jks --ks-key-alias key0 "D:\CTF_tools\AndroidReverse\AndroidKiller_v1.3.1\bin\apktool\apktool\Step3_Task123_lab_new.apk"

最后随便输入一个东西即可执行成功:

Task 2 Give me your token

任务要求:

  • 完成 Task1 后,你需要在这个 Task3 中输⼊正确的 token,并获得相应的提⽰。显然,光靠你的想象⼒是⽆法得到正确 token 的

隐藏的key的逻辑在这里:

sb3.setCharAt(0, (char) ('p' - 4));  // 'l'
sb3.setCharAt(1, (char) ('o' - 6));  // 'i'
sb3.setCharAt(2, (char) ('r' - 11)); // 'g'
sb3.setCharAt(3, (char) ('e' + 3));  // 'h'
//sb3 = "ligh"
sb2.setCharAt(0, (char) ('p' + 4));  // 't'
sb2.setCharAt(1, (char) ('o' + 10)); // 'y'
sb2.setCharAt(2, (char) ('r' - 13)); // 'e'
sb2.setCharAt(3, (char) ('e' + 7));  // 'l'
//sb2 = "tyel"
sb.setCharAt(0, (char) ('p' - 4));  // 'l'
sb.setCharAt(1, (char) ('o' + 0));  // 'o'
sb.setCharAt(2, (char) ('r' + 5));  // 'w'
sb.setCharAt(3, (char) ('e' - 1));  // 'd'
//sb = "lowd"
sb4.setCharAt(0, (char) ('p' + 2));     // 'r'
sb4.setCharAt(1, (char) ('o' - 10));    // 'e'
sb4.setCharAt(2, (char) ('r' + 1));     // 's'
sb4.setCharAt(3, (char) ('e' + 14));    // 's'
//sb4 = "ress"

最后拼在一起:lightyellowdress

Task 3 Call to the NPC

任务要求:

  • 完成Task2后,你需要根据Task2中得到的提⽰进⾏相应的操作,才能得到最终的秘密答案

提示让我们调用某个东西,那可能是 native 函数、隐藏的方法、某个 JNI 接口或隐藏的逻辑,回看这里可以看到有个native方法,那就是得手动调用这个东西,传入的参数必须是这BuildConfig.FLAVOR.concat(sb3.toString()).concat(sb2.toString()).concat(sb.toString()).concat(sb4.toString())

手动加一句:

 invoke-static {p1}, Lcom/pore/play4fun/PlayGame;->skdaga(Ljava/lang/String;)Ljava/lang/String;
    
 move-result-object p0

然后按task1重新打包并签名,重新完成一次task1和2即可:

flag{SmaliIsCoolll}

反编译出的结果如下:

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇