甲骨文数据库身份认证通信加密与解密技术探究(一)

工作准备

·一台Oracle数据库服务 版本为10g、11g、12c三个版本

·甲骨文官方提供的Oracle数据库客户端instanceclient 18.3.x

·Java开发环境eclipse + jdk1.8

·Java逆向反编译工具JD-Core

反编译ODBC8分析甲骨文客户端(instanceclient)与服务器(Oracle database)通信原理

1、经过反编译分析T4CConnection.class文件我们可以看到登录函数logon()所执行的伪代码如下所示:

void logon() throws SQLException {
		long var1 = 0L;
		Object var3 = null;
		boolean var22 = false;

		try {
			……

			this.auth = new T4CTTIoauthenticate(this, this.resourceManagerId);
			……
			if (this.userName != null && this.userName.length() != 0) {
				try {
					this.auth.doOSESSKEY(this.userName, this.LOGON_MODE);
					……
				} catch (SQLException var27) {
					if (var27.getErrorCode() != 1017) {
						throw var27;
					}

					this.userName = null;
				}
			}

			this.auth.doOAUTH(this.userName,this.password.get(),this.newPasswordValue.get(),this.LOGON_MODE);
			……

	}

从这段伪代码中可以看出首先程序实例化了T4CTTIoauthenticate实例,之后依次调用了该实例的doOSESSKEY(String,String)和doOAUTH(String,String,Stirng,int)两个函数。

2、分析反编译后doOSESSKEY和doOAUTH函数源代码

(1)首先是函数doOSESSKEY(String,String),具体如下

void doOSESSKEY(String var1, long var2) throws IOException, SQLException {
		this.setFunCode((short) 118);
		this.user = this.meg.conv.StringToCharBytes(var1);
		this.logonMode = var2 | 1L;
		this.keyValList = new T4CKvaldfList(this.meg.conv);
		this.keyValList.add("AUTH_TERMINAL", this.terminal);
		if (this.programName != null) {
			this.keyValList.add("AUTH_PROGRAM_NM", this.programName);
		}

		this.keyValList.add("AUTH_MACHINE", this.machine);
		this.keyValList.add("AUTH_PID", this.processID);
		this.keyValList.add("AUTH_SID", this.sysUserName);
		this.outNbPairs = 0;
		this.outKeys = (byte[][]) null;
		this.outValues = (byte[][]) null;
		this.outFlags = new int[0];
		this.doRPC();
	}

配合tcp通信数据分析如下:

23815e4e19b5b655a05b7c828387c5aaa5f.jpg

从通信数据分析可知客户端向服务器发送登录所使用的用户账号scott,服务器响应客户端一个AUTH_SESSKEY以及AUTH_VFR_DATA等一系列信息。

2)函数doOAUTH(String,String,Stirng,int),由于此函数代码比较长此处只展示关键部分伪代码如下所示:

void doOAUTH(String var1, @Blind String var2, @Blind String var3, long var4) throws IOException, SQLException {
		……
							this.o5logonHelper = new O5Logon(this.connection,this.connection.isO7L_MRExposed,									this.connection.thinUseJCEAPI);
						……

						this.encryptedKB = new byte[this.encryptedSK.length];

						for (int var22 = 0; var22 < this.encryptedKB.length; ++var22) {
							this.encryptedKB[var22] = 1;
						}

						int[] var34 = new int[1];
						var23 = new byte[256];
						int[] var24 = new int[1];
						byte[] var25 = new byte[256];

						for (int var26 = 0; var26 < 256; ++var26) {
							var23[var26] = 0;
						}

						……
							try {
								this.o5logonHelper.generateOAuthResponse(this.verifierType, this.salt, var19, var20,
										var7, this.encryptedSK, this.encryptedKB, var23, var34,
										this.meg.conv.isServerCSMultiByte,
										this.connection.getServerCompileTimeCapability(4), this.PBKDF2Salt,
										this.PBKDF2VgenCount, this.PBKDF2SderCount, var25, var24);
							} catch (Exception var30) {
								;
							}

							……

							
			String var33 = this.connection.sessionProperties.getProperty("AUTH_SVR_RESPONSE");

			try {
				if (this.o5logonHelper == null) {
					this.o5logonHelper = new O5Logon(this.connection, this.connection.isO7L_MRExposed,
							this.connection.thinUseJCEAPI);
				}

				if (!this.o5logonHelper.validateServerIdentity(var33)) {
					throw (SQLException) ((SQLException) DatabaseError
							.createSqlException(this.getConnectionDuringExceptionHandling(), 452).fillInStackTrace());
				}
			} catch (Exception var29) {
				throw (SQLException) ((SQLException) DatabaseError
						.createSqlException(this.getConnectionDuringExceptionHandling(), 452).fillInStackTrace());
			}
		}

	}

配合tcp通信数据分析如下:

24c974b6cc958b3e871158befbbca9aad95.jpg

2e64c356ae32dbd58037138098d1358c216.jpg

客户端向Oracle服务发送用户名scott、AUTH_PASSWORDAUTH_SESSKEY等认证信息,Oracle服务响应客户端认证结果AUTH_SVR_RESPONSE等认证信息。

3、得出认证流程图如下所示:

2c25282285c50b3d2b0308646106277d505.jpg

 

认证方式分析

经过笔者逆向分析得知oracle在使用o5logon认证模式时目前常见认证方式有(数字表示):2361、6949、18453。其中2361为10g使用,6949为11g所使用,18453为12c所使用。

具体算法可参考O5Logon.class中函数public final void generateOAuthResponse(......)中的代码,伪代码示例如下所示:

public final void generateOAuthResponse(……) {
    ……
            if (var1 == 18453) {
                ……
            } else {
                var2 = this.a(var1, var3, var4, var14, var2, var15);
            }

            ……
        }
    } else {
        throw new Exception("Resource A missing.");
    }
}

……

private byte[] a(int var1, String var2, String var3, boolean var4, byte[] var5, byte var6) {
    byte[] var7;
    int var9;
    if (var1 == 2361) {
        ……
    } else if (var1 != 6949 && var1 != 45394) {
        ……
    } else {
        ……
    }

    return var7;
}

完善用户名密码登录认证程序

由于代码比较长此处只展示main函数中的样例,有兴趣的读者可自行逆向分析阅读更多内容,笔者测试代码以及结果如下:

public static void main(String[] args) throws Exception {
		int verifierType = 18453;
		String user = "sys1111";
		String password = "tiger";
		String authPassword = "ECD6A199458BEBDE31A4C2814CC05E6BE0054757D038E97231D14BFF7784D044";
		String encryptedSK = "307939F649C90D864E54427F9FB8F49EEB8521EC32C66B19EA63531533C32453";
		String encryptedKB = "38037DE9995BE04119130D72398517471DB557A6834C588E05C5336333390960";
		byte[] salt = "475DD077C5BEF46CC44E1C9961EAD417".getBytes();
		byte[] AUTH_PBKDF2_CSK_SALT = "C33538DE162A29738CE7D5B663E14078".getBytes();
		byte[] auth_pbkdf2_speedy_key = "F395C5279960450C939ADD704E5AE4AFFC90A551FD21C7E3550074B44C33BD423272D006536A3B712AD9BB5674DDB875A44AD8D44E9527C6DB3AE84F9739EA999E92B6BAF7E47BF8F3353A4D1005726B".getBytes();
		String AUTH_SVR_RESPONSE = "7F60E6F9CA05504FD53650E40A6C3C42DE6B77E2C9740D38EA33CE04A01F7E75AE209C4165ABCF87DAF69C38AC79B1E6";
		int vgenCount = 4096;
		int sder_count = 3;
		byte serverCompileTimeCapabilities = 0;
		boolean isServerCSMultiByte = false;
		BugByCodeO5Logon logon = new BugByCodeO5Logon(true);
		byte[] encryptedSK_tmp = logon.getEncryptedSK(verifierType, salt, user, authPassword, vgenCount, serverCompileTimeCapabilities, isServerCSMultiByte);
		encryptedSK = new String(encryptedSK_tmp);
		
		System.out.println("loginName:" + user);
		System.out.println("password:" + password);
		System.out.println("=====================================");
		
		System.out.println("verifierType:" + verifierType);
		System.out.println("AUTH_VFR_DATA:" + new String(salt));
		System.out.println("AUTH_PBKDF2_CSK_SALT:" + new String(AUTH_PBKDF2_CSK_SALT));
		System.out.println("AUTH_PBKDF2_VGEN_COUNT:" + vgenCount);
		System.out.println("AUTH_PBKDF2_SDER_COUNT:" + sder_count);
		System.out.println("AUTH_SESSKEY:" + encryptedSK);
		
		System.out.println("=====================================");
		
		int[] var41 = new int[1];
		byte[] var27 = new byte[256];
		
		int[] var34 = new int[1];
		byte[] var23 = new byte[256];
		int[] var24 = new int[1];
		byte[] var25 = new byte[256];
		
		byte[] encryptedKB_tmp = new byte[encryptedSK_tmp.length];
		logon.generateOAuthResponse(verifierType, salt, user, password, password, 
				password.getBytes(), password.getBytes(), encryptedSK.getBytes(), encryptedKB_tmp,
				var23, var27, var34, 
				var41, isServerCSMultiByte, serverCompileTimeCapabilities, 
				AUTH_PBKDF2_CSK_SALT, vgenCount, sder_count, var25, var24);
		encryptedKB = new String(encryptedKB_tmp);
		
		System.out.println("AUTH_SESSKEY:" + encryptedKB);
		
		auth_pbkdf2_speedy_key = new byte[var24[0]];
		System.arraycopy(var25, 0, auth_pbkdf2_speedy_key, 0, auth_pbkdf2_speedy_key.length);
		
		byte[] auth_password = new byte[var27[0]];
		System.arraycopy(var23, 0, auth_password, 0, auth_password.length);
		
		authPassword = new String(auth_password);
		
		System.out.println("AUTH_PASSWORD:" + authPassword);
		System.out.println("AUTH_PBKDF2_SPEEDY_KEY:" + new String(auth_pbkdf2_speedy_key));
		
		boolean check = logon.auth(user, password, authPassword, encryptedSK, encryptedKB, 
				verifierType, salt, auth_pbkdf2_speedy_key, AUTH_PBKDF2_CSK_SALT, 
				vgenCount, sder_count, serverCompileTimeCapabilities, isServerCSMultiByte);
		byte[] tmp = logon.getEncryptedResponse(user, password, authPassword, encryptedSK,
				encryptedKB, verifierType, salt, AUTH_PBKDF2_CSK_SALT, 
				vgenCount, sder_count, serverCompileTimeCapabilities,isServerCSMultiByte);
		AUTH_SVR_RESPONSE = new String(tmp);
		System.out.println("auth:" + check);
		System.out.println("AUTH_SVR_RESPONSE:" + AUTH_SVR_RESPONSE);
		boolean validate = logon.validateServerIdentity(AUTH_SVR_RESPONSE);
		System.out.println("validate:" + validate);
	}

执行结果:

loginName:sys1111
password:tiger
=====================================
verifierType:18453
AUTH_VFR_DATA:475DD077C5BEF46CC44E1C9961EAD417
AUTH_PBKDF2_CSK_SALT:C33538DE162A29738CE7D5B663E14078
AUTH_PBKDF2_VGEN_COUNT:4096
AUTH_PBKDF2_SDER_COUNT:3
AUTH_SESSKEY:FC5EE3AE50760C84EBDDD2A1A98E97AAA5F0383CCEB4EAC734E05FF985D6420B
=====================================
AUTH_SESSKEY:BB4E34F91E5147055874B6EE3A96DF222ACFB6D684EA496A8639F425F0DC6657
AUTH_PASSWORD:7E7A73E7DC0E31CB37B25C712071277EBF5168EFBE80E9CD01790
AUTH_PBKDF2_SPEEDY_KEY:AD8A9A358C02BCF5EA690A71E137C75067D2847A2A17EABA45E13F66F59D8D57A957EE0490681E6D56EB09EE414E5B75C277168ADCE9876ED3B1B6EE2BF1A317DFF7EC8FE7ACA213C372CC094AC42C5D
auth:true
AUTH_SVR_RESPONSE:D58A5FC6B859AD185EBBF7138600276C91BA8C3A259CE1054D4AA67317E7CC09364E9EF36BD6211F10F9273F3821C4C7
validate:true

替换通信中的用户名和密码实现安全加固 

服务器基本信息

以下使用测试oracle服务器版本11g为例:

1、服务器地址:192.168.150.130

2、用户名和密码分别为scott和j1d1sec.f0rt

3、安全加固所使用的用户名和密码分别为admin和admin123

安全加固服务基本信息

编程语言:Java

服务器框架:Netty 4.1.25

端口:1521

登录及转发指令格式:sqlplus admin/admin123@localhost:1521/scott@192.168.150.130@1521@orcl

安全加固关键部分代码如下:

    @Override
	protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
		int len = msg.readableBytes();
		byte[] buff = new byte[len];
		msg.readBytes(buff);
		if(!isOracle12c) {
			isOracle12c = TransferUtil.isOracle12c(buff);
		}
		logger.debug("0	" + new String(buff));
		logger.debug("0	" + StringUtil.byteToHexString(buff));
		if(TransferUtil.isConn(buff,isOracle12c)) {
			OracleServer server = oracleHostFormatService.format(buff);
			byte[] body_buff = server.getBody().getBytes();
			int index = TransferUtil.findKey(AppConfig.CONNECTION_KEY, buff);
			int conn_buff_len = index + body_buff.length;
			byte[] conn_buff = new byte[conn_buff_len];
			System.arraycopy(buff, 0, conn_buff, 0, index);
			System.arraycopy(body_buff, 0, conn_buff, index, body_buff.length);
			
			conn_buff[0x19] = (byte)((conn_buff[0x19] & 0xFF) + (conn_buff_len - buff.length));
			TransferUtil.toHH(conn_buff_len, conn_buff,isOracle12c);
			
			if(this.server == null) {
				this.server = server;
				this.client = new NettyClient(this.server.getOracleHost(), this.server.getOraclePort());
				this.client.setOracleServerHandler(this);
				this.client.connection();
				this.client.waitConnect();
				if(this.client.isClosed()) {
					throw new RuntimeException("Connection error.");
				}
				this.client.writeAndFlush(conn_buff);
			}else if(this.server.toString().equals(server.toString())){
				this.client.writeAndFlush(conn_buff);
			}else {
				ctx.close();
			}
		}else if(TransferUtil.isAuthRecv(buff,isOracle12c)){
			//将目标登录账号替换为目标设备账号
			loginName = TransferUtil.getUserName(AppConfig.AUTH_TERMINAL,buff);
			this.loginPassword = "admin123";
			logger.debug("Login user : " + loginName);
			byte[] login_name_buff = loginName.getBytes();
			byte[] account_buff = server.getOracleAccount().getBytes();
			byte[] tmp = TransferUtil.replaceAuthLoginName(login_name_buff, 
					account_buff, buff,isOracle12c);
			logger.debug("8	" + new String(tmp));
			logger.debug("8	" + StringUtil.byteToHexString(tmp));
			buff = tmp;
			
			this.client.writeAndFlush(buff);
		}else if(TransferUtil.isLogin(buff,isOracle12c)){
			//将目标登录账号替换为目标设备账号
			loginName = TransferUtil.getUserName(AppConfig.AUTH_SESSKEY,buff);
			AUTH_SESSKEY = TransferUtil.findKeyText(AppConfig.AUTH_SESSKEY.getBytes(), buff);
			AUTH_PASSWORD = TransferUtil.findKeyText(AppConfig.AUTH_PASSWORD.getBytes(), buff);
			AUTH_PBKDF2_SPEEDY_KEY = TransferUtil.findKeyText(AppConfig.AUTH_PBKDF2_SPEEDY_KEY.getBytes(), buff);
			logger.debug("AUTH_SESSKEY:" + new String(AUTH_SESSKEY));
			logger.debug("AUTH_PASSWORD:" + new String(AUTH_PASSWORD));
			boolean useDes = false;
			if(verifierType == 18453) {
				useDes = true;
			}
			BugByCodeO5Logon logon = new BugByCodeO5Logon(useDes);
			this.isAuth = logon.auth(loginName, loginPassword, new String(AUTH_PASSWORD), new String(custom_encryptedSK), 
					new String(AUTH_SESSKEY), verifierType, AUTH_VFR_DATA, AUTH_PBKDF2_SPEEDY_KEY, 
					AUTH_PBKDF2_CSK_SALT, AUTH_PBKDF2_VGEN_COUNT, AUTH_PBKDF2_SDER_COUNT, (byte)0, false);
			logger.debug("Login auth : " + this.isAuth);
			if(!this.isAuth) {
				throw new RuntimeException("Username or password error.");
			}
			
			this.AUTH_SVR_RESPONSE = logon.getEncryptedResponse(loginName, loginPassword, 
					new String(AUTH_PASSWORD), new String(custom_encryptedSK), new String(AUTH_SESSKEY), verifierType, 
					AUTH_VFR_DATA, AUTH_PBKDF2_CSK_SALT, AUTH_PBKDF2_VGEN_COUNT, AUTH_PBKDF2_SDER_COUNT, (byte)0,false);
			//10G 11G
			/*byte[] var8 = new byte[256];
			int[] var9 = new int[1];
			byte[] var15 = new byte[256];
			int[] var16 = new int[1];
			*/
			//10g 11g 12C
			int[] var41 = new int[1];
			byte[] var27 = new byte[256];
			
			int[] var34 = new int[1];
			byte[] var23 = new byte[256];
			int[] var24 = new int[1];
			byte[] var25 = new byte[256];
			
			byte[] encryptedKB = new byte[encryptedSK.length];
			
			String oraclePassword = "j1d1sec.f0rt";
			/*
			logon.generateOAuthResponse(verifierType, AUTH_VFR_DATA, server.getOracleAccount(), oraclePassword, 
					oraclePassword.getBytes(), encryptedSK, encryptedKB, var8, var9, 
					false, (byte)0, AUTH_PBKDF2_CSK_SALT, 
					AUTH_PBKDF2_VGEN_COUNT, AUTH_PBKDF2_SDER_COUNT, var15, var16);
			
			byte[] tmp = new byte[var9[0]];
			System.arraycopy(var8, 0, tmp, 0, tmp.length);
			var8 = tmp;
			*/
			logon.generateOAuthResponse(verifierType, AUTH_VFR_DATA, server.getOracleAccount(), oraclePassword, oraclePassword, 
					oraclePassword.getBytes(), oraclePassword.getBytes(), encryptedSK, encryptedKB,
					var23, var27, var34, 
					var41, false, (byte)0, 
					AUTH_PBKDF2_CSK_SALT, AUTH_PBKDF2_VGEN_COUNT, AUTH_PBKDF2_SDER_COUNT, var25, var24);
			
			byte[] tmp = new byte[var34[0]];
			System.arraycopy(var23, 0, tmp, 0, tmp.length);
			byte[] var8 = tmp;
			
			tmp = new byte[var41[0]];
			System.arraycopy(var27, 0, tmp, 0, tmp.length);
			byte[] new_pwd = tmp;
			
			byte[] auth_pbkdf2_speedy_key = new byte[var24[0]];
			System.arraycopy(var25, 0, auth_pbkdf2_speedy_key, 0, auth_pbkdf2_speedy_key.length);
			
			logger.debug("NEW_AUTH_PASSWOR : " + new String(var8));
			logger.debug("NEW_AUTH_NEWPASSWOR : " + new String(new_pwd));
			logger.debug("NEW_AUTH_SESSKEY : " + new String(encryptedKB));
			logger.debug("NEW_AUTH_PBKDF2_SPEEDY_KEY : " + new String(auth_pbkdf2_speedy_key));
			
			int index = TransferUtil.findKey(AUTH_SESSKEY, buff);
			System.arraycopy(encryptedKB, 0, buff, index, encryptedKB.length);
			
			index = TransferUtil.findKey(AUTH_PASSWORD, buff);
			System.arraycopy(var8, 0, buff, index, var8.length);
			
			if(AUTH_PBKDF2_SPEEDY_KEY.length > 0) {
				index = TransferUtil.findKey(AUTH_PBKDF2_SPEEDY_KEY, buff);
				System.arraycopy(auth_pbkdf2_speedy_key, 0, buff, index, auth_pbkdf2_speedy_key.length);
			}
			
			logger.debug("Login user : " + loginName);
			byte[] login_name_buff = loginName.getBytes();
			byte[] account_buff = server.getOracleAccount().getBytes();
			tmp = TransferUtil.replaceAuthLoginName(login_name_buff, account_buff, buff,isOracle12c);
			buff = tmp;
			
			this.client.writeAndFlush(buff);
		}else {
			this.client.writeAndFlush(buff);
		}
	}
    private class WorkThread extends Thread{

		@Override
		public void run() {
			while(!isClosed) {
				try {
					byte[] data = read();
					logger.debug("1	" + new String(data));
					logger.debug("1	" + StringUtil.byteToHexString(data));
					if(TransferUtil.contailsEncryptedSK(data,isOracle12c)) {
						AUTH_VFR_DATA = TransferUtil.findSalt(data);
						encryptedSK = TransferUtil.findKeyText(AppConfig.AUTH_SESSKEY.getBytes(), data);
						verifierType = TransferUtil.getVerifierType(data);
						int offset = TransferUtil.findKey(AppConfig.AUTH_PBKDF2_CSK_SALT.getBytes(), data);
						if(offset != -1) {
							AUTH_PBKDF2_CSK_SALT = TransferUtil.findKeyText(AppConfig.AUTH_PBKDF2_CSK_SALT.getBytes(), data);
						}
						offset = TransferUtil.findKey(AppConfig.AUTH_PBKDF2_VGEN_COUNT.getBytes(), data);
						if(offset != -1) {
							byte[] vgenCountByte = TransferUtil.findKeyText(AppConfig.AUTH_PBKDF2_VGEN_COUNT.getBytes(), data);
							AUTH_PBKDF2_VGEN_COUNT = Integer.valueOf(new String(vgenCountByte));
						}
						offset = TransferUtil.findKey(AppConfig.AUTH_PBKDF2_SDER_COUNT.getBytes(), data);
						if(offset != -1) {
							byte[] sderCountByte = TransferUtil.findKeyText(AppConfig.AUTH_PBKDF2_SDER_COUNT.getBytes(), data);
							AUTH_PBKDF2_SDER_COUNT = Integer.valueOf(new String(sderCountByte));
						}
						logger.debug("AUTH_VFR_DATA:" + new String(AUTH_VFR_DATA));
						logger.debug("encryptedSK:" + new String(encryptedSK));
						logger.debug("verifierType:" + verifierType);
						if(AUTH_PBKDF2_CSK_SALT != null) {
							logger.debug("AUTH_PBKDF2_CSK_SALT:" + new String(AUTH_PBKDF2_CSK_SALT));
						}
						logger.debug("AUTH_PBKDF2_VGEN_COUNT:" + AUTH_PBKDF2_VGEN_COUNT);
						logger.debug("AUTH_PBKDF2_SDER_COUNT:" + AUTH_PBKDF2_SDER_COUNT);
						boolean useDes = false;
						if(verifierType == 18453) {
							useDes = true;
						}
						BugByCodeO5Logon logon = new BugByCodeO5Logon(useDes);
						try {
							custom_encryptedSK = logon.getEncryptedSK(verifierType, AUTH_VFR_DATA, loginName, loginPassword, AUTH_PBKDF2_VGEN_COUNT, (byte)0, false);
							if(custom_encryptedSK.length != encryptedSK.length) {
								throw new InterruptedException("EncryptedSK error.");
							}
							int index = TransferUtil.findKey(encryptedSK, data);
							System.arraycopy(custom_encryptedSK, 0, data, index, custom_encryptedSK.length);
						} catch (Exception e) {
							e.printStackTrace();
							throw new InterruptedException(e.getMessage());
						}
					}else if(TransferUtil.contailsResponse(data,isOracle12c)) {
						byte[] old_response = TransferUtil.findKeyText(AppConfig.AUTH_SVR_RESPONSE.getBytes(), data);
						logger.debug("old_response : " + new String(old_response));
						int index = TransferUtil.findKey(old_response, data);
						System.arraycopy(AUTH_SVR_RESPONSE, 0, data, index, AUTH_SVR_RESPONSE.length);
					}
					
					ByteBuf buf = oracleServerChannel.alloc().buffer(data.length);
					buf.writeBytes(data);
					writeAndFlush(buf);
				} catch (InterruptedException e) {
					logger.error(e.getLocalizedMessage());
				}
			}
		}
		
	}

加固后效果

使用sqlplus命令行登录如下所示:

b329966812371b0d7e515bfb96ee8252363.jpg

使用 PLSQL Developer 12 (64 bit)工具登录效果如下所示:

6332661fb8069448a0e4195370c3d3fe47c.jpg

6601d1bcdb7becac7075df5b36c05349e38.jpg

转载于:https://my.oschina.net/zhangzhigong/blog/3014435

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值