AndFix原理分析.md

hook原理

了解Hook

我們知道,在Android操作系統(tǒng)中系統(tǒng)維護著自己的一套事件分發(fā)機制。應(yīng)用程序扣汪,包括應(yīng)用觸發(fā)事件和后臺邏輯處理,也是根據(jù)事件流程一步步的向下執(zhí)行锨匆。而“鉤子”的意思崭别,就是在事件傳送到終點前截獲并監(jiān)控事件的傳輸,像個鉤子勾上事件一樣恐锣。并且能夠在勾上事件時茅主,處理一些自己特定的事件。如下圖所示:

動態(tài)代理

傳統(tǒng)的靜態(tài)代理模式需要為每一個需要代理的類寫一個代理類土榴,如果需要代理的類有幾百個那不是要累死诀姚?為了更優(yōu)雅地實現(xiàn)代理模式,JDK提供了動態(tài)代理方式玷禽,可以簡單理解為JVM可以在運行時幫我們動態(tài)生成一系列的代理類赫段,這樣我們就不需要手寫每一個靜態(tài)的代理類了。依然以購物為例矢赁,用動態(tài)代理實現(xiàn)如下:

    public static void main(String[] args) {
        Shopping people = new ShoppingImp();
        System.out.println(Arrays.toString(people.doShopping(100)));

        people = (Shopping) Proxy.newProxyInstance(Shopping.class.getClassLoader(),
                people.getClass().getInterfaces(), new ShoppingHandler(people));

        System.out.println(Arrays.toString(people.doShopping(100)));
    }

Hook Android的startActivity方法

Android在啟動的時候會創(chuàng)建ActivityThread, 這是一個單例的對象糯笙,而startActivity實際上是Instrumentation中的execStartActivity()來實現(xiàn)的。所有我們只要替換掉ActivityThread中的Instrumentation的對象成我們自己的方法撩银。

創(chuàng)建代理類

public class ProxyInstrumentation extends Instrumentation {
    private static final String TAG = "EvilInstrumentation";

    // ActivityThread中原始的對象, 保存起來
    Instrumentation mBase;

    public ProxyInstrumentation(Instrumentation base) {
        mBase = base;
    }

    public ActivityResult execStartActivity(
            Context who, IBinder contextThread, IBinder token, Activity target,
            Intent intent, int requestCode, Bundle options){
        // Hook之前, XXX到此一游!

        Log.d(TAG, "sanfen到此一游8椤!额获!");
        Log.d(TAG, "\n執(zhí)行了startActivity, 參數(shù)如下: \n" + "who = [" + who + "], " +
                "\ncontextThread = [" + contextThread + "], \ntoken = [" + token + "], " +
                "\ntarget = [" + target + "], \nintent = [" + intent +
                "], \nrequestCode = [" + requestCode + "], \noptions = [" + options + "]");

        // 開始調(diào)用原始的方法, 調(diào)不調(diào)用隨你,但是不調(diào)用的話, 所有的startActivity都失效了.
        // 由于這個方法是隱藏的,因此需要使用反射調(diào)用;首先找到這個方法
        try {
            Method execStartActivity = Instrumentation.class.getDeclaredMethod(
                    "execStartActivity",
                    Context.class, IBinder.class, IBinder.class, Activity.class,
                    Intent.class, int.class, Bundle.class);
            execStartActivity.setAccessible(true);
            return (ActivityResult) execStartActivity.invoke(mBase, who,
                    contextThread, token, target, intent, requestCode, options);
        } catch (Exception e) {
            // 某該死的rom修改了  需要手動適配
            throw new RuntimeException("do not support!!! pls adapt it");
        }
    }
}

通過反射修改ActivityThread中的mInstrumentation够庙。


  public static void hookStartActivity(){

        try {
            // 先獲取到當(dāng)前的ActivityThread對象
            Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");

            Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
            currentActivityThreadMethod.setAccessible(true);
            Object currentActivityThread = currentActivityThreadMethod.invoke(null);

            // 拿到原始的 mInstrumentation字段
            Field mInstrumentationField = activityThreadClass.getDeclaredField("mInstrumentation");
            mInstrumentationField.setAccessible(true);
            Instrumentation mInstrumentation = (Instrumentation) mInstrumentationField.get(currentActivityThread);

            // 創(chuàng)建代理對象
            Instrumentation evilInstrumentation = new ProxyInstrumentation(mInstrumentation);

            // 偷梁換柱
            mInstrumentationField.set(currentActivityThread, evilInstrumentation);
        } catch (ClassNotFoundException
                | NoSuchMethodException
                | IllegalAccessException
                | InvocationTargetException
                | NoSuchFieldException e) {
            e.printStackTrace();
        }

    }

執(zhí)行效果,在運行startActivty的時候打出了一段日志抄邀。

hook

AndFix使用

AndFix

AndFix采用native hook的方式耘眨,這套方案直接使用dalvik_replaceMethod替換class中方法的實現(xiàn)。由于它并沒有整體替換class, 而field在class中的相對地址在class加載時已確定撤摸,所以AndFix無法支持新增或者刪除filed的情況(通過替換init與clinit只可以修改field的數(shù)值)毅桃。

也正因如此褒纲,Andfix可以支持的補丁場景相對有限,僅僅可以使用它來修復(fù)特定問題钥飞。結(jié)合之前的發(fā)布流程莺掠,我們更希望補丁對開發(fā)者是不感知的,即他不需要清楚這個修改是對補丁版本還是正式發(fā)布版本(事實上我們也是使用git分支管理+cherry-pick方式)读宙。另一方面彻秆,使用native替換將會面臨比較復(fù)雜的兼容性問題。

引入andfix

在gradle中添加依賴

dependencies {
    compile 'com.alipay.euler:andfix:0.5.0@aar'
}

在Application中初始化AndFix

public class AndFixApplication extends Application {
    public static PatchManager mPatchManager;

    @Override
    public void onCreate() {
        super.onCreate();
        // 初始化patch管理類
        mPatchManager = new PatchManager(this);
        // 初始化patch版本
        mPatchManager.init("1.0");
//        String appVersion = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
//        mPatchManager.init(appVersion);

        // 加載已經(jīng)添加到PatchManager中的patch
        mPatchManager.loadPatch();

    }
}

生成patch包

為了方便演示结闸,我們設(shè)置點擊按鈕來加載patch

public class MainActivity extends AppCompatActivity {

    private static final String APATCH_PATH = "/fix.apatch"; // 補丁文件名

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        findViewById(R.id.load).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                update();
            }
        });
    }

    private void update() {
        String patchFileStr = Environment.getExternalStorageDirectory().getAbsolutePath() + APATCH_PATH;
        try {
            AndFixApplication.mPatchManager.addPatch(patchFileStr);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
patch

patch命令

  • -f <new.apk> :新apk
  • -t <old.apk> : 舊apk
  • -o <output> : 輸出目錄(補丁文件的存放目錄)
  • -k <keystore>: 打包所用的keystore
  • -p <password>: keystore的密碼
  • -a <alias>: keystore 用戶別名
  • -e <alias password>: keystore 用戶別名密碼
sh apkpatch.sh -f app-release-2.0.apk -t app-release-1.0.apk -o output -k abc.keystore -p qwe123 -a abc.keystore -e qwe123
patch_diff

運行

load patch

#安裝應(yīng)用
adb install app-debug-1.0.apk
#將patch push到手機中
adb push fix.apatch /storage/emulated/0/fix.apatch

原理解析

.apatch實際是一個壓縮文件

Manifest-Version: 1.0
Patch-Name: app-debug-2
Created-Time: 12 May 2017 02:31:07 GMT
From-File: app-debug-2.0.apk
To-File: app-debug-1.0.apk
Patch-Classes: com.example.fensan.andfixdemo.MainActivity_CF
Created-By: 1.0 (ApkPatch)

這個Patch-CLasses標(biāo)志了哪些類有修改唇兑,這里會顯示完全的類名同時加上一個_CF后綴。AndFix首先會讀取這個文件里面的東西桦锄,保存在Patch類的一個對象里扎附,備用。

然后我們反編譯diff.dex來查看里面的類结耀,用jd-gui來查看:

image

可以看到這個dex里面只有一個class留夜,而且在我們所修改的方法上有一個"@MethodReplace"注解,在代碼中可以明顯的看到了我們加入的this.fix ="修復(fù)了"這段代碼图甜!

源碼淺析

1. PatchManager

    /**
     * initialize
     * 
     * @param appVersion
     *            App version
     */
    public void init(String appVersion) {
        //patch路徑的初始化
        if (!mPatchDir.exists() && !mPatchDir.mkdirs()) {// make directory fail
            Log.e(TAG, "patch dir create error.");
            return;
        } else if (!mPatchDir.isDirectory()) {// not directory
            mPatchDir.delete();
            return;
        }
        SharedPreferences sp = mContext.getSharedPreferences(SP_NAME,
                Context.MODE_PRIVATE);
        String ver = sp.getString(SP_VERSION, null);
        //針對apk版本的patch處理碍粥,如果版本不一致清除,版本一直則加載黑毅。
        if (ver == null || !ver.equalsIgnoreCase(appVersion)) {
            cleanPatch();
            sp.edit().putString(SP_VERSION, appVersion).commit();
        } else {
            //初始化本地的patch
            initPatchs();
        }
    }

    private void initPatchs() {
        File[] files = mPatchDir.listFiles();
        for (File file : files) {
            addPatch(file);
        }
    }

在init()方法中嚼摩,主要對本地的patch進行處理,當(dāng)apk的版本與patch的版本一致矿瘦,就加載本地的patch枕面;版本不一致則清除。

接下來我們來看loadPatch()的代碼實現(xiàn):

/**
     * load patch,call when application start
     * 
     */
    public void loadPatch() {
        mLoaders.put("*", mContext.getClassLoader());// wildcard
        Set<String> patchNames;
        List<String> classes;
        for (Patch patch : mPatchs) {
            patchNames = patch.getPatchNames();
            
            //對本地的patch進行遍歷匪凡,并fix
            for (String patchName : patchNames) {
                classes = patch.getClasses(patchName);
                mAndFixManager.fix(patch.getFile(), mContext.getClassLoader(),
                        classes);
            }
        }
    }

loadPatch()中對本地的patch進行了遍歷膊畴,獲取每個patch的信息,逐一進行fix()病游,其中參數(shù)classes為patch中配置文件Patch.MF的Patch-Classes字段對應(yīng)的所有類唇跨,即為要修復(fù)的類。

接下來進入AndFixManager中衬衬。

AndFixManager

/**
     * fix class
     * 
     * @param clazz
     *            class
     */
    private void fixClass(Class<?> clazz, ClassLoader classLoader) {
        //得到類所有公用方法
        Method[] methods = clazz.getDeclaredMethods();
        MethodReplace methodReplace;
        String clz;
        String meth;
        //枚舉方式买猖,通過注解找到需要替換的類
        for (Method method : methods) {
            //獲得打了@MethodRepalce標(biāo)簽的方法
            methodReplace = method.getAnnotation(MethodReplace.class);
            if (methodReplace == null)
                continue;
            clz = methodReplace.clazz();
            meth = methodReplace.method();
            if (!isEmpty(clz) && !isEmpty(meth)) {
                //需要替換的類,執(zhí)行下一步
                replaceMethod(classLoader, clz, meth, method);
            }
        }
    }

    /**
     * replace method
     * 
     * @param classLoader classloader
     * @param clz class
     * @param meth name of target method 
     * @param method source method
     */
    private void replaceMethod(ClassLoader classLoader, String clz,
            String meth, Method method) {
        try {
            String key = clz + "@" + classLoader.toString();
            //得到原apk中要替換的類
            Class<?> clazz = mFixedClass.get(key);
            
            
            //如果該類還沒有加載
            if (clazz == null) {// class not load
                Class<?> clzz = classLoader.loadClass(clz);
                // initialize target class 
                // 初始化該類
                clazz = AndFix.initTargetClass(clzz);
            }
            if (clazz != null) {// initialize class OK
                mFixedClass.put(key, clazz);
                Method src = clazz.getDeclaredMethod(meth,
                        method.getParameterTypes());
                        
                //開始進入native層進行方法的替換
                AndFix.addReplaceMethod(src, method);
            }
            
        } catch (Exception e) {
            Log.e(TAG, "replaceMethod", e);
        }
    }

fixClass()方法中進行的過程就是從需要修復(fù)的類中定位到需要修復(fù)的方法滋尉。
replaceMethod() 定位到需要修復(fù)的方法以后玉控,進入AndFix進行方法的替換。

AndFix

AndFix是Java層進行方法替換的核心類狮惜,在該類中提供了Native層的接口高诺,加載了andfix.cpp碌识,主要進行了Native層的初始化,以及目標(biāo)修復(fù)類的替換工作虱而。


    /**
     * replace method's body
     * 
     * @param src
     *            source method
     * @param dest
     *            target method
     * 
     */
    public static void addReplaceMethod(Method src, Method dest) {
        try {
            replaceMethod(src, dest);
            initFields(dest.getDeclaringClass());
        } catch (Throwable e) {
            Log.e(TAG, "addReplaceMethod", e);
        }
    }

    private static native void replaceMethod(Method dest, Method src);

Native層

Dalvik部分


    extern jboolean __attribute__ ((visibility ("hidden"))) dalvik_setup(
            JNIEnv* env, int apilevel) {
        //Davik虛擬機實現(xiàn) 是在libdvm.so中
        //dlopen()方法以指定模式打開動態(tài)鏈接庫筏餐,RTLD_NOW立即打開
        void* dvm_hand = dlopen("libdvm.so", RTLD_NOW);
        if (dvm_hand) {
            //dvm_dlsym:通過句柄和連接符名稱獲取函數(shù)或變量名
            dvmDecodeIndirectRef_fnPtr = dvm_dlsym(dvm_hand,
                    apilevel > 10 ?
                            "_Z20dvmDecodeIndirectRefP6ThreadP8_jobject" :
                            "dvmDecodeIndirectRef");
            if (!dvmDecodeIndirectRef_fnPtr) {
                return JNI_FALSE;
            }
            dvmThreadSelf_fnPtr = dvm_dlsym(dvm_hand,
                    apilevel > 10 ? "_Z13dvmThreadSelfv" : "dvmThreadSelf");
            if (!dvmThreadSelf_fnPtr) {
                return JNI_FALSE;
            }
            jclass clazz = env->FindClass("java/lang/reflect/Method");
            jClassMethod = env->GetMethodID(clazz, "getDeclaringClass",
                            "()Ljava/lang/Class;");
    
            return JNI_TRUE;
        } else {
            return JNI_FALSE;
        }
    }

該方法進行的操作主要是打開運行dalvik虛擬機的libdvm.so,得到dvmDecodeIndirectRef_fnPtr牡拇、dvmThreadSelf_fnPtr函數(shù)魁瞪,下面將用到這兩個函數(shù)獲取類對象。

接下來我們進入整個AndFix最核心的dalvik_replaceMethod()方法中惠呼,在其中進行了對類方法指針的替換导俘,真正實現(xiàn)對方法的替換。


    extern void __attribute__ ((visibility ("hidden"))) dalvik_replaceMethod(
        JNIEnv* env, jobject src, jobject dest) {
    //clazz為被替換的類
    jobject clazz = env->CallObjectMethod(dest, jClassMethod);
    //clz 為被替換的類對象
    ClassObject* clz = (ClassObject*) dvmDecodeIndirectRef_fnPtr(
            dvmThreadSelf_fnPtr(), clazz);
    //將類狀態(tài)設(shè)置為裝載完畢
    clz->status = CLASS_INITIALIZED;
    //得到指向新方法的指針
    Method* meth = (Method*) env->FromReflectedMethod(src);
    //得到指向需要修復(fù)的目標(biāo)方法的指針
    Method* target = (Method*) env->FromReflectedMethod(dest);
    LOGD("dalvikMethod: %s", meth->name);


    //新方法指向目標(biāo)方法剔蹋,實現(xiàn)方法的替換
    //  meth->clazz = target->clazz;
    meth->accessFlags |= ACC_PUBLIC;
    meth->methodIndex = target->methodIndex;
    meth->jniArgInfo = target->jniArgInfo;
    meth->registersSize = target->registersSize;
    meth->outsSize = target->outsSize;
    meth->insSize = target->insSize;

    meth->prototype = target->prototype;
    meth->insns = target->insns;
    meth->nativeFunc = target->nativeFunc;
}

ART

art部分根據(jù)版本號的不同旅薄,進行了不同的處理。

image
void replace_5_0(JNIEnv* env, jobject src, jobject dest) {
    //獲得指向新的方法的指針
    art::mirror::ArtMethod* smeth =
            (art::mirror::ArtMethod*) env->FromReflectedMethod(src);
            
    //獲得指向被替換的目標(biāo)方法的指針
    art::mirror::ArtMethod* dmeth =
            (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);
    //目標(biāo)方法的裝載器和方法中聲明類設(shè)置為新方法對應(yīng)值
    dmeth->declaring_class_->class_loader_ =
            smeth->declaring_class_->class_loader_;
    dmeth->declaring_class_->clinit_thread_id_ =
            smeth->declaring_class_->clinit_thread_id_;
    dmeth->declaring_class_->status_ = smeth->declaring_class_->status_-1;

    //新方法指向目標(biāo)方法滩租,實現(xiàn)方法的替換
    smeth->declaring_class_ = dmeth->declaring_class_;
    smeth->access_flags_ = dmeth->access_flags_;
    smeth->frame_size_in_bytes_ = dmeth->frame_size_in_bytes_;
    smeth->dex_cache_initialized_static_storage_ =
            dmeth->dex_cache_initialized_static_storage_;
    smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
    smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
    smeth->vmap_table_ = dmeth->vmap_table_;
    smeth->core_spill_mask_ = dmeth->core_spill_mask_;
    smeth->fp_spill_mask_ = dmeth->fp_spill_mask_;
    smeth->mapping_table_ = dmeth->mapping_table_;
    smeth->code_item_offset_ = dmeth->code_item_offset_;
    smeth->entry_point_from_compiled_code_ =
            dmeth->entry_point_from_compiled_code_;

    smeth->entry_point_from_interpreter_ = dmeth->entry_point_from_interpreter_;
    smeth->native_method_ = dmeth->native_method_;
    smeth->method_index_ = dmeth->method_index_;
    smeth->method_dex_index_ = dmeth->method_dex_index_;

    LOGD("replace_5_0: %d , %d", smeth->entry_point_from_compiled_code_,
            dmeth->entry_point_from_compiled_code_);

}

總體的過程總結(jié)如下:

  1. 初始化patch管理器赋秀,加載補独洹律想;
  2. 檢查手機是否支持,判斷ART绍弟、Dalvik技即;
  3. 進行md5,指紋的安全檢查
  4. 驗證補丁的配置樟遣,通過patch-classes字段得到要替換的所有類
  5. 通過注解從類中得到具體要替換的方法
  6. 修改方法的訪問權(quán)限為public
  7. 得到指向新方法和被替換目標(biāo)方法的指針而叼,將新方法指向目標(biāo)方法,完成方法的替換豹悬。

AndFix提供了一種Native層hook Java層代碼的思路葵陵,實現(xiàn)了動態(tài)的替換方法。在處理簡單沒有特別復(fù)雜的方法中有獨特的優(yōu)勢瞻佛,但因為在加載類時跳過了類裝載過程直接設(shè)置為初始化完畢脱篙,所以不支持新增靜態(tài)變量和方法。

源碼傳送門

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末伤柄,一起剝皮案震驚了整個濱河市绊困,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌适刀,老刑警劉巖秤朗,帶你破解...
    沈念sama閱讀 217,734評論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異笔喉,居然都是意外死亡取视,警方通過查閱死者的電腦和手機硝皂,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,931評論 3 394
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來作谭,“玉大人吧彪,你說我怎么就攤上這事《纾” “怎么了姨裸?”我有些...
    開封第一講書人閱讀 164,133評論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長怨酝。 經(jīng)常有香客問我傀缩,道長,這世上最難降的妖魔是什么农猬? 我笑而不...
    開封第一講書人閱讀 58,532評論 1 293
  • 正文 為了忘掉前任赡艰,我火速辦了婚禮,結(jié)果婚禮上斤葱,老公的妹妹穿的比我還像新娘慷垮。我一直安慰自己,他們只是感情好揍堕,可當(dāng)我...
    茶點故事閱讀 67,585評論 6 392
  • 文/花漫 我一把揭開白布料身。 她就那樣靜靜地躺著,像睡著了一般衩茸。 火紅的嫁衣襯著肌膚如雪芹血。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,462評論 1 302
  • 那天楞慈,我揣著相機與錄音幔烛,去河邊找鬼。 笑死囊蓝,一個胖子當(dāng)著我的面吹牛饿悬,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播聚霜,決...
    沈念sama閱讀 40,262評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼狡恬,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了俯萎?” 一聲冷哼從身側(cè)響起傲宜,我...
    開封第一講書人閱讀 39,153評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎夫啊,沒想到半個月后函卒,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,587評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,792評論 3 336
  • 正文 我和宋清朗相戀三年报嵌,在試婚紗的時候發(fā)現(xiàn)自己被綠了虱咧。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,919評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡锚国,死狀恐怖腕巡,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情血筑,我是刑警寧澤绘沉,帶...
    沈念sama閱讀 35,635評論 5 345
  • 正文 年R本政府宣布,位于F島的核電站豺总,受9級特大地震影響车伞,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜喻喳,卻給世界環(huán)境...
    茶點故事閱讀 41,237評論 3 329
  • 文/蒙蒙 一另玖、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧表伦,春花似錦谦去、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,855評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至翔怎,卻和暖如春窃诉,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背赤套。 一陣腳步聲響...
    開封第一講書人閱讀 32,983評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留珊膜,地道東北人容握。 一個月前我還...
    沈念sama閱讀 48,048評論 3 370
  • 正文 我出身青樓,卻偏偏與公主長得像车柠,于是被迫代替她去往敵國和親剔氏。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,864評論 2 354

推薦閱讀更多精彩內(nèi)容