写在前面
项目中遇到了一次waf的绕过,大师傅也没有透露太多,就漏了一段编码的东西,还是一半,没办法只能自己找一找研究一下了,功夫不负有心人,多方询问并且阅读了相关资料后了解了这次大师傅的手法——QP编码。刚刚好因为自己的师父是写java的,在他的帮助下弄了一个针对组件的测试环境,借由这个测试环境顺便看一下源码。
废话不多说,进入正题,先是有关于QP编码的内容。
QP编码
QP编码全名Quote-Printable编码
,是使用在电子邮件中的一种编码算法,包括Base64
和Quote-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的各位大师傅搭建一个相关测试用例应该不难,这里我就不再粘相关测试用例代码了,展示一下我的测试结果。
代码分析
代码这边直接就从重点内容开始吧,前面不是很重要的可以自己看一下。
调试几次之后,在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;
}
}
}
}
}
}
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;
}
下面的代码就是针对相关内容的处理了,这里要注意,多处业务处理都是调用到这里,可能会导致误判,所以注意变量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;
}
}
重点来了,先看这个类的名字,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-8
、Q
、=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年)二月份时已经公开的内容,由于自身跟进时事的能力以及方式等相关问题没能了解到,这还是比较致命的,所以后续这边还需要改善。
以上~
参考链接
Java文件上传大杀器-绕waf(针对commons-fileupload组件)
Comments | NOTHING