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