Mail.ru Group corporate blog
Abnormal programming
Java
Reverse engineering
27 November 2015

Как мы себя заново писали, или как потерять исходники и не подать виду



Был прекрасный майский день. Мой взгляд случайно упал на чат ребят с крайнего сервера. У них майский день был не таким прекрасным: во время перераскладки второстепенного сервиса упал сервис авторизации, связанный с ним постольку-поскольку. Цимес ситуации в том, что падающую часть сервиса авторизации никто не поддерживает, он перешел к нам по наследству и никогда особо не сбоил. Меня увлекло чтение детектива поиска причин, и до определенного момента я был пассивным читателем — пока не увидел фразу нашего админа, наполненную приобретенной сединой его волос: «За час натекает 800+ потоков».

Вот это уже интересно! На Java течь потоками в таком темпе, да чтобы этого годами не замечать — не так уж это и просто, что я и озвучил. А поскольку в данном чате я был единственным Java-разработчиком, то было лишь вопросом времени, пока кто-нибудь не скажет: «Раз такой умный, возьми да поправь». И не важно, что ты клиентщик, и вообще последние три года пишешь под Андроид.

А почему бы и нет?


Шаг 1: берем сорцы для обзорного ознакомления. Грепаем «Thread», «Executor» и… ничего не находим. Зато находим некую библиотеку, в которую уходят все вызовы.

Шаг 2: берем сорцы библиотеки и… их нет. Вот это поворот! Как так случилось? Да очень просто. Проект состоит из 300+ сервисов. У него богатая и сложная история с неожиданными поворотами. И при переносе всех этих чудес, местами без документации, с разными репозиториями, языками и технологиями, чисто технически не за всем можно уследить, тем более что все отлично компилируется, либо лежит в проекте в виде jar-ки.

В целом, для ознакомления сорцы не особо и нужны. Intellij Idea вполне сносно декомпилирует код. Даже при беглом прочтении волосы встали дыбом. Слово «Executor» все еще не встречалось, зато «new Thread» было буквально повсюду. На этом отложим в сторону код. Прямо сейчас искать в нем утечку ничуть не проще, чем иголку в стоге иголок. Возьмем лучше thread dump и посмотрим:

"Thread-782" daemon prio=10 tid=0x00007f7db4654800 nid=0x2286d9 in Object.wait() [0x00007f7b929d6000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000000b843f3c8> (a java.util.LinkedList)
	at java.lang.Object.wait(Object.java:503)
	at com.aol.saabclient.SAABConnection.AsynchMsgProcessor.run(AsynchMsgProcessor.java:83)
	- locked <0x00000000b843f3c8> (a java.util.LinkedList)

"Thread-781" daemon prio=10 tid=0x00007f7db4651000 nid=0x2286d7 in Object.wait() [0x00007f7de37ee000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000000b843f3c8> (a java.util.LinkedList)
	at java.lang.Object.wait(Object.java:503)
	at com.aol.saabclient.SAABConnection.AsynchMsgProcessor.run(AsynchMsgProcessor.java:83)
	- locked <0x00000000b843f3c8> (a java.util.LinkedList)

"Thread-780" daemon prio=10 tid=0x00007f7db464f000 nid=0x2286d5 in Object.wait() [0x00007f7de118a000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000000b843f3c8> (a java.util.LinkedList)
	at java.lang.Object.wait(Object.java:503)
	at com.aol.saabclient.SAABConnection.AsynchMsgProcessor.run(AsynchMsgProcessor.java:83)
	- locked <0x00000000b843f3c8> (a java.util.LinkedList)

Ну, раз такая пьянка, пойдем прямиком в AsyncMsgProcessor (да-да, SAABConnection — это package). Там мы видим что-то вроде ручной реализации blocking queue вокруг LinkedList. Ясно-понятно, что цикл разбора вечен, и это даже может быть desirable behaviour. Также становится понятно, что AsyncMsgProcessor создается для каждого соединения, но вот очередь общая (static LinkedList messages). Таким образом, раз все AsyncMsgProcessor’ы разгребают одну и ту же очередь, можно просто ограничить их число. Ищем инстанцирование, и находим только одно. Отлично! Осталось поменять прямое инстанцирование на пул и будет нам счастье.

Для этого есть два пути:

  1. Воткнуть декомпилированный код обратно в компилятор и молиться, чтобы декомпилятор не налажал. Это путь темной стороны, так как ведет к непредсказуемым багам;
  2. Поправить byte-код одного маленького метода руками. Шансов ошибиться в разы меньше, а значит это путь настоящего джедая.

Правим byte-код


Для разбора и сбора обратно class-файлов нужна более-менее специфичная тулза. Я нашел только вот эту: JBE — Java Bytecode Editor. Она имеет большую проблему с редактированием кода: нужно руками считать все смещения в условных и безусловных переходах, что, в общем-то, так себе перспектива, даже для сравнительно небольшого метода. Опять-таки из-за большого шанса ошибиться любое изменение будет даваться кровью и потом. Среди менее готовых для прямого использования тулзов есть отличная, очень мощная штука — ASM. Но из коробки не имеет возможности сначала вывести в виде текста, затем подредактировать и собрать обратно. Но можно научить.

Хачим вывод


Для вывода текста используется классы Textifier + TraceMethodVisitor. Но из такого вывода довольно проблематично будет собрать все обратно, чтобы байткод не изменился (хотя бы функционально). Поэтому немного хаков:

Textifier textifier = new Textifier(Opcodes.ASM5) {
   @Override
   public void visitLabel(Label label) {
       buf.setLength(0);
       buf.append('#');
       appendLabel(label);
       buf.append(":\n");
       text.add(buf.toString());
   }

   @Override
   public void visitLineNumber(int line, Label start) {
       buf.setLength(0);
       buf.append("// line ").append(line).append('\n');
       text.add(buf.toString());
   }

   @Override
   public void visitMaxs(int maxStack, int maxLocals) {
       buf.setLength(0);
       buf.append("// MAXSTACK = ").append(maxStack).append('\n');
       text.add(buf.toString());

       buf.setLength(0);
       buf.append("// MAXLOCALS = ").append(maxLocals).append('\n');
       text.add(buf.toString());
   }

   @Override
   public void visitLdcInsn(Object cst) {
       buf.setLength(0);
       buf.append(tab2).append("LDC ");
       if (cst instanceof String) {
           Printer.appendString(buf, (String) cst);
       } else if (cst instanceof org.objectweb.asm.Type) {
           buf.append(((org.objectweb.asm.Type) cst).getDescriptor()).append(".class");
       } else if (cst instanceof Long) {
           buf.append(cst).append('L');
       } else if (cst instanceof Float) {
           buf.append(cst).append('F');
       } else if (cst instanceof Double) {
           buf.append(cst).append('D');
       } else if (cst instanceof Integer) {
           buf.append(cst);
       } else {
           throw new IllegalArgumentException("cst " + cst);
       }
       buf.append('\n');
       text.add(buf.toString());
   }

   @Override
   public void visitFrame(int type, int nLocal, Object[] local, int nStack, Object[] stack) {
   }
};

Хачим ввод


С вводом сложнее. В ASM есть класс MethodNode, являющийся визитором. Обычно подразумевается, что MethodNode подсовывается в accept ClassReader, заполняясь из него, возможно, видоизменяясь. Мы же хотим подсунуть в него текст, сгенеренный на прошлом шаге (именно там должны были произойти «видоизменения»). Симитируем поведение Reader:

   for (String line : methodCode.getText().split("\n")) {
       int lastCommentPos = line.lastIndexOf("//");
       if (lastCommentPos != -1) {
           line = line.substring(0, lastCommentPos);
       }
       line = line.trim();
       if (line.isEmpty()) {
           continue;
       }
       String[] withParams = line.split("\\s+");

       String command = withParams[0];
       if (command.startsWith("#")) {
           verify(command.endsWith(":"));
           String substring = command.substring(1, command.length() - 1);
           method.visitLabel(getLabel(substring, labels));
       } else if (command.equals("TRYCATCHBLOCK")) {
           verify(withParams.length == 5);
           Label start = getLabel(withParams[1], labels);
           Label end = getLabel(withParams[2], labels);
           Label handler = getLabel(withParams[3], labels);
           String type = withParams[4];
           if (type.equals("null")) {
               type = null;
           }
           method.visitTryCatchBlock(start, end, handler, type);
       } else {
           Opcode opcode = OPCODES.get(command); //копипаста из сорцов ASM
           if (opcode == null) {
               throw new RuntimeException("Unknown " + command);
           } else {
               switch (opcode.type) {
                   case OpcodeGroup.INSN:
                       verify(withParams.length == 1);
                       method.visitInsn(opcode.opcode);
                       break;
                   case OpcodeGroup.INSN_INT:
                       verify(withParams.length == 2);
                       method.visitIntInsn(opcode.opcode, Integer.valueOf(withParams[1]));
                       break;
                   case OpcodeGroup.INSN_VAR:
                       verify(withParams.length == 2);
                       method.visitVarInsn(opcode.opcode, Integer.valueOf(withParams[1]));
                       break;
                   case OpcodeGroup.INSN_TYPE:
                       verify(withParams.length == 2);
                       method.visitTypeInsn(opcode.opcode, withParams[1]);
                       break;
                   case OpcodeGroup.INSN_FIELD:
                       verify(withParams.length == 4);
                       verify(withParams[2].equals(":"));
                       int dotIndex = withParams[1].indexOf('.');
                       String owner = withParams[1].substring(0, dotIndex);
                       String name = withParams[1].substring(dotIndex + 1);
                       method.visitFieldInsn(opcode.opcode, owner, name, withParams[3]);
                       break;
                   case OpcodeGroup.INSN_METHOD:
                       verify(withParams.length == 3);
                       dotIndex = withParams[1].indexOf('.');
                       owner = withParams[1].substring(0, dotIndex);
                       name = withParams[1].substring(dotIndex + 1);
                       method.visitMethodInsn(opcode.opcode, owner, name, withParams[2], opcode.opcode == INVOKEINTERFACE);
                       break;
                   case OpcodeGroup.INSN_JUMP:
                       verify(withParams.length == 2);
                       method.visitJumpInsn(opcode.opcode, getLabel(withParams[1], labels));
                       break;
                   case OpcodeGroup.INSN_LDC:
                       withParams = line.split("\\s+", 2);
                       verify(withParams.length == 2);
                       method.visitLdcInsn(parseLdc(withParams[1]));
                       break;
                   case OpcodeGroup.INSN_IINC:
                       verify(withParams.length == 3);
                       method.visitIincInsn(Integer.valueOf(withParams[1]), Integer.valueOf(withParams[2]));
                       break;
                   case OpcodeGroup.INSN_MULTIANEWARRAY:
                       verify(withParams.length == 3);
                       method.visitMultiANewArrayInsn(withParams[1], Integer.valueOf(withParams[2]));
                       break;
                   default:
                       throw new IllegalArgumentException();
               }
           }
       }
   }

Врезку в байткод в итоге мы сделали. Осталось напилить тот самый пул. Код приводить не буду, там еще больше всякого… Но подход очень простой: берем модифицированную библиотеку, кладем ее в зависимости, пишем нужные классы и перекомпилируем. После этого потоки перестали течь, все работает, хэппи энд. На самом деле, все, конечно, было не так — потребовалось 5—7 раскладок в тестовом окружении. То байт-код — не байт-код, то пул — не пул…

А вывод у этой истории простой: невыполнимых задач не бывает, даже потеря исходных кодов — не катастрофа. И если нет нужной тулзы, ее всегда можно соорудить с помощью тех же подручных материалов.

P.S.: Пиво мне так никто и не поставил.

+42
41.2k 58
Comments 32
Top of the day