Commons-fileupload组件文件上传——QP编码

发布于 2022-11-15  1595 次阅读


写在前面

  项目中遇到了一次waf的绕过,大师傅也没有透露太多,就漏了一段编码的东西,还是一半,没办法只能自己找一找研究一下了,功夫不负有心人,多方询问并且阅读了相关资料后了解了这次大师傅的手法——QP编码。刚刚好因为自己的师父是写java的,在他的帮助下弄了一个针对组件的测试环境,借由这个测试环境顺便看一下源码。
  废话不多说,进入正题,先是有关于QP编码的内容。

QP编码

  QP编码全名Quote-Printable编码,是使用在电子邮件中的一种编码算法,包括Base64Quote-Printable两种编码,不应该说包括吧,QP编码解码过程中支持对Base64字符串的解码,所以说编码后的格式满足QP编码格式的Base64字符串同样可以进行解密。
  这个编码来源于标准RFC-2045,发表于1996年,属于比较年长的技术了,标准中也详细叙述了关于它在电子邮件中的使用,从我所了解到的公开资料,针对于这种技术应用在Bypass中是今年(2022年)的二月份左右的一次比赛中,真正使用的时间应该会更早。

概念

  Quoted-Printable编码可译为“可打印字符引用编码”,或者“使用可打印字符的编码”。通常我们接收电子邮件,查看电子邮件原始信息,经常会看到这种类型的编码,电子邮件信头显示:Content-Transfer-Encoding: quoted-printable。它是多用途互联网邮件扩展(MIME) 一种实现方式。
  其中MIME是一个互联网标准,它扩展了电子邮件标准,致力于使其能够支持非ASCII字符、二进制格式附件等多种格式的邮件消息。
  目前http协议中,很多采用MIME框架,quoted-printable就是说用一些可打印常用字符,表示一个字节(8位)中所有非打印字符方法。
  任何一个8位的字节值可编码为3个字符:一个等号“=”后跟随两个十六进制数字(0–9或A–F)表示该字节的数值。
  经过QP编码后的数据,通常是下面这个样子:

=E5=8D=8F=E8=AE=AE=E5=88=86=E6=9E=90=E4=B8=8E=E8=BF=98=E5=8E=9F

  另外,Quoted-Printable编码的数据的每行长度不能超过76个字符。为满足此要求又不改变被编码文本,在QP编码结果的每行末尾加上软换行(soft line break)。 即在每行末尾加上一个”=”, 但并不会出现在解码得到的文本中。

关于相关环境

  经过网络上已经公开的资料查询到现在使用commons_fileupload.jar框架的业务系统都支持QP编码的使用,会写Java的各位大师傅搭建一个相关测试用例应该不难,这里我就不再粘相关测试用例代码了,展示一下我的测试结果。

image-20221115153309708

代码分析

  代码这边直接就从重点内容开始吧,前面不是很重要的可以自己看一下。
  调试几次之后,在org.apache.commons.fileupload.FileUploadBase.findNextItem()中找到了对文件名进行处理的代码String fileName = FileUploadBase.this.getFileName(headers);这里的fileName变量最终值为解码后的文件名,那么从这里跟进去。

// FileUploadBase.class

private boolean findNextItem()throws IOException {
    if (this.eof) {
        return false;
    } else {
        if (this.currentItem != null) {
            this.currentItem.close();
            this.currentItem = null;
        }
        while (true) {
            while (true) {
                while (true) {
                    boolean nextPart;
                    if (this.skipPreamble) {
                        nextPart = this.multi.skipPreamble();
                    } else {
                        nextPart = this.multi.readBoundary();
                    }

                    if (nextPart) {
                        FileItemHeaders headers = FileUploadBase.this.getParsedHeaders(this.multi.readHeaders());
                        String fieldName;
                        if (this.currentFieldName == null) {
                            fieldName = FileUploadBase.this.getFieldName(headers);
                            if (fieldName != null) {
                                String subContentType = headers.getHeader("Content-type");
                                if (subContentType == null || !subContentType.toLowerCase(Locale.ENGLISH).startsWith("multipart/mixed")) {
                                    String fileName = FileUploadBase.this.getFileName(headers);
                                    this.currentItem = new FileItemStreamImpl(fileName, fieldName, headers.getHeader("Content-type"), fileName == null, this.getContentLength(headers));
                                    this.currentItem.setHeaders(headers);
                                    this.notifier.noteItem();
                                    this.itemValid = true;
                                    return true;
                                }

                                this.currentFieldName = fieldName;
                                byte[]subBoundary = FileUploadBase.this.getBoundary(subContentType);
                                this.multi.setBoundary(subBoundary);
                                this.skipPreamble = true;
                                continue;
                            }
                        } else {
                            fieldName = FileUploadBase.this.getFileName(headers);
                            if (fieldName != null) {
                                this.currentItem = new FileItemStreamImpl(fieldName, this.currentFieldName, headers.getHeader("Content-type"), false, this.getContentLength(headers));
                                this.currentItem.setHeaders(headers);
                                this.notifier.noteItem();
                                this.itemValid = true;
                                return true;
                            }
                        }

                        this.multi.discardBodyData();
                    } else {
                        if (this.currentFieldName == null) {
                            this.eof = true;
                            return false;
                        }

                        this.multi.setBoundary(this.boundary);
                        this.currentFieldName = null;
                    }
                }
            }
        }
    }
}

image-20221115132257788

  getFileName()相关内容如下,调试几次之后,发现文件名在Map < String, String > params = parser.parse(pContentDisposition, ';');中进行了解码,根据fileName = (String)params.get("filename");fileName的值更加确定这个判断。

// FileUploadBase.class
protected String getFileName(FileItemHeaders headers) {
    return this.getFileName(headers.getHeader("Content-disposition"));
}

private String getFileName(String pContentDisposition) {
    String fileName = null;
    if (pContentDisposition != null) {
        String cdl = pContentDisposition.toLowerCase(Locale.ENGLISH);
        if (cdl.startsWith("form-data") || cdl.startsWith("attachment")) {
            ParameterParser parser = new ParameterParser();
            parser.setLowerCaseNames(true);
            Map < String, String > params = parser.parse(pContentDisposition, ';');
            if (params.containsKey("filename")) {
                fileName = (String)params.get("filename");
                if (fileName != null) {
                    fileName = fileName.trim();
                } else {
                    fileName = "";
                }
            }
        }
    }
    return fileName;
}

image-20221115132002603

  下面的代码就是针对相关内容的处理了,这里要注意,多处业务处理都是调用到这里,可能会导致误判,所以注意变量paramValue的值是否是编码后的文件名,如图中所示。

// ParameterParser.class

public Map < String, String > parse(String str, char separator) {
    return (Map)(str == null ? new HashMap() : this.parse(str.toCharArray(), separator));
}

public Map < String, String > parse(char[]charArray, char separator) {
    return (Map)(charArray == null ? new HashMap() : this.parse(charArray, 0, charArray.length, separator));
}

public Map < String, String > parse(char[]charArray, int offset, int length, char separator) {
    if (charArray == null) {
        return new HashMap();
    } else {
        HashMap < String, String > params = new HashMap();
        this.chars = charArray;
        this.pos = offset;
        this.len = length;
        String paramName = null;
        String paramValue = null;
        while (this.hasChar()) {
            paramName = this.parseToken(new char[]{
                '=',
                separator
            });
            paramValue = null;
            if (this.hasChar() && charArray[this.pos] == '=') {
                ++this.pos;
                paramValue = this.parseQuotedToken(new char[]{separator});
                if (paramValue != null) {
                    try {
                        paramValue = MimeUtility.decodeText(paramValue);
                    } catch (UnsupportedEncodingException var9) {}
                }
            }

            if (this.hasChar() && charArray[this.pos] == separator) {
                ++this.pos;
            }

            if (paramName != null && paramName.length() > 0) {
                if (this.lowerCaseNames) {
                    paramName = paramName.toLowerCase(Locale.ENGLISH);
                }

                params.put(paramName, paramValue);
            }
        }

        return params;
    }
}

image-20221115133841256

  重点来了,先看这个类的名字,MimeUtility.class针对mime的一个类,这也就说明我们找对了地方,这里简单解释一下,概念里面有讲到,业务中规定QP编码后的长度不能超过76个字符,超过76个字符的要加入软换行,所以首先对这个内容进行处理,根据软换行分段进行解码,然后调用最终的处理方法MimeUtility.decodeWord()

// MimeUtility.class

public static String decodeText(String text)throws UnsupportedEncodingException {
    if (text.indexOf("=?") < 0) {
        return text;
    } else {
        int offset = 0;
        int endOffset = text.length();
        int startWhiteSpace = -1;
        int endWhiteSpace = -1;
        StringBuilder decodedText = new StringBuilder(text.length());
        boolean previousTokenEncoded = false;
        while (true) {
            while (true) {
                while (offset < endOffset) {
                    char ch = text.charAt(offset);
                    if (" \t\r\n".indexOf(ch) != -1) {
                        for (startWhiteSpace = offset; offset < endOffset; ++offset) {
                            ch = text.charAt(offset);
                            if (" \t\r\n".indexOf(ch) == -1) {
                                endWhiteSpace = offset;
                                break;
                            }
                        }
                    } else {
                        int wordStart;
                        for (wordStart = offset; offset < endOffset; ++offset) {
                            ch = text.charAt(offset);
                            if (" \t\r\n".indexOf(ch) != -1) {
                                break;
                            }
                        }

                        String word = text.substring(wordStart, offset); // 截取处理换行符
                        if (word.startsWith("=?")) {
                            try {
                                String decodedWord = decodeWord(word); // 最终调用decodeWord()方法
                                if (!previousTokenEncoded && startWhiteSpace != -1) {
                                    decodedText.append(text.substring(startWhiteSpace, endWhiteSpace));
                                    startWhiteSpace = -1;
                                }

                                previousTokenEncoded = true;
                                decodedText.append(decodedWord);
                                continue;
                            } catch (ParseException var11) {}
                        }

                        if (startWhiteSpace != -1) {
                            decodedText.append(text.substring(startWhiteSpace, endWhiteSpace));
                            startWhiteSpace = -1;
                        }

                        previousTokenEncoded = false;
                        decodedText.append(word);
                    }
                }

                return decodedText.toString();
            }
        }
    }
}

  这里就是针对编码字符的解码处理了,首先判断是否符合编码格式,满足编码格式后,将编码内容分为三段进行截取。举个栗子,编码后的字符串为=?UTF-8?Q?=31=31=31=2e=6a=73=70?=,截取的三段分别为UTF-8Q=31=31=31=2e=6a=73=70,根据第一段设置字符集,第二段设置解码方式:Q对应QP编码,B对应Base64编码,第三段为编码字符串。另外在这里,编码字符串还进行了一个转换,即每一个字符转换为十六进制存储在byte数组中,代码中也有注解。

// MimeUtility.class

private static String decodeWord(String word) throws ParseException, UnsupportedEncodingException {
    if (!word.startsWith("=?")) {
        throw new ParseException("Invalid RFC 2047 encoded-word: " + word);
    } else {
        int charsetPos = word.indexOf(63, 2);
        if (charsetPos == -1) {
            throw new ParseException("Missing charset in RFC 2047 encoded-word: " + word);
        } else {
            String charset = word.substring(2, charsetPos).toLowerCase(Locale.ENGLISH);
            int encodingPos = word.indexOf(63, charsetPos + 1);
            if (encodingPos == -1) {
                throw new ParseException("Missing encoding in RFC 2047 encoded-word: " + word);
            } else {
                String encoding = word.substring(charsetPos + 1, encodingPos);
                int encodedTextPos = word.indexOf("?=", encodingPos + 1);
                if (encodedTextPos == -1) {
                    throw new ParseException("Missing encoded text in RFC 2047 encoded-word: " + word);
                } else {
                    String encodedText = word.substring(encodingPos + 1, encodedTextPos);
                    if (encodedText.length() == 0) {
                        return "";
                    } else {
                        try {
                            ByteArrayOutputStream out = new ByteArrayOutputStream(encodedText.length());
                            byte[] encodedData = encodedText.getBytes("US-ASCII"); // 将所有字符的十六进制放在新的encodeData byte型数组中
                            if (encoding.equals("B")) {
                                Base64Decoder.decode(encodedData, out);
                            } else {
                                if (!encoding.equals("Q")) {
                                    throw new UnsupportedEncodingException("Unknown RFC 2047 encoding: " + encoding);
                                }

                                QuotedPrintableDecoder.decode(encodedData, out); // 解码
                            }

                            byte[] decodedData = out.toByteArray();
                            return new String(decodedData, javaCharset(charset));
                        } catch (IOException var10) {
                            throw new UnsupportedEncodingException("Invalid RFC 2047 encoding");
                        }
                    }
                }
            }
        }
    }
}

  这里就是编码字符串解码算法了。首先针对一些特殊字符进行处理:下划线转换为空格,匹配到"="后开始对"="后两位进行解码。

// QuotedPrintableDecoder.class

public static int decode(byte[] data, OutputStream out) throws IOException {
    int off = 0;
    int length = data.length;
    int endOffset = off + length;
    int bytesWritten = 0;

    while(off < endOffset) {
        byte ch = data[off++];
        if (ch == 95) {
            out.write(32); // 下划线转空格
        } else if (ch == 61) { // 匹配到"="开始匹配解码字符
            if (off + 1 >= endOffset) {
                throw new IOException("Invalid quoted printable encoding; truncated escape sequence");
            }

            byte b1 = data[off++];
            byte b2 = data[off++];
            if (b1 == 13) { // 判断等号后是否是回车键
                if (b2 != 10) { // 判断第二位是否是换行
                    throw new IOException("Invalid quoted printable encoding; CR must be followed by LF");
                }
            } else {
                int c1 = hexToBinary(b1);
                int c2 = hexToBinary(b2); 
                out.write(c1 << 4 | c2); // 将两位十六进制数字转回到一位十六进制,简单解释就是c1左移运算4位,再与c2进行或运算
                ++bytesWritten;
            }
        } else {
            out.write(ch);
            ++bytesWritten;
        }
    }

    return bytesWritten; // 返回结果
}

private static int hexToBinary(byte b) throws IOException {
    int i = Character.digit((char)b, 16);
    if (i == -1) {
        throw new IOException("Invalid quoted printable encoding: not a valid hex digit: " + b);
    } else {
        return i;
    }
}

  整体的流程就是这样了,比较有意思的就是这个解码的算法,因为之前没有接触过,并没有很快的反应过来,我师父给我解释说所有的算法最重要的依据就是ASCII表,所以最终都会计算到相应的ASCII值。
  而这里代码中的算法是将一个字符的十六进制分为两个字符,然后将两个字符中第一位左移四位再与第二位进行或运算,最终可以得到原字符的ASCII值。
  举个栗子来说吧。下面的代码为伪代码。

str1 = 'a'
hex1 = hex(ord(str1)) # hex1 = 0x61
# 这里hex1我们只取61,所以下面我们直接使用hex1 = 61
hex1 = 61
hex2 = 6
hex3 = 1
# 这里将hex1的两位数字分为两个变量分别存储,对应上文中一个字符的十六进制分为两个字符
# 然后进行运算
result = hex2 << 4 | hex3 # result = 97
# 对应的字符'a'的ascii值为97
chr(result) == str1 --> true

  这算是我第一次研究算法,虽然并不是很复杂的东西。

其他

  在后续的使用过程中,毕竟手写编码比较麻烦,简单写了个脚本,原理简单描述一下:以Python中的urlencode编码思想为基础,将urlencode中的%替换为=即可,并拼接其他格式要求内容

import base64
import click

def QP(str_input:str):
    result = "".join(hex(ord(i)).replace("0x", "=") for i in str_input)
    return f"=?UTF-8?Q?{result}?="

def base64_Q(str_input:str):
    result = base64.b64encode(str_input.encode()).decode()
    return f"=?gbk?B?{result}?="

CONTEXT_SETTINGS = dict(help_option_names=['-h','--help'])
@click.command(context_settings=CONTEXT_SETTINGS)
@click.option('--text', help="Enter the string to be encoded")
@click.option('-T', '--type', default='QP', help="Select the encoding format(base64/QP). The default is Quoted printable")
def main(text, type):
    if text:
        if type == "QP":
            click.secho(QP(text))
        elif type == "base64":
            click.secho(base64_Q(text))

if __name__ == '__main__':
    main()

写在最后

  总结一下,这算是第一次比较完整的动态调试的代码审计工作,感觉自己的思路还是比较残暴,能动调就看盯着参数变化看,看从哪里产生了变化,然后再继续跟进相关代码,相对熟悉Java业务一遍过的方式还是比较费时间的,所以Java能力还是欠缺很多。
  另外,关于QP编码,运用到bypass中是今年(2022年)二月份时已经公开的内容,由于自身跟进时事的能力以及方式等相关问题没能了解到,这还是比较致命的,所以后续这边还需要改善。
  以上~

参考链接

Quoted-Printable编码(QP编码)详解

Java文件上传大杀器-绕waf(针对commons-fileupload组件)