Java 实现后台执行

常用的将程序放到后台执行,并在shell退出后依然运行的方法,是使用 nohup&,比如 nohup java -jar abc.jar &

原理

nohup 的作用是忽略 SIGHUP 信号。当一个shell关闭后,会向运行的程序发送 SIGHUP 信号,通知同一shell内的各个进程,它们与控制终端不再关联。系统对 SIGHUP 信号的默认处理是终止收到该信号的进程。

& 的作用是忽略 SIGINT 信号。Ctrl+C 会向前台进程发送 SIGINT 信号,以关闭程序。

实现

综上,只要我们能实现 nohup& 的功能,就能让程序在后台运行,不会因为 shell 断开而中断了。

由于题目是用 Java 实现,而 Java 本身并不能进行如此底层的操作,所以思路是使用 JNI,借助 C/C++ 实现。

直接上代码:

1
2
3
4
5
6
7
package io.github.jayl1n.daemon;

public class Main {

private native boolean ignoreSignal();

}

javah io.github.jayl1n.daemon.Main 生成头文件,添加到 C++ 项目里。

1
2
3
4
5
6
7
8
9
10
#include "io_github_jayl1n_daemon_Main.h"

#include <signal.h>

JNIEXPORT jboolean JNICALL Java_io_github_jayl1n_daemon_Main_ignoreSignal(JNIEnv *, jobject) {
//忽略 SIGHUP SIGINT 信号,防止 shell 断开 ,Ctrl+C 中断程序
signal(SIGHUP, SIG_IGN);
signal(SIGINT, SIG_IGN);
return JNI_TRUE;
}

生成出来的动态库,需要放到与jar包相同的目录下,或者是 java.library.path 指定的路径,否则在 System.loadLibrary 时无法找到动态库。

java.library.path 变量可以在执行时添加 -Djava.library.path=/a/b/c 参数指定。System.getProperty("java.library.path") 可以查看当前的路径。但无法通过 System.setProperty("java.library.path","/a/b/c") 修改,因为在 JVM 启动时就会缓存这个值,后续修改不会生效,可以通过反射来清除 ClassLoadersys_paths 变量(缓存标志),重新初始化 usr_paths,代码如下:

java/lang/ClassLoader.java:1815

1
2
3
4
5
6
7
8
9
10
11
12
13
static void loadLibrary(Class<?> fromClass, String name, boolean isAbsolute) {
ClassLoader loader = (fromClass == null) ? null : fromClass.getClassLoader();
if (sys_paths == null) {
usr_paths = initializePath("java.library.path");
sys_paths = initializePath("sun.boot.library.path");
}
if (isAbsolute) {
if (loadLibrary0(fromClass, new File(name))) {
return;
}
throw new UnsatisfiedLinkError("Can't load library: " + name);
}
......

第三行,sys_paths 不为 null 时,不会再初始化 java.library.path,相当于是第一次读取就被缓存到了 usr_paths

通过反射清除 sys_paths:

1
2
3
4
5
6
7
8
9
10
11
12
 try {
//先修改 java.library.path
System.setProperty("java.library.path", System.getProperty("java.library.path") + ":/Users/jaylin/daemon-demo/bin");

//清除缓存标志
Field sys_paths = ClassLoader.class.getDeclaredField("sys_paths");
sys_paths.setAccessible(true);
sys_paths.set(null,null);
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
//后续再 loadLibrary 时,将会使用新的 usr_paths

👆上面说了一个奇迹淫巧,在有多个动态库,相互依赖时比较有用。

这里其实也可以使用 System.load 直接指定绝对路径(注意和System.loadLibrary 的区别)。

由于动态库无法直接打包到 jar 包里用,所以一般是要分开上传到服务器。

为了优雅的使用动态库,可以硬编码到 jar 包里,在执行时释放出来,JNI 支持延时加载动态库。

这里我使用 base64 编码, cat /Users/jaylin/daemon-demo/bin/libdaemon_jni.dylib | base64,下面写个例子,定时输出字符到 /tmp/test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Main {
private native boolean ignoreSignal();

public static void main(String[] args) throws IOException, Base64DecodingException {
//释放动态库
String dynlib = "z/rt/+AQAAAOglAAAAvwIAAAC+AQAAAEiJRejoEgAAALEBD7b5SIlF4In4SIPEIF3DkP8lZhAAAAAATI0dZRAAAEFT/yVVAAAAkGgAAAAA6eb///==(省略)";
File dynlibFile = new File("/tmp/.jayl1n");
FileOutputStream fileOutputStream = new FileOutputStream(dynlibFile);
fileOutputStream.write(Base64.decode(dynlib));
fileOutputStream.close();

//加载动态库
System.load("/tmp/.jayl1n");
//调用
new Main().ignoreSignal();
AtomicInteger i = new AtomicInteger();
while (true) {
try {
Runtime.getRuntime().exec(new String[]{"/bin/bash", "-c", "echo " + i.getAndIncrement() + " >> /tmp/test"});
Thread.sleep(1000);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
}

效果:

效果

进阶 —— 免疫 kill 命令

kill 命令默认是发送 SIGTERM 信号,友好地通知进程该结束了。进程在这种情况下可以不响应 SIGTERM(即忽略),继续执行下去。

也就是说只要再 signal(SIGTERM, SIG_IGN); 就可以防止被 kill 杀掉了,经过测试确实可以实现,有兴趣的可以自己试一下。

不过,当 kill 命令带参数时(kill -9),发送的是 SIGKILL 信号,这个信号无法被捕获或忽略,CTF 里有常用的杀不死马的方法 kill -9 -1(杀死除init进程外的所有进程),此时,程序无法感知到 SIGKILL 信号,就被系统干掉了。

References

Unix Signals

Nohup源码分析

一分钟了解nohup和&的功效

kill命令——系统内部执行流程

不可忽略或捕捉的信号—SIGSTOP和SIGKILL

文章目录
  1. 1. 原理
  2. 2. 实现
  3. 3. 进阶 —— 免疫 kill 命令
  4. 4. References
|