最近公司有個項目被客戶拿去進行漏洞掃描,發現用戶登錄太簡單,容易被暴力破解。當然發現的問題很多,什么反射型XSS,存儲型XSS,敏感信息泄露等等。但是我們今天不講這么多,就說說如何修復暴力破解的問題。由於登錄界面未設置圖形驗證碼,也沒有對同一用戶在連續登錄錯誤時進行限制,導致攻擊者可以嘗試破解任意已知用戶的密碼。所以針對這種情況,第一,對同一用戶連續登錄在規定時間內的次數進行限制,超過則鎖定用戶。第二,添加圖形驗證碼。
首先給你們看看我的項目結構:
user.sql數據:
SET FOREIGN_KEY_CHECKS=0; -- ---------------------------- -- Table structure for user -- ---------------------------- DROP TABLE IF EXISTS `user`; CREATE TABLE user ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用戶名', `password` varchar(32) NOT NULL COMMENT '密碼,加密存儲', `islocked` varchar(10) DEFAULT "0" COMMENT '是否鎖定', `firsttime` varchar(50) DEFAULT NULL COMMENT '第一次登錄錯誤時間', `count` varchar(10) DEFAULT NULL COMMENT '登錄錯誤次數', PRIMARY KEY (`id`), UNIQUE KEY `username` (`username`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='用戶表'; -- ---------------------------- -- Records of user -- ---------------------------- INSERT INTO user VALUES (null, 'admin', 'e10adc3949ba59abbe56e057f20f883e', '0', null, null); INSERT INTO user VALUES (null, 'nanshan', 'e10adc3949ba59abbe56e057f20f883e', '0', null, null);
新建數據庫test,導入user.sql即可。
web層:
VerifyCodeServlet.java
package cn.itcast.com.servlet; import java.io.IOException; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import cn.itcast.com.util.VerifyCodeUtils; /** * * @author saule * @date 2019年6月28日 下午11:39:19 * @Description 驗證碼servlet */ public class VerifyCodeServlet extends HttpServlet { private static final long serialVersionUID = 1L; public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); } public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int width = 100; int height = 35; //獲取驗證碼 String verifyCode = VerifyCodeUtils.generateVerifyCode(4); //將驗證碼保存到session request.getSession().setAttribute("verifyCode", verifyCode); //向頁面輸出驗證碼 VerifyCodeUtils.outputImage(width, height, response.getOutputStream(), verifyCode); } }
LoginServlet.java
package cn.itcast.com.servlet; import java.io.IOException; import java.util.HashMap; import java.util.Map; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import net.sf.json.JSONArray; import cn.itcast.com.pojo.User; import cn.itcast.com.service.UserService; /** * 正常用戶登錄流程: * 1,判斷驗證碼是否正確。 * 2,判斷用戶是否鎖定。 * 3,根據用戶名稱查找,判斷用戶是否存在。 * 4,判斷密碼是否正確。 */ public class LoginServlet extends HttpServlet { private static final long serialVersionUID = 1L; public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("text/xml;charset=utf-8"); String username=request.getParameter("username"); String password=request.getParameter("password"); String vercode=request.getParameter("vercode"); String sessionCode=(String) request.getSession().getAttribute("verifyCode"); //清除session中的驗證碼 request.getSession().removeAttribute("verifyCode"); Map<String,String> map=new HashMap<>(); //驗證碼錯誤 if(sessionCode!=null && !sessionCode.equals(vercode.toUpperCase())){ map.put("errcode", "0"); // 轉換成json String json = JSONArray.fromObject(map).toString(); response.getWriter().write(json); response.getWriter().close(); return; } System.out.println(username+":"+password); UserService userService=new UserService(); User loginUser=new User(); loginUser.setUsername(username); loginUser.setPassword(password); Map<String, Object> result = userService.login(loginUser); if(result.get("errcode").equals("4")){//驗證成功 request.getSession().setAttribute("username", username); request.getSession().setAttribute("password", password);
//用ajax請求用戶登錄,重定向和轉發都是失效的。 //request.getRequestDispatcher("/WEB-INF/success.jsp").forward(request, response); } // 轉換成json String json = JSONArray.fromObject(result).toString(); response.getWriter().write(json); response.getWriter().close(); } public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doGet(request, response); } }
Service層:
UserService.java
package cn.itcast.com.service; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.Map; import cn.itcast.com.dao.UserDao; import cn.itcast.com.pojo.User; import cn.itcast.com.util.Md5Utils; /** * * @author saule * @date 2019年6月27日 上午10:50:44 * @Description */ public class UserService { private static final String USER_NOT_EXIST="1"; private static final String USER_IS_LOCK="2"; private static final String PASSWORD_ERROR="3"; private static final String VERIFICATION_SUCCESS="4"; SimpleDateFormat sdf=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); //登錄操作 public Map<String,Object> login(User loginUser) { Map<String,Object> loginMap=new HashMap<String, Object>(); UserDao userDao=new UserDao(); User user=userDao.findByName(loginUser.getUsername()); String pwd = Md5Utils.md5(loginUser.getPassword()); if(user==null){//用戶不存在 loginMap.put("errcode", USER_NOT_EXIST); }else if(user.getIslocked().equals("1")){//用戶被鎖定 loginMap.put("errcode", USER_IS_LOCK); }else if(!user.getPassword().equals(pwd)){//密碼錯誤 //超過規定時間,次數不超過4次登錄時,將時間與次數重置。 if(user.getFirsttime()!=null){ try { long firsttime=sdf.parse(user.getFirsttime()).getTime(); long nowtime=new Date().getTime(); if((nowtime-firsttime)>5*60*1000){ user.setFirsttime(null); user.setCount("0"); userDao.update(user); } } catch (ParseException e) { e.printStackTrace(); } } if(user.getFirsttime()==null){//初次登錄密碼錯誤,記錄數據。 user.setFirsttime(sdf.format(new Date())); user.setCount("1"); userDao.update(user); }else if(user.getFirsttime()!=null && Integer.valueOf(user.getCount())<3){//連續登陸錯誤次數小於4次 String newCount = String.valueOf(Integer.valueOf(user.getCount())+1); user.setCount(newCount); userDao.update(user); }else{//大於4次且時間間隔小於5分鍾則鎖定用戶。禁止登錄。 try { long firsttime=sdf.parse(user.getFirsttime()).getTime(); long nowtime=new Date().getTime(); if((nowtime-firsttime)<5*60*1000){ user.setFirsttime(null); user.setCount("0"); user.setIslocked("1");//1表示鎖定用戶 userDao.update(user); } } catch (ParseException e) { e.printStackTrace(); } } loginMap.put("errcode", PASSWORD_ERROR); loginMap.put("user", user); }else{ loginMap.put("errcode", VERIFICATION_SUCCESS); } return loginMap; } }
Dao層:
UserDao.java
package cn.itcast.com.dao; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import cn.itcast.com.pojo.User; import cn.itcast.com.util.JdbcUtils; public class UserDao { //根據用戶查詢 public User findByName(String username) { Connection conn=null; PreparedStatement pst=null; ResultSet rs=null; String sql="select * from user where username=?"; try { conn=JdbcUtils.getConnection(); pst=conn.prepareStatement(sql); pst.setString(1, username); rs=pst.executeQuery(); if(rs.next()){ User user=new User(); user.setId(rs.getInt("id")); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); user.setIslocked(rs.getString("islocked")); user.setFirsttime(rs.getString("firsttime")); user.setCount(rs.getString("count")); return user; } } catch (SQLException e) { e.printStackTrace(); }finally{ try { JdbcUtils.close(conn, pst, rs); } catch (SQLException e) { e.printStackTrace(); } } return null; } //修改用戶信息 public void update(User user) { Connection conn=null; PreparedStatement pst=null; String sql="update user set islocked=?,firsttime=?,count=? where id=?"; try { conn=JdbcUtils.getConnection(); pst = conn.prepareStatement(sql); pst.setString(1, user.getIslocked()); pst.setString(2, user.getFirsttime()); pst.setString(3, user.getCount()); pst.setInt(4, user.getId()); int row = pst.executeUpdate(); if(row!=0){ System.out.println("修改成功"); } } catch (SQLException e) { e.printStackTrace(); }finally{ try { JdbcUtils.close(conn, pst, null); } catch (SQLException e) { e.printStackTrace(); } } } }
實體類:
user.java
package cn.itcast.com.pojo; public class User { private Integer id; private String username; private String password; private String islocked;//是否鎖定,0表示沒有鎖定;1表示鎖定; private String firsttime; private String count;//連續登錄錯誤次數 public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getIslocked() { return islocked; } public void setIslocked(String islocked) { this.islocked = islocked; } public String getFirsttime() { return firsttime; } public void setFirsttime(String firsttime) { this.firsttime = firsttime; } public String getCount() { return count; } public void setCount(String count) { this.count = count; } }
幫助類:
VerifyCodeUtils.java
package cn.itcast.com.util; import javax.imageio.ImageIO; import java.awt.*; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.Arrays; import java.util.Random; /** * 驗證碼生成工具 * @author zl */ public class VerifyCodeUtils{ //使用到Algerian字體,系統里沒有的話需要安裝字體,字體只顯示大寫,去掉了1,0,i,o幾個容易混淆的字符 public static final String VERIFY_CODES = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; private static Random random = new Random(); /** * 使用系統默認字符源生成驗證碼 * @param verifySize 驗證碼長度 * @return */ public static String generateVerifyCode(int verifySize){ return generateVerifyCode(verifySize, VERIFY_CODES); } /** * 使用指定源生成驗證碼 * @param verifySize 驗證碼長度 * @param sources 驗證碼字符源 * @return */ public static String generateVerifyCode(int verifySize, String sources){ if(sources == null || sources.length() == 0){ sources = VERIFY_CODES; } int codesLen = sources.length(); Random rand = new Random(System.currentTimeMillis()); StringBuilder verifyCode = new StringBuilder(verifySize); for(int i = 0; i < verifySize; i++){ verifyCode.append(sources.charAt(rand.nextInt(codesLen-1))); } return verifyCode.toString(); } /** * 生成隨機驗證碼文件,並返回驗證碼值 * @param w * @param h * @param outputFile * @param verifySize * @return * @throws IOException */ public static String outputVerifyImage(int w, int h, File outputFile, int verifySize) throws IOException{ String verifyCode = generateVerifyCode(verifySize); outputImage(w, h, outputFile, verifyCode); return verifyCode; } /** * 輸出隨機驗證碼圖片流,並返回驗證碼值 * @param w * @param h * @param os * @param verifySize * @return * @throws IOException */ public static String outputVerifyImage(int w, int h, OutputStream os, int verifySize) throws IOException{ String verifyCode = generateVerifyCode(verifySize); outputImage(w, h, os, verifyCode); return verifyCode; } /** * 生成指定驗證碼圖像文件 * @param w * @param h * @param outputFile * @param code * @throws IOException */ public static void outputImage(int w, int h, File outputFile, String code) throws IOException{ if(outputFile == null){ return; } File dir = outputFile.getParentFile(); if(!dir.exists()){ dir.mkdirs(); } try{ outputFile.createNewFile(); FileOutputStream fos = new FileOutputStream(outputFile); outputImage(w, h, fos, code); fos.close(); } catch(IOException e){ throw e; } } /** * 輸出指定驗證碼圖片流 * @param w * @param h * @param os * @param code * @throws IOException */ public static void outputImage(int w, int h, OutputStream os, String code) throws IOException{ int verifySize = code.length(); BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); Random rand = new Random(); Graphics2D g2 = image.createGraphics(); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON); Color[] colors = new Color[5]; Color[] colorSpaces = new Color[] { Color.WHITE, Color.CYAN, Color.GRAY, Color.LIGHT_GRAY, Color.MAGENTA, Color.ORANGE, Color.PINK, Color.YELLOW }; float[] fractions = new float[colors.length]; for(int i = 0; i < colors.length; i++){ colors[i] = colorSpaces[rand.nextInt(colorSpaces.length)]; fractions[i] = rand.nextFloat(); } Arrays.sort(fractions); g2.setColor(Color.GRAY);// 設置邊框色 g2.fillRect(0, 0, w, h); Color c = getRandColor(200, 250); g2.setColor(c);// 設置背景色 g2.fillRect(0, 2, w, h-4); //繪制干擾線 Random random = new Random(); g2.setColor(getRandColor(160, 200));// 設置線條的顏色 for (int i = 0; i < 20; i++) { int x = random.nextInt(w - 1); int y = random.nextInt(h - 1); int xl = random.nextInt(6) + 1; int yl = random.nextInt(12) + 1; g2.drawLine(x, y, x + xl + 40, y + yl + 20); } // 添加噪點 float yawpRate = 0.05f;// 噪聲率 int area = (int) (yawpRate * w * h); for (int i = 0; i < area; i++) { int x = random.nextInt(w); int y = random.nextInt(h); int rgb = getRandomIntColor(); image.setRGB(x, y, rgb); } shear(g2, w, h, c);// 使圖片扭曲 g2.setColor(getRandColor(100, 160)); int fontSize = h-4; Font font = new Font("Algerian", Font.ITALIC, fontSize); g2.setFont(font); char[] chars = code.toCharArray(); for(int i = 0; i < verifySize; i++){ AffineTransform affine = new AffineTransform(); affine.setToRotation(Math.PI / 4 * rand.nextDouble() * (rand.nextBoolean() ? 1 : -1), (w / verifySize) * i + fontSize/2, h/2); g2.setTransform(affine); g2.drawChars(chars, i, 1, ((w-10) / verifySize) * i + 5, h/2 + fontSize/2 - 10); } g2.dispose(); ImageIO.write(image, "jpg", os); } private static Color getRandColor(int fc, int bc) { if (fc > 255) fc = 255; if (bc > 255) bc = 255; int r = fc + random.nextInt(bc - fc); int g = fc + random.nextInt(bc - fc); int b = fc + random.nextInt(bc - fc); return new Color(r, g, b); } private static int getRandomIntColor() { int[] rgb = getRandomRgb(); int color = 0; for (int c : rgb) { color = color << 8; color = color | c; } return color; } private static int[] getRandomRgb() { int[] rgb = new int[3]; for (int i = 0; i < 3; i++) { rgb[i] = random.nextInt(255); } return rgb; } private static void shear(Graphics g, int w1, int h1, Color color) { shearX(g, w1, h1, color); shearY(g, w1, h1, color); } private static void shearX(Graphics g, int w1, int h1, Color color) { int period = random.nextInt(2); boolean borderGap = true; int frames = 1; int phase = random.nextInt(2); for (int i = 0; i < h1; i++) { double d = (double) (period >> 1) * Math.sin((double) i / (double) period + (6.2831853071795862D * (double) phase) / (double) frames); g.copyArea(0, i, w1, 1, (int) d, 0); if (borderGap) { g.setColor(color); g.drawLine((int) d, i, 0, i); g.drawLine((int) d + w1, i, w1, i); } } } private static void shearY(Graphics g, int w1, int h1, Color color) { int period = random.nextInt(40) + 10; // 50; boolean borderGap = true; int frames = 20; int phase = 7; for (int i = 0; i < w1; i++) { double d = (double) (period >> 1) * Math.sin((double) i / (double) period + (6.2831853071795862D * (double) phase) / (double) frames); g.copyArea(i, 0, 1, h1, 0, (int) d); if (borderGap) { g.setColor(color); g.drawLine(i, (int) d, i, 0); g.drawLine(i, (int) d + h1, i, h1); } } } public static void main(String[] args) throws IOException{ File dir = new File("F:/verifies"); int w = 200, h = 80; String verifyCode = generateVerifyCode(4); File file = new File(dir, verifyCode + ".jpg"); outputImage(w, h, file, verifyCode); } }
Md5Utils.java
package cn.itcast.com.util; import java.math.BigInteger; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; public class Md5Utils { /** * 使用md5的算法進行加密 */ public static String md5(String plainText) { byte[] secretBytes = null; try { secretBytes = MessageDigest.getInstance("md5").digest( plainText.getBytes()); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("沒有md5這個算法!"); } String md5code = new BigInteger(1, secretBytes).toString(16);// 16進制數字 // 如果生成數字未滿32位,需要前面補0 for (int i = 0; i < 32 - md5code.length(); i++) { md5code = "0" + md5code; } return md5code; } }
JdbcUtils.java
package cn.itcast.com.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ResourceBundle; /** * @author saule * @date 2019年6月27日 上午10:23:58 * @Description JDBC幫助類 */ public class JdbcUtils { private static String driver="com.mysql.jdbc.Driver"; private static String url="jdbc:mysql://localhost:3306/test?characterEncoding=utf-8"; private static String username="root"; private static String password="root"; static { try { // 將加載驅動操作,放置在靜態代碼塊中.這樣就保證了只加載一次. Class.forName(driver); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { //獲取連接 Connection con = DriverManager.getConnection(url, username, password); return con; } //關閉操作 public static void close(Connection con,Statement st,ResultSet rs) throws SQLException{ if(con!=null){ con.close(); }else if(st!=null){ st.close(); }else if(rs!=null){ rs.close(); } } }
web.xml
<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" id="WebApp_ID" version="3.0"> <display-name>youdu</display-name> <servlet> <servlet-name>VerifyCodeServlet</servlet-name> <servlet-class>cn.itcast.com.servlet.VerifyCodeServlet</servlet-class> </servlet> <servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>cn.itcast.com.servlet.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>VerifyCodeServlet</servlet-name> <url-pattern>/getVerifyCode</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> </web-app>
前端頁面:
login.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>登錄頁面</title> <style type="text/css"> #login{ border: 1px solid deepskyblue; background-color: blanchedalmond; width:400px; height:250px; padding-left: 100px; padding-top: 50px; } </style> <script type="text/javascript"> //創建一個ajax對象 function getLoginAjax(){ var loginRequest; if(window.XMLHttpRequest){ loginRequest=new XMLHttpRequest(); }else if(window.ActiveXObject){//window對象中有ActiveXObject屬性存在就是IE瀏覽器的低版本 try{ loginRequest= new ActiveXObject("Msxml2.XMLHTTP"); }catch(e){ loginRequest= new ActiveXObject("Microsoft.XMLHTTP"); } } return loginRequest; } //登錄方法 function userlogin(){ //獲取參數 var username=document.getElementById("username").value; var password=document.getElementById("password").value; var vercode=document.getElementById("vercode").value; /* 此處省略非空校驗 */ var loginAjax=getLoginAjax(); loginAjax.open("POST", "login", true); //請求頭 loginAjax.setRequestHeader("Content-Type","application/x-www-form-urlencoded"); loginAjax.send("username="+username+"&password="+password+"&vercode="+vercode); //設置回調函數 loginAjax.onreadystatechange = function() { //處理響應數據 當信息全部返回,並且是成功 if (loginAjax.readyState == 4 && loginAjax.status == 200) { //返回的數值類型一般是json格式,比較常見 console.log(); var jsonObj = eval("(" + loginAjax.responseText + ")"); for(var i=0;i<jsonObj.length;i++){ var errcode = jsonObj[i].errcode; if(errcode==0){ alert("驗證碼錯誤"); identifyload(); return false; }else if(errcode==1){ alert("用戶不存在"); identifyload(); return false; }else if(errcode==2){ alert("用戶被鎖定,請聯系管理。"); return false; }else if(errcode==3){ var user = jsonObj[i].user; var locked=user.islocked; var count=user.count; if(locked=='0' && count<4){ alert("密碼錯誤,你還有"+(4-count)+"次機會"); identifyload(); }else if(locked=='1'){ alert("用戶被鎖定,請聯系管理。"); } return false; }else if(errcode==4){ alert("登錄成功"); window.location="http://localhost:8080/youdu/success.jsp"; } } } }; } function identifyload() { document.getElementById("imgcode").src = 'getVerifyCode?nowtime='+ new Date().getTime(); } </script> </head> <body> <div id="login"> <form action="./login" method="Post" name="login"> <table> <tr> <td>用戶名:</td> <td> <input name="username" type="text" placeholder="請輸入昵稱" id="username"/><br/><br/> </td> </tr> <tr> <td>密碼:</td> <td> <input name="password" type="password" placeholder="請輸入密碼" id="password"/><br/><br/> </td> </tr> <tr> <td>驗證碼:</td> <td> <input name="vercode" type="text" id="vercode"/> <img src="./getVerifyCode" height="32" id="imgcode" onclick="identifyload()" title="點擊更換驗證碼"> </td> </tr> <tr> <td height="20"> </td> </tr> <tr> <td> <button type="button" value="登錄" onclick="userlogin()">登錄</button> <button type="reset" value="重置">重置</button> </td> </tr> </table> </form> </div> </body> </html>
success.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>Insert title here</title> </head> <body> <h3>登錄成功</h3> 用戶名:${sessionScope.username} 密碼:${sessionScope.password} </body> </html>
來個效果圖:
最后說幾句。雖然用戶登錄流程簡單,但是用servlet和原生js、ajax實現還是有些坑的,特別是現在個個都習慣在框架下寫代碼,就比如ajax請求后,servlet重定向和轉發都會失效,你得想辦法登錄成功后跳轉系統頁面。雖然都是比較基礎的東西,對於初學者來說還是需要好好掌握。