ARTHook(一)

作者: LSteven | 来源:发表于2020-10-18 00:37 被阅读0次

最近对art虚拟机比较感兴趣,因此就选了ARTHook作为切入点(深入)理解下。选了比较有名的epic,本身该框架考虑的点比较完善,api也比较友好,挺适合学习的。

ARTMethod

Java 对象在内存中的布局可以看成一个结构体,父类的变量在开头,本身的变量紧随其后。
这些对象结构体在 ART 中被映射成 mirror::Object cpp 类

// C++ mirror of java.lang.reflect.Method.
class MANAGED Method : public Executable {
  ....
}
// C++ mirror of java.lang.reflect.Executable.
class MANAGED Executable : public AccessibleObject {
  uint16_t has_real_parameter_data_;
  HeapReference<mirror::Class> declaring_class_;
  HeapReference<mirror::Class> declaring_class_of_overridden_method_;
  HeapReference<mirror::Array> parameters_;
  // ArtMethod 地址
  uint64_t art_method_;
  uint32_t access_flags_;
  uint32_t dex_method_index_;
}

对于java.lang.reflect.Method(java对象),其父类在AndroidO以上为java.lang.reflect.Executable。通过获取Executable中的artMethod变量,可以拿到ArtMethod

LinkCode

看老罗的art类加载就知道了,art中分解释执行与本地机器指令执行。所以就会存在以下几种情况:

  • 解释执行函数调用本地执行函数
  • 本地执行函数调用解释执行函数
  • 解释进入解释
  • 本地进入本地

所以就衍生除了很多enteyCode

static void LinkCode(ClassLinker* class_linker,
                     ArtMethod* method,
                     const OatFile::OatClass* oat_class,
                     uint32_t class_def_method_index) REQUIRES_SHARED(Locks::mutator_lock_) {
  if (oat_class != nullptr) {
    // 判断方法是否已经被 OAT 
    const OatFile::OatMethod oat_method = oat_class->GetOatMethod(class_def_method_index);
    oat_method.LinkMethod(method);
  }

  // Install entry point from interpreter.
  const void* quick_code = method->GetEntryPointFromQuickCompiledCode();
  bool enter_interpreter = class_linker->ShouldUseInterpreterEntrypoint(method, quick_code);

//如果是从解释器进来的
if (enter_interpreter) { //如果要进入解释执行,那就是解释进入解释
    method->SetEntryPointFromInterpreter(interpreter::artInterpreterToInterpreterBridge);
  } else { //解释进入本地
    method->SetEntryPointFromInterpreter(artInterpreterToCompiledCodeBridge);
  }

//如果是从本地指令进来的
  if (method->IsStatic() && !method->IsConstructor()) {
    // 对于静态方法,后面会在 ClassLinker::InitializeClass 里被 ClassLinker::FixupStaticTrampolines 替换掉,先设置为stub,后面要考
    method->SetEntryPointFromQuickCompiledCode(GetQuickResolutionStub());
  } else if (quick_code == nullptr && method->IsNative()) {
    // Native 方法跳转到 JNI
    method->SetEntryPointFromQuickCompiledCode(GetQuickGenericJniStub());
  } else if (enter_interpreter) {
    // 解释模式,跳转到解释器
    method->SetEntryPointFromQuickCompiledCode(GetQuickToInterpreterBridge());
  }
  // ...
}

Q: 如何判断thumb指令还是art指令

在ARM-v7A中常使用32位ARM指令集并且支持thumb指令集与arm的切换,而在ARMV8中使用的是64位ARM指令集且不再有thumb指令集状态的切换了.

目前,ARM是三级流水线,因此,当CPU在执行S指令的时候,PC指向的是S+2指令。但是当手动向PC赋值,则是让CPU跳转到赋入的值 所代表的地址去运行。

注:通常PC指针指向的地址都是4字节对齐,即地址的[1:0]位总是为0,这也是我们说的ARM模式。现在很多CPU都支持混合编码即同时支持ARM指令和Thumb指令,因此为了区分Thumb指令,ARM将[0]位设置成1,即地址最低位如果是1,表示当前指令是Thumb指令,否则为ARM指令。Thumb模式到ARM模式可以通过带X的跳转进行切换

区分可使用:

isThumb = ((entryPointFromQuickCompiledCode & 1) == 1);

Q: BL/BLX等跳转指令区别

区别

Q: JNI中的 jobject,Java中的Object,ART 中的 art::mirror::Object 到底是个什么关系

art::mirror::Object 是 Java的Object在Runtime中的表示,java.lang.Object的地址就是art::mirror::Object的地址;但是jobject略有不同,它并非地址,而是一个句柄(或者说透明引用)。为何如此?

因为JNI对于ART来说是外部环境,如果直接把ART中的对象地址交给JNI层(也就是jobject直接就是Object的地址),其一不是很安全,其二直接暴露内部实现不妥。就拿GC来说,虚拟机在GC过程中很可能移动对象,这样对象的地址就会发生变化,如果JNI直接使用地址,那么对GC的实现提出了很高要求。因此,典型的Java虚拟机对JNI的支持中,jobject都是句柄(或者称之为透明引用);ART虚拟机内部可以在joject与 art::mirror::Object中自由转换,但是JNI层只能拿这个句柄去标志某个对象。

转换见这个

Q: ART函数的调用约定

Thumb2为例,子函数调用的参数传递是通过寄存器r0~r3 以及sp寄存器完成的。r0 ~ r3 依次传递第一个至第4个参数,同时 sp, (sp + 4), (sp + 8), (sp + 12) 也存放着r0~r3上对应的值;

在ART中,r0寄存器固定存放被调用方法的ArtMethod指针,如果是non-static 方法,r1寄存器存放方法的this对象;

Q: dex_cache是什么,什么时候引入

dex_cache_resolved_methods_是一个指针数组,保存的是ArtMethod结构指针。。顾名思义,这个数组用于缓存解析的方法。通过它可以获得ArtMethod所在dex所有Method对应的ArtMethod*。

各个版本介绍

Q: 什么是unSafe

Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力

unSafe介绍

unSafe介绍2

如下就是怎么object&address互转


public static long getObjectAddress(Object obj) {
    try {
        Object[] array = new Object[]{obj};
        if (arrayIndexScale(Object[].class) == 8) {
            return getLong(array, arrayBaseOffset(Object[].class));
        } else {
            return 0xffffffffL & getInt(array, arrayBaseOffset(Object[].class));
        }
    } catch (Exception e) {
        Log.w(TAG, e);
        return -1;
    }
}

/**
 * get Object from address, refer: http://mishadoff.com/blog/java-magic-part-4-sun-dot-misc-dot-unsafe/
 * @param address the address of a object.
 * @return
 */
public static Object getObject(long address) {
    Object[] array = new Object[]{null};
    long baseOffset = arrayBaseOffset(Object[].class);
    if (Runtime.is64Bit()) {
        putLong(array, baseOffset, address);
    } else {
        putInt(array, baseOffset, (int) address);
    }
    return array[0];
}

备份

对于要hook的函数,首先对其进行备份:
看下面的代码得出:
Method中有变量artMethodjava.lang.reflect.Executable.artMethod,所以就创建一个新的Method,拷贝其中的artMethod即可。

Class<?> abstractMethodClass = Method.class.getSuperclass();

Object executable = this.getExecutable();
ArtMethod artMethod;
if (Build.VERSION.SDK_INT < 23) {
    Class<?> artMethodClass = Class.forName("java.lang.reflect.ArtMethod");
    //Get the original artMethod field, 拿到artMethod字段
    Field artMethodField = abstractMethodClass.getDeclaredField("artMethod");
    if (!artMethodField.isAccessible()) {
        artMethodField.setAccessible(true);
    }
    Object srcArtMethod = artMethodField.get(executable);
    //创建一个artmethod
    Constructor<?> constructor = artMethodClass.getDeclaredConstructor();
    constructor.setAccessible(true);
    Object destArtMethod = constructor.newInstance();

    //Fill the fields to the new method we created
    for (Field field : artMethodClass.getDeclaredFields()) {
        if (!field.isAccessible()) {
            field.setAccessible(true);
        }
        field.set(destArtMethod, field.get(srcArtMethod));
    }
    Method newMethod = Method.class.getConstructor(artMethodClass).newInstance(destArtMethod);
    newMethod.setAccessible(true);
    artMethod = ArtMethod.of(newMethod);

    artMethod.setEntryPointFromQuickCompiledCode(getEntryPointFromQuickCompiledCode());
    artMethod.setEntryPointFromJni(getEntryPointFromJni());
} else {
    //private java.lang.reflect.Method()
    Constructor<Method> constructor = Method.class.getDeclaredConstructor();
    // we can't use constructor.setAccessible(true); because Google does not like it
    // AccessibleObject.setAccessible(new AccessibleObject[]{constructor}, true);
    Field override = AccessibleObject.class.getDeclaredField(
            Build.VERSION.SDK_INT == Build.VERSION_CODES.M ? "flag" : "override");
    override.setAccessible(true);
    override.set(constructor, true);

    Method m = constructor.newInstance();
    m.setAccessible(true);
    for (Field field : abstractMethodClass.getDeclaredFields()) {
        field.setAccessible(true);
        field.set(m, field.get(executable));
    }
    Field artMethodField = abstractMethodClass.getDeclaredField("artMethod");
    //private long java.lang.reflect.Executable.artMethod
    artMethodField.setAccessible(true);
    int artMethodSize = getArtMethodSize();
    long memoryAddress = EpicNative.map(artMethodSize);

    byte[] data = EpicNative.get(address, artMethodSize);
    EpicNative.put(data, memoryAddress);
    artMethodField.set(m, memoryAddress);
    // From Android R, getting method address may involve the jni_id_manager which uses
    // ids mapping instead of directly returning the method address. During resolving the
    // id->address mapping, it will assume the art method to be from the "methods_" array
    // in class. However this address may be out of the range of the methods array. Thus
    // it will cause a crash during using the method offset to resolve method array.
    artMethod = ArtMethod.of(m, memoryAddress);
}
artMethod.makePrivate();
artMethod.setAccessible(true);
artMethod.origin = this; // save origin method.
return artMethod;

basic

AbstractMethod类中对应的artMethod属性的值可以作为c层ArtMethod的地址直接使用,即我们在Java拿到的artMethod就是c层ArtMethod的实际地址

@@art/mirror/abstract_method.cc
ArtMethod* AbstractMethod::GetArtMethod() {
  return reinterpret_cast<ArtMethod*>(GetField64(ArtMethodOffset()));
}

@@art/mirror/abstract_method.h
static MemberOffset ArtMethodOffset() {
    return MemberOffset(OFFSETOF_MEMBER(AbstractMethod, art_method_));
}

Q 如何获取方法大小

ArtMethod 被存放在线性内存区域,并且不会 Moving GC,那么,相邻的两个方法他们的 ArtMethod 也是相邻的,所以 size = ArtMethod2 - ArtMethod1

public static int getArtMethodSize() {
    if (artMethodSize > 0) {
        return artMethodSize;
    }
    final Method rule1 = XposedHelpers.findMethodExact(ArtMethod.class, "rule1");
    final Method rule2 = XposedHelpers.findMethodExact(ArtMethod.class, "rule2");
    final long rule2Address = EpicNative.getMethodAddress(rule2);
    final long rule1Address = EpicNative.getMethodAddress(rule1);
    final long size = Math.abs(rule2Address - rule1Address);
    artMethodSize = (int) size;
    Logger.d(TAG, "art Method size: " + size);
    return artMethodSize;
}

这里看到epic很讨巧,在一个自定义的ArtMethod中加了两个函数,然后算这两个函数的地址差。

jlong epic_getMethodAddress(JNIEnv *env, jclass clazz, jobject method) {
    jlong art_method = (jlong) env->FromReflectedMethod(method);

这里获取地址用了FromReflectedMethod,env->FromReflectedMethod(src) 返回的是 jmethodID ,事实上就是 ArtMethod 结构体的指针地址,所以可以强制类型转换成 ArtMethod 结构体指针

静态方法

art_quick_resolution_trampoline, 同理androidN以上默认不进行aot编译。

哪些不同的Java方法会具有相同的compiled_code入口点呢?

1、所有ART版本上未被resolve的static函数 art_quick_resolution_trampoline

2、Android N 以上的未被编译的所有函数

3、代码逻辑一模一样的函数

4、JNI函数

所以epic进行了强制编译:
artOrigin.ensureResolved();

public Object invoke(Object receiver, Object... args) throws IllegalAccessException, InvocationTargetException, InstantiationException {

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 
        if (origin != null) {
            byte[] currentAddress = EpicNative.get(origin.address, 4);
            byte[] backupAddress = EpicNative.get(address, 4);
            if (!Arrays.equals(currentAddress, backupAddress)) {
                if (Debug.DEBUG) {
                    Logger.i(TAG, "the address of java method was moved by gc, backup it now! origin address: 0x"
                            + Arrays.toString(currentAddress) + " , currentAddress: 0x" + Arrays.toString(backupAddress));
                }
                EpicNative.put(currentAddress, address);
                return invokeInternal(receiver, args);
            } else {
                Logger.i(TAG, "the address is same with last invoke, not moved by gc");
            }
        }
    }

    return invokeInternal(receiver, args);
}

//在N以下,如果是构造函数,触发一次instance,否则就执行一次method即可。
private Object invokeInternal(Object receiver, Object... args) throws IllegalAccessException, InvocationTargetException, InstantiationException {
    if (constructor != null) {
        return constructor.newInstance(args);
    } else {
        return method.invoke(receiver, args);
    }
}

对于当前还是解释执行

则需要编译一次artOrigin.compile()

简单而言就通过jit_compile_method即可。jit_compile_method_ = (bool (*)(void *, void *, void *, bool)) dlsym_ex(jit_lib, "jit_compile_method");

jboolean epic_compile(JNIEnv *env, jclass, jobject method, jlong self) {
    LOGV("self from native peer: %p, from register: %p", reinterpret_cast<void*>(self), __self());
    jlong art_method = (jlong) env->FromReflectedMethod(method);
    if (art_method % 2 == 1) {
        art_method = reinterpret_cast<jlong>(JniIdManager_DecodeMethodId_(ArtHelper::getJniIdManager(), art_method));
    }
    bool ret;
    if (api_level >= 29) {
        ret = ((JIT_COMPILE_METHOD2) jit_compile_method_)(jit_compiler_handle_,
                                                          reinterpret_cast<void *>(art_method),
                                                          reinterpret_cast<void *>(self), false, false);
    } else {
        ret = ((JIT_COMPILE_METHOD1) jit_compile_method_)(jit_compiler_handle_,
                                                          reinterpret_cast<void *>(art_method),
                                                          reinterpret_cast<void *>(self), false);
    }
    return (jboolean)ret;
}

下面还是进入正题开始跳转

Trampoline

1511354138004.png

epic的精髓就是callee跳转,即被调用方跳转。为啥要设置两段跳板呢,因为二端跳板其实很长,在原有的artMethod的compileCode指向的方法代码中可能放不下

1. 创建二段跳板

(这里不说怎么看thumb指令了自己学)

r0指的是当前artMethod的地址,如果与source method地址不相同,则直接执行原方法。

其实很简单,先看当前的r0和你想hook的method是不是同一个,
然后把sp, r2,r3, source_method_address放到ip指向的struct,然后跳到target_method_entry_point执行。

byte[] instructions = new byte[]{

(byte) 0xdf, (byte) 0xf8, (byte) 0x30, (byte) 0xc0, // ldr ip, [pc, #48] ip = source method address

(byte) 0x60, (byte) 0x45,                           // cmp r0, ip        if r0 != ip
(byte) 0x40, (byte) 0xf0, (byte) 0x19, (byte) 0x80, // bne.w 1f          jump label 1:
(byte) 0x08, (byte) 0x48,                           // ldr r0, [pc, #28] r0 = target_method_address
(byte) 0xdf, (byte) 0xf8, (byte) 0x28, (byte) 0xc0, // ldr ip, [pc, #38] ip = struct address
(byte) 0xcc, (byte) 0xf8, (byte) 0x00, (byte) 0xd0, // str sp, [ip, #0]
(byte) 0xcc, (byte) 0xf8, (byte) 0x04, (byte) 0x20, // str r2, [ip, #4]
(byte) 0xcc, (byte) 0xf8, (byte) 0x08, (byte) 0x30, // str r3, [ip, #8]

(byte) 0x63, (byte) 0x46,                           // mov r3, ip
(byte) 0x05, (byte) 0x4a,                           // ldr r2, [pc, #16] r2 = source_method_address
(byte) 0xcc, (byte) 0xf8, (byte) 0x0c, (byte) 0x20, // str r2, [ip, #12]
(byte) 0x4a, (byte) 0x46,                           // move r2, r9
(byte) 0x4a, (byte) 0x46,                           // move r2, r9

(byte) 0xdf, (byte) 0xf8, (byte) 0x04, (byte) 0xf0, // ldr pc, [pc, #4]

0x0, 0x0, 0x0, 0x0,                             // target_method_pos_x
0x0, 0x0, 0x0, 0x0,                             // target_method_entry_point
0x0, 0x0, 0x0, 0x0,                             // src_method_address
0x0, 0x0, 0x0, 0x0,                             // struct address (sp, r1, r2)
// 1:
};

所以这段就是构造参数然后跳到图上的java bridge执行。(bridge是什么后面说)

有关堆栈平衡

如果我们在二段跳板代码里面开辟堆栈,进而修改了sp寄存器;那么在我们修改sp到调用bridge函数的这段时间里,堆栈结构与不Hook的时候是不一样的(虽然bridge函数执行完毕之后我们可以恢复正常);在这段时间里如果虚拟机需要进行栈回溯,sp被修改的那一帧会由于回溯不到对应的函数引发致命错误,导致Runtime 直接Abort。

对于堆栈平衡而言,最重要即为esp不能修改。

Trampoline 一段跳转指令

执行方法时不执行quickCompileCode地址,跳到target pc执行。即跳到二段代码。

private boolean activate() {
    long pc = getTrampolinePc();
    Logger.d(TAG, "Writing direct jump entry " + Debug.addrHex(pc) + " to origin entry: 0x" + Debug.addrHex(jumpToAddress));
    synchronized (Trampoline.class) {
        return EpicNative.activateNative(jumpToAddress, pc, shellCode.sizeOfDirectJump(),
                shellCode.sizeOfBridgeJump(), shellCode.createDirectJump(pc));
    }
}

正常来说这里跟二段跳板一下改一下内存就行,但这里跳到了native:
我们先看传入的参数:

  • jumpToAddress 原方法的entryCode
  • pc 二段跳板的地址
  • sizeOfDirectJump
  • sizeOfBridgeJump
  • createDirectJump(pc) 构造跳到二段跳板的方法

整个方法即把sourceMethod.entryCode的前面几个字节改成跳二段跳板的地址。【跳】二段跳板=一段跳板

createDirectJump

@Override
public byte[] createDirectJump(long targetAddress) {
    byte[] instructions = new byte[] {
            (byte) 0xdf, (byte) 0xf8, 0x00, (byte) 0xf0,        // ldr pc, [pc]
            0, 0, 0, 0
    };
    writeInt((int) targetAddress, ByteOrder.LITTLE_ENDIAN, instructions,
            instructions.length - 4);
    return instructions;
}

看代码和分析,从androidN开始因为引入了混合编译,所以随时jit线程都有可能更改code。为了原子操作所以需要暂停所有线程操作。

jboolean epic_activate(JNIEnv* env, jclass jclazz, jlong jumpToAddress, jlong pc, jlong sizeOfDirectJump,
                       jlong sizeOfBridgeJump, jbyteArray code) {

    // fetch the array, we can not call this when thread suspend(may lead deadlock)
    jbyte *srcPnt = env->GetByteArrayElements(code, 0);
    jsize length = env->GetArrayLength(code);

    jlong cookie = 0;
    bool isNougat = api_level >= 24;
    if (isNougat) {
        // We do thus things:
        // 1. modify the code mprotect
        // 2. modify the code

        // Ideal, this two operation must be atomic. Below N, this is safe, because no one
        // modify the code except ourselves;
        // But in Android N, When the jit is working, between our step 1 and step 2,
        // if we modity the mprotect of the code, and planning to write the code,
        // the jit thread may modify the mprotect of the code meanwhile
        // we must suspend all thread to ensure the atomic operation.

        LOGV("suspend all thread.");
        cookie = epic_suspendAll(env, jclazz);
    }

    jboolean result = epic_munprotect(env, jclazz, jumpToAddress, sizeOfDirectJump);
    if (result) {
        unsigned char *destPnt = (unsigned char *) jumpToAddress;
        for (int i = 0; i < length; ++i) {
            destPnt[i] = (unsigned char) srcPnt[i];
        }
        jboolean ret = epic_cacheflush(env, jclazz, pc, sizeOfBridgeJump);
        if (!ret) {
            LOGV("cache flush failed!!");
        }
    } else {
        LOGV("Writing hook failed: Unable to unprotect memory at %d", jumpToAddress);
    }

    if (cookie != 0) {
        LOGV("resume all thread.");
        epic_resumeAll(env, jclazz, cookie);
    }

    env->ReleaseByteArrayElements(code, srcPnt, 0);
    return result;
}

origin

除了Trampoline代码外,还有一段老逻辑,这也是前面jump label 1:可以直接跳到方法最后的原因,因为原方法就拼在后面。

for (ArtMethod method : segments) {
    byte[] bridgeJump = createTrampoline(method);
    int length = bridgeJump.length;
    System.arraycopy(bridgeJump, 0, mainPage, offset, length);
    offset += length;
}

byte[] callOriginal = shellCode.createCallOrigin(jumpToAddress, originalCode);
System.arraycopy(callOriginal, 0, mainPage, offset, callOriginal.length);

origin为2*directJump指令的大小。
第一段为原originalPrologue(即原quickCompileCode取directJump大小的指令)
第二段为createDirectJump:

byte[] instructions = new byte[] {
(byte) 0xdf, (byte) 0xf8, 0x00, (byte) 0xf0,        // ldr pc, [pc]
0, 0, 0, 0
};
public byte[] createCallOrigin(long originalAddress, byte[] originalPrologue) {
    byte[] callOriginal = new byte[sizeOfCallOrigin()];
    System.arraycopy(originalPrologue, 0, callOriginal, 0, sizeOfDirectJump()); //(Object src, int srcPos, Object dest, int destPos, int length)
    byte[] directJump = createDirectJump(toPC(originalAddress + sizeOfDirectJump()));
    System.arraycopy(directJump, 0, callOriginal, sizeOfDirectJump(), directJump.length);
    return callOriginal;
}

排布即为:

//先原ArtMethod从compileCode开始的几个字节
//directJump代码,跳到一段代码后面的代码

可以理解为将原先的compileCode分成A+B A变成一段代码,即跳到B。

一段跳转代码都放不下

if (quickCompiledCodeSize < sizeOfDirectJump) {
    Logger.w(TAG, originMethod.toGenericString() + " quickCompiledCodeSize: " + quickCompiledCodeSize);
    originMethod.setEntryPointFromQuickCompiledCode(getTrampolinePc());
    return true;
}

其他知识

entrypoint replacement
但定义在boot.oat里的代码都已经是用绝对地址访问:

boot.oat里面如果要使用某个类、field、method,只要它在boot.art中被定义,那么就可以直接使用决定地址来访问。因为boot.oat 这个文件在内存中的加载地址是固定的

Android7.0

开始混合编译,Android N采用了混合编译的模式,既有解释执行,也有AOT和JIT;APK刚安装完毕是解释执行的,运行时JIT会收集方法调用信息,必要的时候直接编译此方法,甚至栈上替换

Android8.0

跨dex方法内联

Android 9.0

私有api调用限制 使用自己解析elf文件使用dlopen&dlsym访问

todo

http://rk700.github.io/2017/03/30/YAHFA-introduction/

相关文章

  • ARTHook(一)

    最近对art虚拟机比较感兴趣,因此就选了ARTHook作为切入点(深入)理解下。选了比较有名的epic,本身该框架...

  • Android卡顿优化 | 卡顿单点问题监测方案

    本文要点背景介绍监测指标常规方案IPC问题监测技巧相对优雅的方案【ARTHook】ARTHook实战小结 项目Gi...

  • 。一一,一,一,一。

    一,、

  • 一 一

    2018年6月22日 星期五 雨 一水一万物 一星一宇宙 一字一文章 一书一世界 一读一微笑 一赞一知音

  • 一 一

    杨德昌《一 一》,早年曾看过一遍。 婷婷短发,白净,蓝色衬衫,学生裙,黑皮鞋,白袜子,学习很好的中学女生。温柔,懂...

  • 一 一

    给自己无处安放的灵魂找到了家!简书,我的新写作时光!继续,在流年里拾荒,禅落一身的光!

  • 一.一

  • 一.一

    一节车厢,一只行囊,肯为当时一念疯狂。 一根点燃,一缕惆怅,不许未来一片迷茫。 一眼远看,一众不详,哪知各位一去何...

  • 一(一)

    我叫一,总有人喜欢在背后说我,因为很多时候我都是自己一个人。很多人都说我很孤单,看起来很可怜,但我觉得很奇怪,他们...

  • (一-一)

    白天不看书晚上开灯照亮全宿舍的sb们该睡了

网友评论

    本文标题:ARTHook(一)

    本文链接:https://www.haomeiwen.com/subject/rjsqmktx.html