在Java字节码中识别循环

18

我正在尝试检测Java字节码。

我想要识别Java循环的入口和出口,但我发现识别循环非常具有挑战性。我花费了好几个小时研究ASM开源反编译器(我认为他们必须经常解决这个问题),但我没有找到答案。

我正在使用ASM增强/扩展工具,因此最好的情况是我想知道如何通过ASM检测Java中不同循环结构的入口和出口。但是,我也希望推荐一个好的开源反编译器,因为他们显然已经解决了相同的问题。

4个回答

21

编辑4:一些背景信息/序言。

  • Peter的回答中“唯一倒退到代码中的方式是通过循环”并不完全正确。你可以来回跳转,而这并不意味着它是一个循环。一个简单的例子可能是这样的:

0: goto 2
1: goto 3
2: goto 1

当然,这个例子非常人为且有点愚蠢。但是,对源代码到字节码编译器的行为进行假设可能会导致意外情况。正如 Peter 和我在各自的答案中所示,两种流行的编译器可以产生相当不同的输出(即使没有混淆)。这很少有关系,因为当您执行代码时,所有这些都往往被 JIT 编译器很好地优化了。

话虽如此,在绝大多数情况下,向后跳转将是确定循环开始位置的一个合理指标。与其他部分相比,找到循环的入口点是“容易”的部分。

在考虑任何循环启动/退出工具之前,您应该查看入口、出口和后继项的定义。尽管循环只有一个入口点,但它可能有多个出口点和/或多个后继项,通常是由于 break 语句(有时带有标签)、return 语句和/或异常(明确捕获或未捕获)引起的。虽然您没有给出有关您正在调查的工具类型的详细信息,但考虑插入代码的位置(如果这是您想做的事情)肯定是值得考虑的。通常,某些工具可能需要在每个退出语句之前或者代替每个后继语句进行一些插装(在这种情况下,您将不得不移动原始语句)。


Soot 是一个很好的框架来做这个事情。它有许多中间表示使字节码分析更方便(例如 Jimple)。

您可以基于方法体构建一个 BlockGraph,例如一个 ExceptionalBlockGraph。一旦你将控制流图分解成这样的块图形式,从节点中,您应该能够识别支配者(即有箭头返回到它们的块)。这将给出循环开始的位置。

您可能会在 本论文的第 4.3 至 4.7 节中找到类似的东西。

编辑:

根据与 @Peter 在评论中的讨论。谈论同一个例子:

public int foo(int i, int j) {
    while (true) {
        try {
            while (i < j)
                i = j++ / i;
        } catch (RuntimeException re) {
            i = 10;
            continue;
        }
        break;
    }
    return j;
}

这次使用Eclipse编译器编译(没有具体选项:只是从IDE内部自动编译)。 这段代码没有被混淆(除了代码本身就糟糕之外,但那是另一回事)。 以下是结果(来自javap -c):

public int foo(int, int);
  Code:
   0:   goto    10
   3:   iload_2
   4:   iinc    2, 1
   7:   iload_1
   8:   idiv
   9:   istore_1
   10:  iload_1
   11:  iload_2
   12:  if_icmplt   3
   15:  goto    25
   18:  astore_3
   19:  bipush  10
   21:  istore_1
   22:  goto    10
   25:  iload_2
   26:  ireturn
  Exception table:
   from   to  target type
     0    15    18   Class java/lang/RuntimeException

在开始于10的情况下,3到12之间存在一个循环,以及由于8到22的除数为零而发生异常所导致的另一个循环。与javac编译器的结果不同,其中人们可能会猜测在0到22之间有一个外部循环和0到12之间的内部循环,但这里的嵌套不太明显。

编辑2:

为了举例说明可能遇到的问题,这里是一个相对简单的循环:

public void foo2() {
    for (int i = 0; i < 5; i++) {
        System.out.println(i);
    }
}

在Eclipse中进行(正常)编译后,javap -c的输出如下:

public void foo2();
  Code:
   0:   iconst_0
   1:   istore_1
   2:   goto    15
   5:   getstatic   #25; //Field java/lang/System.out:Ljava/io/PrintStream;
   8:   iload_1
   9:   invokevirtual   #31; //Method java/io/PrintStream.println:(I)V
   12:  iinc    1, 1
   15:  iload_1
   16:  iconst_5
   17:  if_icmplt   5
   20:  return

在循环内执行任何操作之前,您直接从2跳转到15。块15到17是循环的头部(即“入口点”)。有时,头块可能包含更多指令,特别是如果退出条件涉及更多评估或者如果它是一个 do {} while() 循环。

循环的“入口”和“出口”的概念并不总是反映您作为Java源代码编写时的合理写法(包括您可以将 for 循环重写为 while 循环的事实,例如)。使用 break 还可能导致多个退出点。

顺便说一下,“块”是指一个字节码序列,您无法在其中间跳转,并且只能从第一行(可能不是从上一行开始,也可能从其他地方跳转)进入,从最后一行(不一定是到下一行,它也可以跳转到其他地方)退出。

编辑3:

自上次我查看Soot以来,似乎已经添加了新的分析循环的类/方法,这使得它更加方便。

以下是完整示例。

要分析的类/方法( TestLoop.foo()

public class TestLoop {
    public void foo() {
        for (int j = 0; j < 2; j++) {
            for (int i = 0; i < 5; i++) {
                System.out.println(i);
            }
        }
    }
}

使用 Eclipse 编译器编译时,会产生以下字节码(javap -c):

public void foo();
  Code:
   0:   iconst_0
   1:   istore_1
   2:   goto    28
   5:   iconst_0
   6:   istore_2
   7:   goto    20
   10:  getstatic   #25; //Field java/lang/System.out:Ljava/io/PrintStream;
   13:  iload_2
   14:  invokevirtual   #31; //Method java/io/PrintStream.println:(I)V
   17:  iinc    2, 1
   20:  iload_2
   21:  iconst_5
   22:  if_icmplt   10
   25:  iinc    1, 1
   28:  iload_1
   29:  iconst_2
   30:  if_icmplt   5
   33:  return

这里有一个程序,使用Soot加载类(假设它在类路径上),并显示其块和循环:

import soot.Body;
import soot.Scene;
import soot.SootClass;
import soot.SootMethod;
import soot.jimple.toolkits.annotation.logic.Loop;
import soot.toolkits.graph.Block;
import soot.toolkits.graph.BlockGraph;
import soot.toolkits.graph.ExceptionalBlockGraph;
import soot.toolkits.graph.LoopNestTree;

public class DisplayLoops {
    public static void main(String[] args) throws Exception {
        SootClass sootClass = Scene.v().loadClassAndSupport("TestLoop");
        sootClass.setApplicationClass();

        Body body = null;
        for (SootMethod method : sootClass.getMethods()) {
            if (method.getName().equals("foo")) {
                if (method.isConcrete()) {
                    body = method.retrieveActiveBody();
                    break;
                }
            }
        }

        System.out.println("**** Body ****");
        System.out.println(body);
        System.out.println();

        System.out.println("**** Blocks ****");
        BlockGraph blockGraph = new ExceptionalBlockGraph(body);
        for (Block block : blockGraph.getBlocks()) {
            System.out.println(block);
        }
        System.out.println();

        System.out.println("**** Loops ****");
        LoopNestTree loopNestTree = new LoopNestTree(body);
        for (Loop loop : loopNestTree) {
            System.out.println("Found a loop with head: " + loop.getHead());
        }
    }
}

请查阅Soot文档以获取有关如何加载类的更多详细信息。 Body是循环体的模型,即由字节码生成的所有语句。这使用中间Jimple表示形式,它等效于字节码,但更易于分析和处理。

以下是此程序的输出:

Body:

    public void foo()
    {
        TestLoop r0;
        int i0, i1;
        java.io.PrintStream $r1;

        r0 := @this: TestLoop;
        i0 = 0;
        goto label3;

     label0:
        i1 = 0;
        goto label2;

     label1:
        $r1 = <java.lang.System: java.io.PrintStream out>;
        virtualinvoke $r1.<java.io.PrintStream: void println(int)>(i1);
        i1 = i1 + 1;

     label2:
        if i1 < 5 goto label1;

        i0 = i0 + 1;

     label3:
        if i0 < 2 goto label0;

        return;
    }

块:

Block 0:
[preds: ] [succs: 5 ]
r0 := @this: TestLoop;
i0 = 0;
goto [?= (branch)];

Block 1:
[preds: 5 ] [succs: 3 ]
i1 = 0;
goto [?= (branch)];

Block 2:
[preds: 3 ] [succs: 3 ]
$r1 = <java.lang.System: java.io.PrintStream out>;
virtualinvoke $r1.<java.io.PrintStream: void println(int)>(i1);
i1 = i1 + 1;

Block 3:
[preds: 1 2 ] [succs: 4 2 ]
if i1 < 5 goto $r1 = <java.lang.System: java.io.PrintStream out>;

Block 4:
[preds: 3 ] [succs: 5 ]
i0 = i0 + 1;

Block 5:
[preds: 0 4 ] [succs: 6 1 ]
if i0 < 2 goto i1 = 0;

Block 6:
[preds: 5 ] [succs: ]
return;

循环:

Found a loop with head: if i1 < 5 goto $r1 = <java.lang.System: java.io.PrintStream out>
Found a loop with head: if i0 < 2 goto i1 = 0
LoopNestTree使用LoopFinder,后者使用ExceptionalBlockGraph来构建块列表。 Loop类将为您提供入口语句和退出语句。然后,如果需要,您应该能够添加额外的语句。Jimple对此非常方便(它足够接近字节码,但具有稍高的级别,因此不必手动处理所有内容)。然后,如果需要,可以输出修改后的.class文件。 (请参阅Soot文档。)

这似乎是一个相当大的挑战,但反编译器必须已经在某种程度上解决了这个问题,不是吗? - user858203
嗯,Soot 实际上是一个反编译器,或者至少是一个执行此操作的框架(它甚至有一个反编译器前端 Dava)。这里有 Soot 的教程:http://www.sable.mcgill.ca/soot/tutorial/index.html。它还取决于您需要什么样的工具。Java 反编译并不总是完美的。(字节码分析通常也不容易。) - Bruno
当循环抛出异常时,对其后继进行仪器化是否可行? - biziclop
@biziclop:当我使用AspectJ进行这项工作时,更准确地说是使用AspectBench编译器(也许我应该更清楚地说明我写的是指的论文),这是由剩下的织入器完成的。你可以选择一个afterafter throwingafter returning的切入点(AspectJ语法)来选择何时使用该方面。无论如何,这是织入器系统地完成的(包括更传统的切入点,例如call)。通过块(和通过Jimple)进行推理也使得实现变得更容易一些。 - Bruno
谢谢,这很有用。 - biziclop
显示剩余5条评论

6
唯一能够在代码中进行向后跳转的方法是通过循环。因此,您需要查找goto,if_icmplt等指令,它们会跳转到前一个字节码指令。一旦找到了循环的结尾和跳回的位置,那么这个位置就是循环的开始。
这里有一个复杂的例子,来自Bruno建议的文档。
public int foo(int i, int j) {
    while (true) {
        try {
            while (i < j)
                i = j++ / i;
        } catch (RuntimeException re) {
            i = 10;
            continue;
        }
        break;
    }
    return j;
}

这段代码的字节码在 javap -c 中显示为

public int foo(int, int);
  Code:
   0:   iload_1
   1:   iload_2
   2:   if_icmpge       15
   5:   iload_2
   6:   iinc    2, 1
   9:   iload_1
   10:  idiv
   11:  istore_1
   12:  goto    0
   15:  goto    25
   18:  astore_3
   19:  bipush  10
   21:  istore_1
   22:  goto    0
   25:  iload_2
   26:  ireturn
  Exception table:
   from   to  target type
     0    15    18   Class java/lang/RuntimeException

您可以看到有一个内部循环从0到12,一个try/catch块从0到15,还有一个外部循环从0到22。

1
@Bruno,编译器进行的优化很少。这是由JIT完成的。这使得将字节码转换为Java代码比您预期的要简单。 - Peter Lawrey
1
在我看来,递归 != 循环,它们在字节码中看起来完全不同。 - Peter Lawrey
@Bruno,你可以拥有有效的字节码,但它可能是完全没有意义的,无法解码成Java。这就是混淆器故意要做的事情。总的来说,理解所有可能的有效字节码是不可能的。我认为你想要解码“javac”生成的字节码是很合理的。 - Peter Lawrey
我可以确认它将针对非混淆示例进行定位。目标是从我们自己的代码中生成运行时可视化,以帮助看到这个API、库或框架真正在做什么?静态分析无法实现这一点,因为您无法确定运行时执行了什么,因此我们正在努力获得运行时图片。然而,当然,由于各种各样的原因,从私有方法调用到循环,结果图表中会产生太多噪音。因此,我们正在研究总结所得分析/可视化的方法。感谢您的意见。 - user858203
@Peter,看一下我的编辑答案,附带来自Eclipse的输出。循环检测并不像看起来那么简单。 - Bruno
显示剩余7条评论

0

你是真的在逐字节地构建你的类吗?这太疯狂了!ASM 的首页链接到 Eclipse 的 Bytecode Outline 插件,我想你正在使用它。如果你点击那里的第一张图片,你会注意到代码有一个 while 循环,并且你可以看到至少一些用于实现该循环的字节码。以下是该截图:

Bytecode Outline Screenshot

直接链接

看起来循环是通过带有边界检查的GOTO实现的。我指的是这一行:

L2 (173)
  GOTO L3

我相信L3标记器有检查索引边界的代码,并决定是否跳转。如果你想一次一个字节码地检测循环,我认为这对你来说会非常困难。汇编语言确实有使用模板类作为仪器基础的选项,你试过使用它吗?


我不是逐字逐句地构建代码,而是要对现有代码进行仪器化,但希望添加仪器以通知分析器/可视化工具循环正在进入和退出(支持嵌套/深度)。我已经查看了字节码并认识到我正在寻找带有某些保护条件的goto。然而,我想知道ASM中是否存在现有模式或现有反编译器源代码。我假设这是以前完成过的事情,不想重新实现字节码解析的那部分内容(用于识别循环X的开始、循环X的结束等)。 - user858203
@user858203 - 啊,好的,这样就更有意义了。我在ASM文档中没有看到支持这种情况的任何内容,你可能会在开源反编译器中找到更好的解决方案。 - Perception

0

我知道这是一个老问题 - 然而,对于如何使用ASM库实现这一点存在特定的兴趣,这可能对未来的访问者有用。请注意,其他答案给出了警告,要避免与“goto”语句相关的概括性假设,但是有一种方法可以做到这一点。(这假设在给定方法中任何代码组合都应该被检测到“循环” - 通常这是一个实际的循环结构,但其他(罕见但存在)例子已经提供了如何发生这种情况的说明。)

你需要做的主要事情是跟踪ASM在“跳转指令”之前访问的“标签”(字节码中的位置) - 如果它跳转到的标签已经在同一方法的上下文中遇到过,那么你就有了潜在的循环代码。

我在这里看到的一个显着差异是,它读取了一个简单文件的“循环”跳转命令,其操作码不是“goto” - 这可能只是自此提问以来Java编译中的更改,但值得注意。

ASM的基本示例代码如下(通过checkForLoops方法输入):

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public void checkForLoops(Path classFile) {
    LoopClassVisitor classVisitor = new LoopClassVisitor();

    try (InputStream inputStream = Files.newInputStream(classFile)) {
        ClassReader cr = new ClassReader(inputStream);

        cr.accept(classVisitor, 0);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

public class LoopClassVisitor extends ClassVisitor {

    public LoopClassVisitor() {
        super(Opcodes.ASM7);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
            String[] exceptions) {
        return new LoopMethodVisitor();
    }

}

public class LoopMethodVisitor extends MethodVisitor {

    private List<Label> visitedLabels;

    public LoopMethodVisitor() {
        super(Opcodes.ASM7);

        visitedLabels = new ArrayList<>();
    }

    @Override
    public void visitLineNumber(final int line, final Label start) {
        System.out.println("lnLabel: " + start.toString());

        visitedLabels.add(start);
    }

    @Override
    public void visitLabel(final Label label) {
        System.out.println("vLabel: " + label.toString());

        visitedLabels.add(label);
    }

    @Override
    public void visitJumpInsn(final int opcode, final Label label) {
        System.out.println("Label: " + label.toString());

        if (visitedLabels.contains(label)) {
            System.out.println("Op: " + opcode + ", GOTO to previous command - possible looped execution");
        }
    }

}

如果有可用的行号信息,您还可以将其附加到标签上,并在方法访问器中跟踪它,以输出源代码中检测循环开始和结束的位置。


需要注意的是:如果您正在尝试检测循环以查找特定迭代的代码,则还需要查找使用Java流方法(例如map和filter)的位置-为此,您需要研究方法访问者中的“invoke dynamic”方法以及“bootstrap method arguments”的可能值。 - romeara

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接