通过给Tomcat打Monkey Patch实现数据库连接池配置用户密码加密

针对一个老旧的J2EE项目,为了满足信息安全需求,本文档详细介绍了如何在不改变原有代码的基础上,通过修改Tomcat自带的dbcp库来实现数据库连接池密码的加密处理。

    有一个陈旧的J2EE项目使用Tomcat做应用容器,数据库连接部分使用里容器的连接池,由于信息安全的需要,数据库密码不允许明文保存,而Tomcat的连接池配置文件不管是xml格式也好还是properties格式也好都只支持明文的读取,而且系统是陈旧项目,变动代码风险太大,因此决定修改中间件实现数据库密码的加密。
    通过查找一些资料,并且确定Tomcat确实使用的自带的dbcp库来维护连接池,于是第一步想通过重新构建commons-dbcp来实现,结果在执行的过程中发现dbcp有一堆的依赖库,而这些依赖库本身已经在Tomcat的发行包中已经具有,为了不让各种jar包泛滥,准备直接在Tomcat中修改dbcp代码。
    首先下载Tomcat的源码包,这里选择的Apache-Tomcat-6.0.35,这是6版本的最新子版本了,解决了很多Web服务器的漏洞,通过查看官方的Release Note,该版本使用了dbcp的1.3版本,在下载的Tomcat源码包中搜索,结果没发现dbcp的影子,通过插卡build.xml,了解到dbcp被作为Tomcat的依赖库处理,并且在执行Ant命令的时候得到提示,有ant download可以下载依赖包(先前尝试用Maven下载依赖包,结果Maven插件式的依赖包管理只适合于具体的某个项目,想不通过配置文件的修改达成目标基本是不可能了),于是执行ant download命令,在命令输出中看到了commons-dbcp-1.3.src字样,说明有戏了,只可惜等执行完,一个完整的tomcat-dbcp.jar已经出现了,还来不及动手。
    此时通过观察依赖包下载的目的路径,发现包已经被移动,Tomcat在构建是已经修改dbcp的包路径,于是将构建好的依赖包和类路径删除,此时修改dbcp最初是的源代码,之后执行ant download,此时编译报错了,说明修改的类已经作用于依赖包的构建了。仔细查看错误输出,结果发现是加密的代码依赖的类在Tomcat的依赖编译过程中没有定位到,于是将加密算法依赖的源代码拷贝到dbcp所在的原始目录,结果在重新ant download的过程中依赖包也同时被移动,并且自动变更了一用的import包路径,相当智能哦!
    到此就可以开始编译整个Tomcat源码包了,编译得到的tomcat-dbcp.jar替换原始的jar包。
    在此过程需要记录下重要的几个特点:
    1、dbcp的1.3版本只能用jdk 1.5编译,否则无法生产tomcat-dbcp.jar;
    2、加密算法依赖的包要拷贝至dbcp原始目录能引用到的位置,执行ant download命令时会自动拷贝到依赖配置中来,并自动解决包移动过程带来的import包路径问题;
    3、可逆的加密算法加密得到的串一般不全是可见字符,为了写入被指文件需要Base64之类的编码方式;
    4、连接池配置中的数据源工厂类要手工指定,Tomcat修改了缺省的commons-dbcp的包路径,使用commons原来的包名是不正确的;
    5、数据库驱动都要放在Tomcat的lib目录下,即使是应用程序范围的连接池是在应用还未初始化完成前就已经需要创建了。
    6、进行密码加密转换的位置为BasicDataSourceFactory.java从配置文件读入口令并设置到DataSource对象上的时候;
    7、密码在加密和解密时要保持和配置文件一致的编码方案,采用utf8会比较理想。

    下面加密算法和修改后的文件的情况:

   
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.commons.dbcp;

import java.io.ByteArrayInputStream;
import java.sql.Connection;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.Properties;
import java.util.StringTokenizer;
import java.util.Collections;

import javax.naming.Context;
import javax.naming.Name;
import javax.naming.RefAddr;
import javax.naming.Reference;
import javax.naming.spi.ObjectFactory;
import javax.sql.DataSource;

import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;

import org.apache.commons.codec.binary.Base64;

/**
 * <p>JNDI object factory that creates an instance of
 * <code>BasicDataSource</code> that has been configured based on the
 * <code>RefAddr</code> values of the specified <code>Reference</code>,
 * which must match the names and data types of the
 * <code>BasicDataSource</code> bean properties.</p>
 *
 * @author Craig R. McClanahan
 * @author Dirk Verbeeck
 * @version $Revision: 828639 $ $Date: 2009-10-22 06:27:43 -0400 (Thu, 22 Oct 2009) $
 */
public class BasicDataSourceFactory implements ObjectFactory {

    private final static String PROP_DEFAULTAUTOCOMMIT = "defaultAutoCommit";
    private final static String PROP_DEFAULTREADONLY = "defaultReadOnly";
    private final static String PROP_DEFAULTTRANSACTIONISOLATION = "defaultTransactionIsolation";
    private final static String PROP_DEFAULTCATALOG = "defaultCatalog";
    private final static String PROP_DRIVERCLASSNAME = "driverClassName";
    private final static String PROP_MAXACTIVE = "maxActive";
    private final static String PROP_MAXIDLE = "maxIdle";
    private final static String PROP_MINIDLE = "minIdle";
    private final static String PROP_INITIALSIZE = "initialSize";
    private final static String PROP_MAXWAIT = "maxWait";
    private final static String PROP_TESTONBORROW = "testOnBorrow";
    private final static String PROP_TESTONRETURN = "testOnReturn";
    private final static String PROP_TIMEBETWEENEVICTIONRUNSMILLIS = "timeBetweenEvictionRunsMillis";
    private final static String PROP_NUMTESTSPEREVICTIONRUN = "numTestsPerEvictionRun";
    private final static String PROP_MINEVICTABLEIDLETIMEMILLIS = "minEvictableIdleTimeMillis";
    private final static String PROP_TESTWHILEIDLE = "testWhileIdle";
    private final static String PROP_PASSWORD = "password";
    private final static String PROP_URL = "url";
    private final static String PROP_USERNAME = "username";
    private final static String PROP_VALIDATIONQUERY = "validationQuery";
    private final static String PROP_VALIDATIONQUERY_TIMEOUT = "validationQueryTimeout";
    /**
     * The property name for initConnectionSqls.
     * The associated value String must be of the form [query;]*
     * @since 1.3
     */
    private final static String PROP_INITCONNECTIONSQLS = "initConnectionSqls";
    private final static String PROP_ACCESSTOUNDERLYINGCONNECTIONALLOWED = "accessToUnderlyingConnectionAllowed";
    private final static String PROP_REMOVEABANDONED = "removeAbandoned";
    private final static String PROP_REMOVEABANDONEDTIMEOUT = "removeAbandonedTimeout";
    private final static String PROP_LOGABANDONED = "logAbandoned";
    private final static String PROP_POOLPREPAREDSTATEMENTS = "poolPreparedStatements";
    private final static String PROP_MAXOPENPREPAREDSTATEMENTS = "maxOpenPreparedStatements";
    private final static String PROP_CONNECTIONPROPERTIES = "connectionProperties";

    private final static String[] ALL_PROPERTIES = {
        PROP_DEFAULTAUTOCOMMIT,
        PROP_DEFAULTREADONLY,
        PROP_DEFAULTTRANSACTIONISOLATION,
        PROP_DEFAULTCATALOG,
        PROP_DRIVERCLASSNAME,
        PROP_MAXACTIVE,
        PROP_MAXIDLE,
        PROP_MINIDLE,
        PROP_INITIALSIZE,
        PROP_MAXWAIT,
        PROP_TESTONBORROW,
        PROP_TESTONRETURN,
        PROP_TIMEBETWEENEVICTIONRUNSMILLIS,
        PROP_NUMTESTSPEREVICTIONRUN,
        PROP_MINEVICTABLEIDLETIMEMILLIS,
        PROP_TESTWHILEIDLE,
        PROP_PASSWORD,
        PROP_URL,
        PROP_USERNAME,
        PROP_VALIDATIONQUERY,
        PROP_VALIDATIONQUERY_TIMEOUT,
        PROP_INITCONNECTIONSQLS,
        PROP_ACCESSTOUNDERLYINGCONNECTIONALLOWED,
        PROP_REMOVEABANDONED,
        PROP_REMOVEABANDONEDTIMEOUT,
        PROP_LOGABANDONED,
        PROP_POOLPREPAREDSTATEMENTS,
        PROP_MAXOPENPREPAREDSTATEMENTS,
        PROP_CONNECTIONPROPERTIES
    };

    // -------------------------------------------------- ObjectFactory Methods

    /**
     * <p>Create and return a new <code>BasicDataSource</code> instance.  If no
     * instance can be created, return <code>null</code> instead.</p>
     *
     * @param obj The possibly null object containing location or
     *  reference information that can be used in creating an object
     * @param name The name of this object relative to <code>nameCtx</code>
     * @param nameCtx The context relative to which the <code>name</code>
     *  parameter is specified, or <code>null</code> if <code>name</code>
     *  is relative to the default initial context
     * @param environment The possibly null environment that is used in
     *  creating this object
     *
     * @exception Exception if an exception occurs creating the instance
     */
    public Object getObjectInstance(Object obj, Name name, Context nameCtx,
                                    Hashtable environment)
        throws Exception {

        // We only know how to deal with <code>javax.naming.Reference</code>s
        // that specify a class name of "javax.sql.DataSource"
        if ((obj == null) || !(obj instanceof Reference)) {
            return null;
        }
        Reference ref = (Reference) obj;
        if (!"javax.sql.DataSource".equals(ref.getClassName())) {
            return null;
        }

        Properties properties = new Properties();
        for (int i = 0 ; i < ALL_PROPERTIES.length ; i++) {
            String propertyName = ALL_PROPERTIES[i];
            RefAddr ra = ref.get(propertyName);
            if (ra != null) {
                String propertyValue = ra.getContent().toString();
                properties.setProperty(propertyName, propertyValue);
            }
        }

        return createDataSource(properties);
    }

    /**
     * Creates and configures a {@link BasicDataSource} instance based on the
     * given properties.
     * 
     * @param properties the datasource configuration properties
     * @throws Exception if an error occurs creating the data source
     */
    public static DataSource createDataSource(Properties properties) throws Exception {
        BasicDataSource dataSource = new BasicDataSource();
        String value = null;

        value = properties.getProperty(PROP_DEFAULTAUTOCOMMIT);
        if (value != null) {
            dataSource.setDefaultAutoCommit(Boolean.valueOf(value).booleanValue());
        }

        value = properties.getProperty(PROP_DEFAULTREADONLY);
        if (value != null) {
            dataSource.setDefaultReadOnly(Boolean.valueOf(value).booleanValue());
        }

        value = properties.getProperty(PROP_DEFAULTTRANSACTIONISOLATION);
        if (value != null) {
            int level = PoolableConnectionFactory.UNKNOWN_TRANSACTIONISOLATION;
            if ("NONE".equalsIgnoreCase(value)) {
                level = Connection.TRANSACTION_NONE;
            }
            else if ("READ_COMMITTED".equalsIgnoreCase(value)) {
                level = Connection.TRANSACTION_READ_COMMITTED;
            }
            else if ("READ_UNCOMMITTED".equalsIgnoreCase(value)) {
                level = Connection.TRANSACTION_READ_UNCOMMITTED;
            }
            else if ("REPEATABLE_READ".equalsIgnoreCase(value)) {
                level = Connection.TRANSACTION_REPEATABLE_READ;
            }
            else if ("SERIALIZABLE".equalsIgnoreCase(value)) {
                level = Connection.TRANSACTION_SERIALIZABLE;
            }
            else {
                try {
                    level = Integer.parseInt(value);
                } catch (NumberFormatException e) {
                    System.err.println("Could not parse defaultTransactionIsolation: " + value);
                    System.err.println("WARNING: defaultTransactionIsolation not set");
                    System.err.println("using default value of database driver");
                    level = PoolableConnectionFactory.UNKNOWN_TRANSACTIONISOLATION;
                }
            }
            dataSource.setDefaultTransactionIsolation(level);
        }

        value = properties.getProperty(PROP_DEFAULTCATALOG);
        if (value != null) {
            dataSource.setDefaultCatalog(value);
        }

        value = properties.getProperty(PROP_DRIVERCLASSNAME);
        if (value != null) {
            dataSource.setDriverClassName(value);
        }

        value = properties.getProperty(PROP_MAXACTIVE);
        if (value != null) {
            dataSource.setMaxActive(Integer.parseInt(value));
        }

        value = properties.getProperty(PROP_MAXIDLE);
        if (value != null) {
            dataSource.setMaxIdle(Integer.parseInt(value));
        }

        value = properties.getProperty(PROP_MINIDLE);
        if (value != null) {
            dataSource.setMinIdle(Integer.parseInt(value));
        }

        value = properties.getProperty(PROP_INITIALSIZE);
        if (value != null) {
            dataSource.setInitialSize(Integer.parseInt(value));
        }

        value = properties.getProperty(PROP_MAXWAIT);
        if (value != null) {
            dataSource.setMaxWait(Long.parseLong(value));
        }

        value = properties.getProperty(PROP_TESTONBORROW);
        if (value != null) {
            dataSource.setTestOnBorrow(Boolean.valueOf(value).booleanValue());
        }

        value = properties.getProperty(PROP_TESTONRETURN);
        if (value != null) {
            dataSource.setTestOnReturn(Boolean.valueOf(value).booleanValue());
        }

        value = properties.getProperty(PROP_TIMEBETWEENEVICTIONRUNSMILLIS);
        if (value != null) {
            dataSource.setTimeBetweenEvictionRunsMillis(Long.parseLong(value));
        }

        value = properties.getProperty(PROP_NUMTESTSPEREVICTIONRUN);
        if (value != null) {
            dataSource.setNumTestsPerEvictionRun(Integer.parseInt(value));
        }

        value = properties.getProperty(PROP_MINEVICTABLEIDLETIMEMILLIS);
        if (value != null) {
            dataSource.setMinEvictableIdleTimeMillis(Long.parseLong(value));
        }

        value = properties.getProperty(PROP_TESTWHILEIDLE);
        if (value != null) {
            dataSource.setTestWhileIdle(Boolean.valueOf(value).booleanValue());
        }

        value = properties.getProperty(PROP_PASSWORD);
        if (value != null) {
            dataSource.setPassword(new String(doDes(1,doBase64(1,value.getBytes("UTF-8")))));//Coding
        }

        value = properties.getProperty(PROP_URL);
        if (value != null) {
            dataSource.setUrl(value);
        }

        value = properties.getProperty(PROP_USERNAME);
        if (value != null) {
            dataSource.setUsername(value);
        }

        value = properties.getProperty(PROP_VALIDATIONQUERY);
        if (value != null) {
            dataSource.setValidationQuery(value);
        }

        value = properties.getProperty(PROP_VALIDATIONQUERY_TIMEOUT);
        if (value != null) {
            dataSource.setValidationQueryTimeout(Integer.parseInt(value));
        }
        
        value = properties.getProperty(PROP_ACCESSTOUNDERLYINGCONNECTIONALLOWED);
        if (value != null) {
            dataSource.setAccessToUnderlyingConnectionAllowed(Boolean.valueOf(value).booleanValue());
        }

        value = properties.getProperty(PROP_REMOVEABANDONED);
        if (value != null) {
            dataSource.setRemoveAbandoned(Boolean.valueOf(value).booleanValue());
        }

        value = properties.getProperty(PROP_REMOVEABANDONEDTIMEOUT);
        if (value != null) {     
            dataSource.setRemoveAbandonedTimeout(Integer.parseInt(value));
        }

        value = properties.getProperty(PROP_LOGABANDONED);
        if (value != null) {
            dataSource.setLogAbandoned(Boolean.valueOf(value).booleanValue());
        }

        value = properties.getProperty(PROP_POOLPREPAREDSTATEMENTS);
        if (value != null) {
            dataSource.setPoolPreparedStatements(Boolean.valueOf(value).booleanValue());
        }

        value = properties.getProperty(PROP_MAXOPENPREPAREDSTATEMENTS);
        if (value != null) {
            dataSource.setMaxOpenPreparedStatements(Integer.parseInt(value));
        }

        value = properties.getProperty(PROP_INITCONNECTIONSQLS);
        if (value != null) {
            StringTokenizer tokenizer = new StringTokenizer(value, ";");
            dataSource.setConnectionInitSqls(Collections.list(tokenizer));
        }

        value = properties.getProperty(PROP_CONNECTIONPROPERTIES);
        if (value != null) {
          Properties p = getProperties(value);
          Enumeration e = p.propertyNames();
          while (e.hasMoreElements()) {
            String propertyName = (String) e.nextElement();
            dataSource.addConnectionProperty(propertyName, p.getProperty(propertyName));
          }
        }

        // DBCP-215
        // Trick to make sure that initialSize connections are created
        if (dataSource.getInitialSize() > 0) {
            dataSource.getLogWriter();
        }

        // Return the configured DataSource instance
        return dataSource;
    }

    /**
     * <p>Parse properties from the string. Format of the string must be [propertyName=property;]*<p>
     * @param propText
     * @return Properties
     * @throws Exception
     */
    static private Properties getProperties(String propText) throws Exception {
      Properties p = new Properties();
      if (propText != null) {
        p.load(new ByteArrayInputStream(propText.replace(';', '\n').getBytes()));
      }
      return p;
    }

	/**
	*	Base64 encoding
	*/
	public static byte[] doBase64(int type, byte[] src) {
		if (src == null || src.length == 0) {
			return null;
		}
		Base64 base64 = new Base64();
		if (type == 0) {
			return base64.encode(src);
		} else {
			return base64.decode(src);
		}
	}

	/**
	*	DES crypto
	*/
	public static byte[] doDes(int type, byte[] src) {
		if (src == null || src.length == 0) {
			return null;
		}
		final String passwd = "1234567890";
		int encType = 0;
		if (type == 0) {
			encType = Cipher.ENCRYPT_MODE;
		} else {
			encType = Cipher.DECRYPT_MODE;
		}
		PBEKeySpec pbeKeySpec = new PBEKeySpec(passwd.toCharArray());
		SecretKeyFactory keyFac;
		try {
			keyFac = SecretKeyFactory.getInstance("PBEWithMD5AndDES");
		} catch (NoSuchAlgorithmException e) {
			e.printStackTrace();
			return null;
		}
		SecretKey pbeKey;
		try {
			pbeKey = keyFac.generateSecret(pbeKeySpec);
		} catch (InvalidKeySpecException e) {
			e.printStackTrace();
			return null;
		}
		byte[] salt = { (byte) 0xc7, (byte) 0x73, (byte) 0x21, (byte) 0x8c,
				(byte) 0x7e, (byte) 0xc8, (byte) 0xee, (byte) 0x99 };
		int count = 2;
		PBEParameterSpec pbeParamSpec = new PBEParameterSpec(salt, count);
		Cipher pbeCipher;
		try {
			pbeCipher = Cipher.getInstance("PBEWithMD5AndDES");
		} catch (NoSuchAlgorithmException e) {
			e.printStackTrace();
			return null;
		} catch (NoSuchPaddingException e) {
			e.printStackTrace();
			return null;
		}
		try {
			pbeCipher.init(encType, pbeKey, pbeParamSpec);
		} catch (InvalidKeyException e) {
			e.printStackTrace();
			return null;
		} catch (InvalidAlgorithmParameterException e) {
			e.printStackTrace();
			return null;
		}
		byte[] srctext = src;
		byte[] destext;
		try {
			destext = pbeCipher.doFinal(srctext);
		} catch (IllegalBlockSizeException e) {
			e.printStackTrace();
			return null;
		} catch (BadPaddingException e) {
			e.printStackTrace();
			return null;
		}
		return destext;
	}
}

    生产加密密码的Java类如下所示:

   

import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;

import org.apache.commons.codec.binary.Base64;

//Compile with commons-codec-1.3.jar

public class Hello {

	/**
	*	Base64 encoding
	*/
	public static byte[] doBase64(int type, byte[] src) {
		if (src == null || src.length == 0) {
			return null;
		}
		Base64 base64 = new Base64();
		if (type == 0) {
			return base64.encode(src);
		} else {
			return base64.decode(src);
		}
	}

	/**
	*	DES crypto
	*/
	public static byte[] doDes(int type, byte[] src) {
		if (src == null || src.length == 0) {
			return null;
		}
		final String passwd = "1234567890";
		int encType = 0;
		if (type == 0) {
			encType = Cipher.ENCRYPT_MODE;
		} else {
			encType = Cipher.DECRYPT_MODE;
		}
		PBEKeySpec pbeKeySpec = new PBEKeySpec(passwd.toCharArray());
		SecretKeyFactory keyFac;
		try {
			keyFac = SecretKeyFactory.getInstance("PBEWithMD5AndDES");
		} catch (NoSuchAlgorithmException e) {
			e.printStackTrace();
			return null;
		}
		SecretKey pbeKey;
		try {
			pbeKey = keyFac.generateSecret(pbeKeySpec);
		} catch (InvalidKeySpecException e) {
			e.printStackTrace();
			return null;
		}
		byte[] salt = { (byte) 0xc7, (byte) 0x73, (byte) 0x21, (byte) 0x8c,
				(byte) 0x7e, (byte) 0xc8, (byte) 0xee, (byte) 0x99 };
		int count = 2;
		PBEParameterSpec pbeParamSpec = new PBEParameterSpec(salt, count);
		Cipher pbeCipher;
		try {
			pbeCipher = Cipher.getInstance("PBEWithMD5AndDES");
		} catch (NoSuchAlgorithmException e) {
			e.printStackTrace();
			return null;
		} catch (NoSuchPaddingException e) {
			e.printStackTrace();
			return null;
		}
		try {
			pbeCipher.init(encType, pbeKey, pbeParamSpec);
		} catch (InvalidKeyException e) {
			e.printStackTrace();
			return null;
		} catch (InvalidAlgorithmParameterException e) {
			e.printStackTrace();
			return null;
		}
		byte[] srctext = src;
		byte[] destext;
		try {
			destext = pbeCipher.doFinal(srctext);
		} catch (IllegalBlockSizeException e) {
			e.printStackTrace();
			return null;
		} catch (BadPaddingException e) {
			e.printStackTrace();
			return null;
		}
		return destext;
	}

	/**
	 * @param args
	 */
	public static void main(String[] args) throws Exception {
		// TODO Auto-generated method stub
		String src = "abcdefg";
		byte[] tmp = Hello.doBase64(0, Hello.doDes(0, src.getBytes("UTF-8")));
		String des = new String(tmp);
		String rev = new String(Hello.doDes(1, Hello.doBase64(1, tmp)));
		System.out.println(src);
		System.out.println(des);
		System.out.println(rev);
	}

}


    用于混算的密钥和混算数组、以及次数可以根据自己的需要进行设置,只是加密和解密的这三项一定要匹配,中间的byte数组不能随意转成String,会丢失数据。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值