|Yoshixis Web|

IrisCTF 2025

Challenges

password-manager

Solved with Flight

Challenge Info

irisctf https://password-manager-web.chal.irisc.tf/

password-manager.tar.gz

Recon

Ok, so we can take a look at the source code

index.js has:

function showAlert(message) {
    const alertDiv = document.getElementById('alertMessage');
    alertDiv.innerHTML = '<p>' + message + '</p>';
    alertDiv.classList.remove('d-none');
}

document.getElementById("submit").onclick = async function() {
    fetch("./login", {
        method: "POST",
        body: JSON.stringify({
            usr: document.getElementById("username").value,
            pwd: document.getElementById("password").value,
        }),
    }).then(async (response) => {
        const body = await response.text()

        try {
            JSON.parse(body)
            window.location.href = "./passwords"
        } catch(_) {
            showAlert(body)
        }
    })
}

main.go has:

package main

import (
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
	"time"

	_ "github.com/go-sql-driver/mysql"
)

type CustomMux struct {
	handlers map[string]http.HandlerFunc
}
type Auth struct {
	User     string `json:"usr"`
	Password string `json:"pwd"`
}

var DB *sql.DB
var PathReplacer = strings.NewReplacer(
	"../", "",
)
var users map[string]string

func NewCustomMux() *CustomMux {
	return &CustomMux{handlers: make(map[string]http.HandlerFunc)}
}

func (mux *CustomMux) HandleFunc(pattern string, handler http.HandlerFunc) {
	mux.handlers[pattern] = handler
}

func (mux *CustomMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	rawPath := r.URL.RawPath
	if rawPath == "" {
		rawPath = r.URL.Path
	}

	if handler, exists := mux.handlers[rawPath]; exists {
		handler(w, r)
	} else {
		mux.handlers["/"](w, r)
	}
}

func main() {
	// Connect to MySQL
	db, err := sql.Open("mysql", "readonly_user:password@tcp(127.0.0.1:3306)/uwu")
	if err != nil {
		fmt.Printf("Error connecting to mysql: %v\n", err)
		return
	}
	DB = db

	db.SetConnMaxLifetime(time.Minute * 3)
	db.SetMaxOpenConns(10)
	db.SetMaxIdleConns(10)

	// Initialize users var
	file, err := os.Open("./users.json")
	if err != nil {
		fmt.Printf("Error reading users.json: %v\n", err)
		return
	}

	if err := json.NewDecoder(file).Decode(&users); err != nil {
		fmt.Printf("Error reading users.json: %v\n", err)
		return
	}

	// Create HTTP server
	mux := NewCustomMux()

	mux.HandleFunc("/", pages)

	fmt.Println("Server starting on :8000")
	err = http.ListenAndServe(":8000", rawMux(mux))
	if err != nil {
		fmt.Printf("Error starting server: %v\n", err)
	}
}

func rawMux(handler http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handler.ServeHTTP(w, r)
	})
}

func login(w http.ResponseWriter, r *http.Request) {
	var auth Auth

	if err := json.NewDecoder(r.Body).Decode(&auth); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("Invalid request!"))
		return
	}

	if !validateLogin(auth.User, auth.Password) {
		w.WriteHeader(http.StatusUnauthorized)
		w.Write([]byte("Invalid password!"))
		return
	}

	authJson, err := json.Marshal(auth)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		w.Write([]byte("Error occurred! (this should not happen, please open a ticket!)"))
		return
	}

	http.SetCookie(w, &http.Cookie{
		Name:  "auth",
		Value: base64.RawStdEncoding.EncodeToString(authJson),
	})
	w.Write([]byte("{}"))
}

func validateLogin(user, password string) bool {
	if realpassword, ok := users[user]; !ok || password != realpassword {
		fmt.Printf("%t | \"%s\"==\"%s\" %t", ok, password, realpassword, password == realpassword)
		return false
	}
	
	return true
}

func isLoggedIn(w http.ResponseWriter, r *http.Request) (bool, error) {
	var auth Auth
	authCookie, err := r.Cookie("auth")
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return false, err
	}

	data, err := base64.RawStdEncoding.DecodeString(authCookie.Value)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return false, err
	}

	json.Unmarshal(data, &auth)

	return validateLogin(auth.User, auth.Password), nil
}

func getpasswords(w http.ResponseWriter, r *http.Request) {
	loggedIn, err := isLoggedIn(w, r)

	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Println(err)
		return
	}

	if !loggedIn {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	res, err := DB.Exec("SELECT * FROM passwords")
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Println(err)
		return
	}

	err = json.NewEncoder(w).Encode(res)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Println(err)
		return
	}
}

func homepage(w http.ResponseWriter, r *http.Request) {
	http.ServeFile(w, r, "./pages/index.html")
}

func notfound(w http.ResponseWriter, _ *http.Request) {
	fmt.Fprintf(w, "Hey! No page found!")
}

func pages(w http.ResponseWriter, r *http.Request) {
	// You. Shall. Not. Path traverse!
	path := PathReplacer.Replace(r.URL.Path)

	if path == "/" {
		homepage(w, r)
		return
	}

	if path == "/login" {
		login(w, r)
		return
	}

	if path == "/getpasswords" {
		getpasswords(w, r)
		return
	}

	fullPath := "./pages" + path

	if _, err := os.Stat(fullPath); os.IsNotExist(err) {
		notfound(w, r)
		return
	}

	http.ServeFile(w, r, fullPath)
}
  • Connects to a local SQL database (I tried to connect, but it is limited to the server's IP to connect only)
  • Creates a users.json file that stores a json mapping usernames and passwords
  • Sets routes to be handled with pages()
  • the pages() handles Path Traversal with a replacer
  • pages() sets the default root directory to be at index.html, which has the index.js file to handle login requests
  • login requests are compared with the data inside users.json
  • If the login matches a username and password pair in users.json, returns a valid json object which is just {}, and runs get_passwords() which queries the SQL database for all the user's passwords
  • We are then routed to https://password-manager-web.chal.irisc.tf/passwords/ which should display all our passwords nicely

Soln

So, the vulnerability lies inside the replacer function that is supposed to prevent path traversal.

var PathReplacer = strings.NewReplacer(
	"../", "",
)

It evaluates the literal and replaces only ../

So, if we still want our string to have ../ capabilites of path traversal, we can pass in our url to be: .../...// which when removed of its ../, we get ../

So, we make the URL be: https://password-manager-web.chal.irisc.tf/.../...//users.json

Now that we have users.json, we can input these credentials directly and get the flag

Zettelkasten

Themes
Games
88x31