微信资源混淆AndResGuard源码解析(二)

apk学习系列

Posted by Cc1over on August 9, 2019

微信资源混淆AndResGuard源码解析(二)

前言

上一篇主要是针对整个resource.arsc文件的解析作分析,而这一篇除了上一篇的忽略的两个点之外,更多就是写回操作的分析

[ApkDecoder-> decode]

public void decode() throws AndrolibException, IOException, DirectoryException {
    if (hasResources()) {
      ensureFilePath();
      // read the resources.arsc checking for STORED vs DEFLATE compression
      // this will determine whether we compress on rebuild or not.
      System.out.printf("decoding resources.arsc\n");
      RawARSCDecoder.decode(apkFile.getDirectory().getFileInput("resources.arsc"));
      ResPackage[] pkgs = ARSCDecoder.decode(apkFile.getDirectory().getFileInput("resources.arsc"), this);

      //把没有纪录在resources.arsc的资源文件也拷进dest目录
      copyOtherResFiles();

      ARSCDecoder.write(apkFile.getDirectory().getFileInput("resources.arsc"), this, pkgs);
    }
  }

[ARSCDecoder-> write]

public static void write(InputStream arscStream, ApkDecoder decoder, ResPackage[] pkgs) throws AndrolibException {
    try {
      ARSCDecoder writer = new ARSCDecoder(arscStream, decoder, pkgs);
      writer.writeTable();
    } catch (IOException ex) {
      throw new AndrolibException("Could not decode arsc file", ex);
    }
  }

[ARSCDecoder-> writeTable]

private void writeTable() throws IOException, AndrolibException {
    
    mTableLenghtChange = 0;
    writeNextChunkCheck(Header.TYPE_TABLE, 0);
    int packageCount = mIn.readInt();
    mOut.writeInt(packageCount);
    // 1
    mTableLenghtChange += StringBlock.writeTableNameStringBlock(mIn, mOut, mTableStringsResguard);
    writeNextChunk(0);
    
    for (int i = 0; i < packageCount; i++) {
      mCurPackageID = i;
      writePackage();
    }
    // 最后需要把整个的size重写回去
    reWriteTable();
  }
  • 这个写入操作对于一些table的数据边读边写的策略
  • 注释1:带着解析操作填充好的mTableStringsResguard这个map去执行全局常量池的写入操作
  • 执行packages的写入操作
  • 执行table的size写入和拷贝过程
  • 这里的逻辑思路大概就是因为,做完资源混淆之后,table的长度变化了,而现在解析完的这个阶段,这些映射关系是存在内存中的,直接用内存中的map的关系计算出变化的长度不是很合适,所以就创建一个临时文件,先把混淆后的结果写到这个临时文件中,然后边写边计算出table的变化长度,然后,再把长度信息,和临时文件里的信息写回到正式文件中

[StringBlock-> writeTableNameStringBlock]

public static int writeTableNameStringBlock(
      ExtDataInput reader, ExtDataOutput out, Map<Integer, String> tableProguardMap)
      throws IOException, AndrolibException {
    int type = reader.readInt();
    int chunkSize = reader.readInt();
    int stringCount = reader.readInt();
    int styleOffsetCount = reader.readInt();
    int flags = reader.readInt();
    int stringsOffset = reader.readInt();
    int stylesOffset = reader.readInt();

    StringBlock block = new StringBlock();
    block.m_isUTF8 = (flags & UTF8_FLAG) != 0;
    if (block.m_isUTF8) {
      System.out.printf("resources.arsc Character Encoding: utf-8\n");
    } else {
      System.out.printf("resources.arsc Character Encoding: utf-16\n");
    }

    block.m_stringOffsets = reader.readIntArray(stringCount);
    block.m_stringOwns = new int[stringCount];
    for (int i = 0; i < stringCount; i++) {
      block.m_stringOwns[i] = -1;
    }
    if (styleOffsetCount != 0) {
      block.m_styleOffsets = reader.readIntArray(styleOffsetCount);
    }
}
  • 首先就是把全局常量池中的从chunk header开始到style这部分的信息,读取出来,但是这里埋下的一个伏笔就是stringOwns中的项先置为了-1
{
      int size = ((stylesOffset == 0) ? chunkSize : stylesOffset) - stringsOffset;
      if ((size % 4) != 0) {
        throw new IOException("String data size is not multiple of 4 (" + size + ").");
      }
      block.m_strings = new byte[size];
      reader.readFully(block.m_strings);
    }
  • 这里的逻辑就是计算出strings的长度,因为要考虑到由styles和没有styles的情况,所以计算规则则是由styles偏移量不为0即styles是存在的,那么直接把styles的偏移量减去strings的偏移量就是strings的长度了,如果没有那么就以为着strings的结尾就是整个块的结尾了,所以直接用chunksize减就可以了
  • 然后这里有一个判断就是这个strings的长度必须是4的倍数
  • 然后就是填充这个strings数组
if (stylesOffset != 0) {
      int size = (chunkSize - stylesOffset);
      if ((size % 4) != 0) {
        throw new IOException("Style data size is not multiple of 4 (" + size + ").");
      }
      block.m_styles = reader.readIntArray(size / 4);
    }
  • 这里的逻辑也类似,就是当styles存在的时候,styles的长度也必须是4的倍数
  int totalSize = 0;
  out.writeCheckInt(type, CHUNK_STRINGPOOL_TYPE);
  totalSize += 4;
   
  totalSize += 6 * 4 + 4 * stringCount + 4 * styleOffsetCount;
  stringsOffset = totalSize;
  byte[] strings = new byte[block.m_strings.length];
  int[] stringOffsets = new int[stringCount];
  System.arraycopy(block.m_stringOffsets, 0, stringOffsets, 0, stringOffsets.length);
  • 这里就是一路计算到strings的偏移量的位置,然后重新创建一个strings和stringsOffsets数组,然后先把原来的stringsOffsets拷贝过去
  • 这个totalSize后面可能还会用上
    int offset = 0;
    int i;
    for (i = 0; i < stringCount; i++) {
      stringOffsets[i] = offset;
      //如果找不到即没混淆这一项,直接拷贝
      if (tableProguardMap.get(i) == null) {
        //需要区分是否是最后一项
        int copyLen = (i == (stringCount - 1)) ? (block.m_strings.length - block.m_stringOffsets[i])
            : (block.m_stringOffsets[i + 1] - block.m_stringOffsets[i]);
        System.arraycopy(block.m_strings, block.m_stringOffsets[i], strings, offset, copyLen);
        offset += copyLen;
        totalSize += copyLen;
      }
  • 这里描述的就是在tableProguardMap找不到对应项的情况,就直接拷贝原来的字符串,这里有一个操作就是区分最后一项
       String name = tableProguardMap.get(i);
        if (block.m_isUTF8) {
          strings[offset++] = (byte) name.length();
          strings[offset++] = (byte) name.length();
          totalSize += 2;
          byte[] tempByte = name.getBytes(Charset.forName("UTF-8"));
          // 1  
          if (name.length() != tempByte.length) {
            throw new AndrolibException(String.format(
                "writeTableNameStringBlock UTF-8 length is different  name %d, tempByte %d\n",
                name.length(),
                tempByte.length
            ));
          }
          System.arraycopy(tempByte, 0, strings, offset, tempByte.length);
          offset += name.length();
          strings[offset++] = NULL;
          totalSize += name.length() + 1;
        } else {
          writeShort(strings, offset, (short) name.length());
          offset += 2;
          totalSize += 2;
          byte[] tempByte = name.getBytes(Charset.forName("UTF-16LE"));
          if ((name.length() * 2) != tempByte.length) {
            throw new AndrolibException(String.format(
                "writeTableNameStringBlock UTF-16LE length is different  name %d, tempByte %d\n",
                name.length(),
                tempByte.length
            ));
          }
          System.arraycopy(tempByte, 0, strings, offset, tempByte.length);
          offset += tempByte.length;
          strings[offset++] = NULL;
          strings[offset++] = NULL;
          totalSize += tempByte.length + 2;
        }
      }
  • 这段逻辑描述的就是tableProguardMap命中的情况
  • 首先就是对编码格式的兼容,这里判断长度的原因主要是因为utf-16编码是以2个字节编码的,所以理所当然的解码的过程也是按照2个字节去进行解析,而utf-8则比较特殊,它要根据额外的标识信息去判断长度,所以就意味着utf-8是采用1-3去表示原来的编码的,所以根据长度的判断就可以判定编码是否一致
  • 校验完之后的操作就是array copy过去,然后接着往上加total size
  • 然后如果它本来就不是用utf-8编码方式的话,那么同样道理校验一下是否为2的倍数,然后不是就抛异常,是的话就执行array copy以及total size的往上添加
//要保证string size 是4的倍数,要补零
    int size = totalSize - stringsOffset;
    if ((size % 4) != 0) {
      int add = 4 - (size % 4);
      for (i = 0; i < add; i++) {
        strings[offset++] = NULL;
        totalSize++;
      }
    }
  • 然后这一段就是为了保证混淆修改了字符串之后整个strings的长度还是4的倍数
//因为是int的,如果之前的不为0
    if (stylesOffset != 0) {
      stylesOffset = totalSize;
      totalSize += block.m_styles.length * 4;
    }

    out.writeInt(totalSize);
    out.writeInt(stringCount);
    out.writeInt(styleOffsetCount);
    out.writeInt(flags);
    out.writeInt(stringsOffset);
    out.writeInt(stylesOffset);
    out.writeIntArray(stringOffsets);
    if (stylesOffset != 0) {
      out.writeIntArray(block.m_styleOffsets);
    }
    out.write(strings, 0, offset);
    if (stylesOffset != 0) {
      out.writeIntArray(block.m_styles);
    }
    return (chunkSize - totalSize);
  • 然后的操作就是常规的,写入,然后返回一个全局常量池中长度差值

阶段小结

整个全局常量池的写入操作的本质就是把之前在readValue阶段保存的全局常量池索引值资源名称得到的一个映射表,转换一个新的strings字节数组,然后就写入进去,这个阶段的易错点有两个,一个是编码格式,另一个就是4的倍数这个问题,编码格式如果不做强校验的话,写进去了之后反编译再看的话就会乱码,而4的倍数问题更严重,如果这里不做强对齐的话,就会导致apk安装不了!

[ARSCDecoder-> writeTable]

   for (int i = 0; i < packageCount; i++) {
      mCurPackageID = i;
      writePackage();
    }

[ARSCDecoder-> writePackage]

private void writePackage() throws IOException, AndrolibException {
   
    mResId = id << 24;
    // 1
    StringBlock.writeAll(mIn, mOut);

    if (mPkgs[mCurPackageID].isCanResguard()) {
      int specSizeChange = StringBlock.writeSpecNameStringBlock(mIn,
         mOut,
         mPkgs[mCurPackageID].getSpecNamesBlock(),
         mCurSpecNameToPos
      );
      mPkgsLenghtChange[mCurPackageID] += specSizeChange;
      mTableLenghtChange += specSizeChange;
    } else {
      StringBlock.writeAll(mIn, mOut);
    }
    writeNextChunk(0);
    while (mHeader.type == Header.TYPE_LIBRARY) {
      writeLibraryType();
    }
    while (mHeader.type == Header.TYPE_SPEC_TYPE) {
      writeTableTypeSpec();
    }
  }
  • 常规在mResId的高4位的记录下packageId
  • 注释1:然后资源类型的stringBlock就不混淆直接写入
  • 然后就判断一下当前拿到的这个包是否是可以混淆,这个值是在decode的时候就设置上去的,只有系统的包名是不允许混淆的
  • 然后就写入nameSpec这个stringBlock,拿到变化的diff记录下来

[StringBlock-> writeSpecNameStringBlock]

if (styleOffsetCount != 0) {
      throw new AndrolibException(String.format("writeSpecNameStringBlock styleOffsetCount != 0  styleOffsetCount %d",
          styleOffsetCount
      ));
    }
  • 强校验让specName的styles必须没有
byte[] stringBytes = new byte[size * 2];
    int offset = 0;
    int i = 0;
 curSpecNameToPos.clear();
  • 创建2倍size的strings数组
for (Iterator<String> it = specNames.iterator(); it.hasNext(); ) {
      stringOffsets[i] = offset;
      String name = it.next();
      curSpecNameToPos.put(name, i);
      if (isUTF8) {
        stringBytes[offset++] = (byte) name.length();
        stringBytes[offset++] = (byte) name.length();
        totalSize += 2;
        byte[] tempByte = name.getBytes(Charset.forName("UTF-8"));
        if (name.length() != tempByte.length) {
          throw new AndrolibException(String.format(
              "writeSpecNameStringBlock %s UTF-8 length is different name %d, tempByte %d\n",
              name,
              name.length(),
              tempByte.length
          ));
        }
        System.arraycopy(tempByte, 0, stringBytes, offset, tempByte.length);
        offset += name.length();
        stringBytes[offset++] = NULL;
        totalSize += name.length() + 1;
      } else {
        writeShort(stringBytes, offset, (short) name.length());
        offset += 2;
        totalSize += 2;
        byte[] tempByte = name.getBytes(Charset.forName("UTF-16LE"));
        if ((name.length() * 2) != tempByte.length) {
          throw new AndrolibException(String.format(
              "writeSpecNameStringBlock %s UTF-16LE length is different name %d, tempByte %d\n",
              name,
              name.length(),
              tempByte.length
          ));
        }
        System.arraycopy(tempByte, 0, stringBytes, offset, tempByte.length);
        offset += tempByte.length;
        stringBytes[offset++] = NULL;
        stringBytes[offset++] = NULL;
        totalSize += tempByte.length + 2;
      }
      i++;
    }
  • 这个写入的操作步骤和逻辑和之前全局常量池的写入是差不多的,不过不一样的是,这里传入了一个curSpecNameToPos用来记录specName和位置的映射表,这里猜测应该是后面entry或者value处理的时候要用到这个东西
  • 这个curSpecNameToPos对应了ARSCDecoder中的mCurSpecNameToPos

[ARSCDecoder-> writeTableTypeSpec]

private void writeTableTypeSpec() throws AndrolibException, IOException {
    mResId = (0xff000000 & mResId) | id << 16;
    while (writeNextChunk(0).type == Header.TYPE_TYPE) {
      writeConfig();
    }
  }
  • 例行地往mResId写入资源id

[ARSCDecoder-> writeConfig]

private void writeConfig() throws IOException, AndrolibException {
   
   for (int i = 0; i < entryOffsets.length; i++) {
      if (entryOffsets[i] != -1) {
        mResId = (mResId & 0xffff0000) | i;
        writeEntry();
      }
    }
  }
  • 例行加入entryId,得到完整的资源id

[ARSCDecoder-> writeEntry]

 private void writeEntry() throws IOException, AndrolibException {
    ResPackage pkg = mPkgs[mCurPackageID];
    if (pkg.isCanResguard()) {
      specNamesId = mCurSpecNameToPos.get(pkg.getSpecRepplace(mResId));
    }
    mOut.writeInt(specNamesId);

    if ((flags & ENTRY_FLAG_COMPLEX) == 0) {
      writeValue();
    } else {
      writeComplexEntry();
    }
  }
  • 符合上面的猜想,那就是mCurSpecNameToPos这个索引表是在entry写入中使用的,这里看到的感悟就是数据结构用得行云流水,因为在readEntry的过程中分别会保存资源id混淆名称/资源名称的映射以及就是混淆名称/资源名称的一个集合Set,因为这个混淆名称/资源名称的容器肯定是要不能重复的,但是以此为代价就是无序,所以才需要把资源id混淆名称/资源名称的映射关系的保存起来
  • 因为在上一步写入specName的时候其实遍历set得到的混淆名称肯定是和一开始放进去的顺序不一样的,所以就手动通过mCurSpecNameToPos记录下set中拿到的值与offset的关系
  • 然后通过计算出来的资源id就可以拿到当时在readEntry保存的名称,然后通过这个名称在从mCurSpecNameToPos里面拿到索引值,那么这个索引值就是specNamesId

[ARSCDeocder-> writeValue]

private void writeValue() throws IOException, AndrolibException {
    /* size */
    mOut.writeCheckShort(mIn.readShort(), (short) 8);
    /* zero */
    mOut.writeCheckByte(mIn.readByte(), (byte) 0);
    byte type = mIn.readByte();
    mOut.writeByte(type);
    int data = mIn.readInt();
    mOut.writeInt(data);
  }
  • 然后因为全局常量池是按照顺序一一写入的,所以就没有specName那里那种问题,所以writeValue就相对没有那么复杂

总结

整个写入的操作和之前猜想是一致的,那就是把读取过程中记录在内存的数据,写回到文件里面,写回分两步,先是写到临时文件,而且要计算混淆之后的长度,然后再把临时文件的内容写到真实文件,而且要写道混淆后的长度