kelinci 简介 kelinci是一个能够在不修改AFL代码的情况下对Java进行模糊测试的工具。
架构&&原理 
kelinic主要分为两个大模块,分别为Fuzzer端和 Java端。
Fuzzer端 Fuzzer端主要由AFL和一个C文件interface.c组成。
对于AFL来说,AFL只是以为在fuzz interface这个程序,而不是java程序。
AFL在运行过程中会将输入文件传递给interface,然后interface会将文件传递给java端,java端会将运行结果反馈给fuzz端,从而使AFL进一步fuzz。
Java端 Java端的Instrumentor主要有两部分,一个TCP服务器,和真正的要测试的程序。
待测试程序会先使用Java的ASM库进行插桩。
TCP服务器负责和Fuzzer端的interface通信。并运行被插桩的目标程序。
细节 Mem.java kelinci受制于语言,没有像AFL一样直接开辟共享内存区域,而是自己定义了一个Mem静态工具类来当共享内存。
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 public  class  Mem  { 	 	public  static  final  int  SIZE  =  65536 ; 	public  static  byte  mem[] = new  byte [SIZE]; 	public  static  int  prev_location  =  0 ; 	 	 	public  static  void  clear ()  { 		for  (int  i  =  0 ; i < SIZE; i++) 			mem[i] = 0 ; 	} 	 	 	public  static  void  print ()  { 		for  (int  i  =  0 ; i < SIZE; i++) { 			if  (mem[i] != 0 ) { 				System.out.println(i + " -> "  + mem[i]); 			} 		} 	} }
 
Kelinci.java kelinci类中的main函数首先会解析命令行参数,然后加载目标程序的main函数,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ClassLoader  classloader  =  Thread.currentThread().getContextClassLoader(); 		try  { 			Class<?> target = classloader.loadClass(mainClass); 			targetMain = target.getMethod("main" , String[].class); 		} catch  (ClassNotFoundException e) { 			System.err.println("Main class not found: "  + mainClass); 			return ; 		} catch  (NoSuchMethodException e) { 			System.err.println("No main method found in class: "  + mainClass); 			return ; 		} catch  (SecurityException e) { 			System.err.println("Main method in class not accessible: "  + mainClass); 			return ; 		}
 
然后会拉起一个服务端线程和fuzz线程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Thread  server  =  new  Thread (new  Runnable () {     @Override      public  void  run ()  {         runServer();     } }); server.start();Thread  fuzzerRuns  =  new  Thread (new  Runnable () {     @Override      public  void  run ()  {         doFuzzerRuns();     } }); fuzzerRuns.start();
 
服务器线程会先创建一个socket对象,并监听端口的请求。监听到请求后会将请求放入队列中,供fuzz线程使用,若队列已满则返回队列已满的信号。
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 try  (ServerSocket  ss  =  new  ServerSocket (port)) { 		while  (true ) { 			Socket  s  =  ss.accept(); 			boolean  status  =  false ; 			if  (requestQueue.size() < maxQueue) { 				status = requestQueue.offer(new  FuzzRequest (s)); 			}  			if  (!status) { 				OutputStream  os  =  s.getOutputStream(); 				os.write(STATUS_QUEUE_FULL); 				os.flush(); 				s.shutdownOutput(); 				s.shutdownInput(); 				s.close(); 			} 		} 	} catch  (BindException be) { 		System.err.println("Unable to bind to port "  + port); 		System.exit(1 ); 	} catch  (Exception e) { 		System.err.println("Exception in request server" ); 		e.printStackTrace(); 		System.exit(1 ); 	} }
 
fuzz线程会不断的从请求队列中拉取请求,拉取新的请求后会定义输入输出流,然后将共享内存清0
1 2 3 4 5 6 7 8 9 10 while  (true ) { 			try  { 				FuzzRequest  request  =  requestQueue.poll(); 				if  (request != null ) { 					InputStream  is  =  request.clientSocket.getInputStream(); 					OutputStream  os  =  request.clientSocket.getOutputStream(); 					Mem.clear(); 					byte  result  =  STATUS_CRASH; 					ApplicationCall  appCall  =  null ;
 
以本地模式来举例,首先读取path的长度,然后读取path,根据path定义appCall,期间异常全部按照连接异常来返回。
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 if  (mode == LOCAL_MODE) {						 	 	int  pathlen  =  is.read() | is.read() << 8  | is.read() << 16  | is.read() << 24 ; 	if  (pathlen < 0 ) { 		result = STATUS_COMM_ERROR; 	} else  { 	 		 		byte  input[] = new  byte [pathlen]; 		int  read  =  0 ; 		while  (read < pathlen) {			 			if  (is.available() > 0 ) { 				input[read++] = (byte ) is.read(); 			} else  { 					result = STATUS_COMM_ERROR; 					break ; 				} 			} 		} 		String  path  =  new  String (input); 		appCall = new  ApplicationCall (path); 	} 	 } 
 
处理好输入文件后,执行appCall,并根据执行的状态返回不同的结果,将status和共享内存通过TCP连接,写回Fuzzer端。
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 27 28 29 if  (result != STATUS_COMM_ERROR && appCall != null ) { 	 	ExecutorService  executor  =  Executors.newSingleThreadExecutor(); 	Future<Long> future = executor.submit(appCall); 	try  { 		future.get(timeout, TimeUnit.MILLISECONDS); 		result = STATUS_SUCCESS; 	} catch  (TimeoutException te) { 		future.cancel(true ); 		result = STATUS_TIMEOUT; 	} catch  (Throwable e) { 		future.cancel(true ); 		if  (e.getCause() instanceof  RuntimeException) { 		} else  if  (e.getCause() instanceof  Error) { 		} 		e.printStackTrace(); 	} 	executor.shutdownNow(); } os.write(result); os.write(Mem.mem, 0 , Mem.mem.length); os.flush(); request.clientSocket.shutdownOutput(); request.clientSocket.shutdownInput(); request.clientSocket.setSoLinger(true , 100000 ); request.clientSocket.close();
 
appCall中,会调用runApplication函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private  static  class  ApplicationCall  implements  Callable <Long> { 	byte  input[]; 	String path; 	ApplicationCall(byte  input[]) { 		this .input = input; 	} 	 	ApplicationCall(String path) { 		this .path = path; 	} 	@Override  	public  Long call ()  throws  Exception { 		if  (path != null ) 			return  runApplication(path); 		return  runApplication(input); 	} }
 
runApplicatio函数会执行目标程序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private  static  long  runApplication (String filename)  { 		long  runtime  =  -1L ; 		String[] args = Arrays.copyOf(targetArgs, targetArgs.length); 		for  (int  i  =  0 ; i < args.length; i++) { 			if  (args[i].equals("@@" )) { 				args[i] = filename; 			} 		} 		long  pre  =  System.nanoTime(); 		try  { 			targetMain.invoke(null , (Object) args); 		} catch  (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { 			e.printStackTrace(); 			throw  new  RuntimeException ("Error invoking target main method" ); 		} 		runtime = System.nanoTime() - pre; 		return  runtime; 	}
 
插桩的实现主要在MethodTransformer.java里面,InstrumentLocation里面,仿照AFL的方式进行插桩。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private  void  instrumentLocation ()  { 		Integer  id  =  getNewLocationId(); 		mv.visitFieldInsn(GETSTATIC, "edu/cmu/sv/kelinci/Mem" , "mem" , "[B" ); 		mv.visitLdcInsn(id); 		mv.visitFieldInsn(GETSTATIC, "edu/cmu/sv/kelinci/Mem" , "prev_location" , "I" ); 		mv.visitInsn(IXOR); 		mv.visitInsn(DUP2); 		mv.visitInsn(BALOAD); 		mv.visitInsn(ICONST_1); 		mv.visitInsn(IADD); 		mv.visitInsn(I2B); 		mv.visitInsn(BASTORE); 		mv.visitIntInsn(SIPUSH, (id >> 1 )); 		mv.visitFieldInsn(PUTSTATIC, "edu/cmu/sv/kelinci/Mem" , "prev_location" , "I" ); 	}
 
访问类方法,分支时,均会调用插桩函数。
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 27 28 29 30 @Override  	public  void  visitCode ()  { 		mv.visitCode(); 		 		 		instrumentLocation(); 	} 	 	@Override  	public  void  visitJumpInsn (int  opcode, Label label)  { 		mv.visitJumpInsn(opcode, label); 		 		 		instrumentLocation(); 	} 	 	@Override  	public  void  visitLabel (Label label)  { 		mv.visitLabel(label); 		 		 		instrumentLocation(); 	}
 
Instrumentor.java 在Instrumentor.java中,创建ClassTransformer对象,对输入的class进行插桩。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 for  (String cls : inputClasses) { 			System.out.println("Instrumenting class: "  + cls); 			InputStream  bytecode  =  classloader.getResourceAsStream(cls); 			 			ClassWriter  cw  =  new  ClassWriter (ClassWriter.COMPUTE_FRAMES); 			ClassTransformer  ct  =  new  ClassTransformer (cw); 			ClassReader cr; 			try  { 				cr = new  ClassReader (bytecode); 			} catch  (IOException | NullPointerException e) { 				System.err.println("Error loading class: "  + cls); 				e.printStackTrace(); 				return ; 			}
 
interface.c interface中
首先会创建属于AFL的共享内存
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 char * shmname = getenv(SHM_ENV_VAR);   int  status = 0 ;   uint8_t  kelinci_status = STATUS_SUCCESS;   if  (shmname) {          in_afl = 1 ; 	            LOG("SHM_ID: %s\n" , shmname);     key_t  key = atoi(shmname);     if  ((trace_bits = shmat(key, 0 , 0 )) == (uint8_t *) -1 ) {       DIE("Failed to access shared memory 2\n" );     }     LOGIFVERBOSE("Pointer: %p\n" , trace_bits);     LOG("Shared memory attached. Value at loc 3 = %d\n" , trace_bits[3 ]);
 
然后会开始fork server
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 while (1 ) {       if (read(198 , &status, 4 ) != 4 ) {          DIE("Read failed\n" );       }       int  child_pid = fork();       if  (child_pid < 0 ) {         DIE("Fork failed\n" );       } else  if  (child_pid == 0 ) {         LOGIFVERBOSE("Child process, continue after pork server loop\n" ); 	break ;       }       LOGIFVERBOSE("Child PID: %d\n" , child_pid);       write(199 , &child_pid, 4 );              LOGIFVERBOSE("Status %d \n" , status);       if (waitpid(child_pid, &status, 0 ) <= 0 ) {         DIE("Fork crash" );       }       LOGIFVERBOSE("Status %d \n" , status);       write(199 , &status, 4 );     }
 
子进程会不断尝试连接java端端服务器,将文件长度和文件的路径发送到服务器。发送后会等待java端返回的状态和共享内存。并将传递过来的共享内存写入真正的共享内存之中。
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53  do  {          if (try > 0 )       usleep(100000 );     setup_tcp_connection(server, port);          write(tcp_socket, &mode, 1 );          if  (mode == LOCAL_MODE) {              char  path[10000 ];       realpath(filename, path);              int  pathlen = strlen (path);       if  (write(tcp_socket, &pathlen, 4 ) != 4 ) {         DIE("Error sending path length" );       }       LOG("Sent path length: %d\n" , pathlen);              if  (write(tcp_socket, path, pathlen) != pathlen) {         DIE("Error sending path" );       }       LOG("Sent path: %s\n" , path);              nread = read(tcp_socket, &kelinci_status, 1 );     if  (nread != 1 ) {       LOG("Failure reading exit status over socket.\n" );       kelinci_status = STATUS_COMM_ERROR;       goto  cont;     }     LOG("Return kelinci_status = %d\n" , status);             uint8_t  *shared_mem = malloc (SHM_SIZE);     for  (int  offset = 0 ; offset < SHM_SIZE; offset += SOCKET_READ_CHUNK) {       nread = read(tcp_socket, shared_mem+offset, SOCKET_READ_CHUNK);       if  (nread != SOCKET_READ_CHUNK) { 	LOG("Error reading from socket\n" ); 	kelinci_status = STATUS_COMM_ERROR; ...        for  (int  i = 0 ; i < SHM_SIZE; i++) {       if  (shared_mem[i] != 0 ) {         LOG("%d -> %d\n" , i, shared_mem[i]);         trace_bits[i] += shared_mem[i];       }     }
 
3.21补充 实际上手发现了不一样的地方,一般进行AFL模糊测试时,会用afl-gcc对目标程序进行编译插桩,而kelinci并没有。
而是用afl-fuzz直接运行,因为在interface.c中,作者利用了afl本身的管道通信和forkserver的机制。
具体的细节如下面两张图所示
而在interface.c中,作者没有对interface进行插桩,但根据代码可以理解为作者手动进行了插桩,利用了AFL的forkserver机制。
首先开启forkserver机制,通过向FORKSRV_FD+1即199这个文件描述符,向状态管道中写入4个字节的值,用来告知afl fuzz,fork server成功启动,等待下一步指示。
1 2 3 4 5 LOG("Starting fork server...\n" );if  (write(199 , &status, 4 ) != 4 ) {   LOG("Write failed\n" );   goto  resume; }
 
进入__afl_fork_wait_loop循环,从FORKSRV即198中读取字节到_afl_temp,直到读取到4个字节,这代表afl fuzz命令我们新建进程执行一次测试。
1 2 3 if (read(198 , &status, 4 ) != 4 ) {    DIE("Read failed\n" ); }
 
fork出子进程,原来的父进程充当fork server来和fuzz进行通信,而子进程则继续执行target。父进程即fork server将子进程的pid写入到状态管道,告知fuzz。
1 2 LOGIFVERBOSE("Child PID: %d\n" , child_pid);       write(199 , &child_pid, 4 );
 
然后父进程即fork server等待子进程结束,并保存其执行结果到_afl_temp中,然后将子进程的执行结果,从_afl_temp写入到状态管道,告知fuzz。
1 2 3 4 5 6 if (waitpid(child_pid, &status, 0 ) <= 0 ) {        DIE("Fork crash" );      }      LOGIFVERBOSE("Status %d \n" , status);      write(199 , &status, 4 );
 
父进程不断轮询__afl_fork_wait_loop循环,不断从控制管道读取,直到fuzz端命令fork server进行新一轮测试。
运行 作者在项目里给了运行实例。
首先要编译目标程序
 
然后对目标程序进行插桩,这里由于java反射安全等原因,只能用jdk8,才能成功运行并进行插桩。
1 java -cp ../../instrumentor/build/libs/kelinci.jar edu.cmu.sv.kelinci.instrumentor.Instrumentor -i bin -o bin-instrumented
 
然后可以开始启动kelinci的server端
1 java -cp bin-instrumented edu.cmu.sv.kelinci.Kelinci SimpleBuggy @@
 
再启动AFL端即可对目标java程序进行fuzz
1 afl-fuzz -i in_dir -o out_dir ../../fuzzerside/interface @@