CVE-2022-4879 (Lua, published 2023-01-06T11:15Z)
Description: value: A vulnerability was found in Forged Alliance Forever up to 3746. It has been declared as critical. Affected by this vulnerability is an unknown functionality of the component Vote Handler. The manipulation leads to improper authorization. Upgrading to version 3747 is able to address this issue. The patch is named 6880971bd3d73d942384aff62d53058c206ce644. It is recommended to upgrade the affected component. The associated identifier of this vulnerability is VDB-217555.
Severity: nan (CVSS v3 base score: 7.5)
Weakness: NVD-CWE-Other \u2014 Other
NVD is only using a subset of CWE for mapping instead of the entire CWE, and the weakness type is not covered by that subset.
Repository: https://github.com/FAForever/fa
Files changed:
lua/SimSync.lua
lua/SimUtils.lua
lua/sim/Recall.lua
lua/ui/game/diplomacy.lua
lua/ui/game/recall.lua
Commit message: Update recall feature (#4398)

Fix a few exploits that people could use to vote for another player

Vulnerable code:
        else
        brain.LastRecallRequestTime = PlayerGateCooldown - PlayerRequestCooldown
        brain.LastRecallVoteTime = PlayerGateCooldown - TeamVoteCooldown
    end
    ForkThread(function()
        if ScenarioInfo.RecallDisabled then
            Sync.RecallRequest = {CannotRequest = "scenario"}
        else
            Sync.RecallRequest = {CannotRequest = "gate"}
            WaitTicks(PlayerGateCooldown + 1)

            local focus = GetFocusArmy()
            local brain = GetArmyBrain(focus)
            for index, brainWith in ArmyBrains do
                if brain ~= brainWith then
                    if  not brain:IsDefeated() and
                        IsAlly(focus, index) and
                        brainWith.BrainType ~= "Human" and
                        not ArmyIsCivilian(index)
                    then
                        Sync.RecallRequest = {CannotRequest = "ai"}
                        return
                    end
            Sync.RecallRequest = {CannotRequest = false}
    end)
    playerGatein = playerGatein or 0
    local reqCooldown = lastPlayerRequest + PlayerRequestCooldown - gametime
    local voteCooldown = lastTeamVote + TeamVoteCooldown - gametime
    local gateCooldown = playerGatein + PlayerGateCooldown - gametime

    local largest, reason = reqCooldown, "request"
    if largest < voteCooldown then
        largest, reason = voteCooldown, "vote"
    if largest < gateCooldown then
        largest, reason = gateCooldown, "gate"
    if largest < 0 then
        return 0, false
    return largest, reason
        return 36000, "scenario"
        return 36000, "active"
        if not ally:IsDefeated() and IsAlly(army, index) and not ArmyIsCivilian(index) then
local function SetCannotRequestRecall(reason)
    local recallSync = Sync.RecallRequest
    if not recallSync then
        Sync.RecallRequest = {CannotRequest = reason}
    else
        recallSync.CannotRequest = reason
    end
end

    WaitTicks(VoteTime + 1)
    local team = {}
    local focus = GetFocusArmy()
    local brain = GetArmyBrain(requestingArmy)
        Sync.RecallRequest = {
            Close = recallAccepted,
        }
        local dip = Sync.DiplomacyAnnouncement
        if not dip then
            dip = {}
            Sync.DiplomacyAnnouncement = dip
        end
        table.insert(dip, {
            Team = brain.Nickname,
        })
        SPEW("Vote passed; recalling!")
        for army, brain in team do
        brain.LastRecallRequestTime = gametick

        local cooldown, reason = ArmyRecallRequestCooldown(focus)
        repeat
            SetCannotRequestRecall(reason)
            WaitTicks(cooldown + 1)

            cooldown, reason = ArmyRecallRequestCooldown(focus)
        until cooldown <= 0
        SetCannotRequestRecall(false)
    brain.recallVotingThread = nil
            if army ~= index and not ally:IsDefeated() and IsAlly(army, index) then
    -- don't update the recall panel for votes we aren't a part of
    if not IsAlly(focus, army) then
        return false
    end
    local recallSync = Sync.RecallRequest
    if not recallSync then
        recallSync = {}
        Sync.RecallRequest = recallSync
    end
    if vote then
        local accept = recallSync.Accept or 0
        recallSync.Accept = accept + 1
    else
        local veto = recallSync.Veto or 0
        recallSync.Veto = veto + 1
        recallSync.CannotRequest = "active"
local function ArmyRequestRecall(army, teammates, lastVote)
        GetArmyBrain(army).recallVotingThread = ForkThread(RecallVotingThread, army)
        if ArmyVoteRecall(army, true, lastVote) then
            local recallSync = Sync.RecallRequest
            recallSync.Open = VoteTime * 0.1
            recallSync.CanVote = army ~= GetFocusArmy()
            recallSync.Blocks = teammates + 1
    elseif lastVote then
        -- if we're the first and last vote, it's just us; recall our army
        SPEW("Immediately recalling")
        GetArmyBrain(army):RecallAllCommanders()
    local focus = GetFocusArmy()
            SetCannotRequestRecall("scenario")
                    SetCannotRequestRecall("ai")
            lastVote = lastVote and allyHasVoted
            isRequest = isRequest and not allyHasVoted
        local cooldown, reason = ArmyRecallRequestCooldown(army)
        if cooldown > 0 then
                SetCannotRequestRecall(reason)
        SPEW("Army " .. tostring(army) .. " is requesting recall")
        ArmyRequestRecall(army, teammates, lastVote)
local cannotRequestRecall = false
function CannotRequestRecall()
    return cannotRequestRecall
function UpdateCannotRequestRecall(canRequest)
    cannotRequestRecall = canRequest
    if not import("/lua/ui/campaign/campaignmanager.lua").campaignMode then
        local reason = CannotRequestRecall()
                    function()
                        SimCallback({
                            Func = "SetRecallVote",
                            Args = {
                                From = GetFocusArmy(),
                                Vote = true,
                            },
                        })
                        recallButton:Disable()
                        cannotRequestRecall = "active"
                        Tooltip.AddButtonTooltip(recallButton, "dip_recall_request_dis_active")
                        import("/lua/ui/game/tabs.lua").CollapseWindow()
                    end,
        import("/lua/ui/game/diplomacy.lua").UpdateCannotRequestRecall(data.CannotRequest)
        panel:StartVote(data.Blocks, data.Open, data.CanVote)
    StartVote = function(self, blocks, duration, canVote)
        SPEW("Recall voting!")
        self.startTime:Set(GetGameTimeSeconds())

Fixed code:
    import("/lua/sim/recall.lua").OnArmyChange()
    import("/lua/sim/recall.lua").OnAllianceChange(data)
-- import recall parameters
local SyncAnnouncement = import("/lua/simdiplomacy.lua").SyncAnnouncement


---@alias CannotRecallReason false
---| "active"
---| "ai"
---| "gate"
---| "request"
---| "scenario"
---| "vote"
---| "observer"

    -- setup sim recall state in the brains
    local playerCooldown = PlayerGateCooldown - PlayerRequestCooldown
    local teamCooldown = PlayerGateCooldown - TeamVoteCooldown
        brain.LastRecallRequestTime = playerCooldown
        brain.LastRecallVoteTime = teamCooldown
    end

    -- setup user recall state notifier in this thread
    SyncRecallStatus()
end

function OnArmyChange()
    local focus = GetFocusArmy()
    if focus == -1 then
        SyncCancelRecallVote()
        SyncRecallStatus()
        return
    end
    local teamSize = 0
    local accept, veto = 0, 0
    local votingThreadBrain
    for index, brain in ArmyBrains do
        if IsAlly(focus, index) and not ArmyIsCivilian(index) then
            -- Found a voting thread. We really do need a better way to handle team data...
            teamSize = teamSize + 1
            if brain.Vote ~= nil then
                if brain.Vote then
                    accept = accept + 1
                else
                    veto = veto + 1
            if brain.recallVotingThread then
                votingThreadBrain = brain
            end
        end
    end
    if votingThreadBrain then
        Sync.RecallRequest = {
            StartTime = votingThreadBrain.RecallVoteStartTime,
            Open = VoteTime * 0.1,
            Blocks = teamSize,
            Accept = accept,
            Veto = veto,
            CanVote = GetArmyBrain(focus).Vote ~= nil,
        }
    end
    SyncRecallStatus()
end

---@param data {From: number, To: number}
function OnAllianceChange(data)
    local armyFrom, armyTo = data.From, data.To
    local oldTeammates = 0
    local oldTeam = {}
    local votingThreadBrain
    for index, ally in ArmyBrains do
        if (IsAlly(armyFrom, index) or IsAlly(armyTo, index))
            and not ally:IsDefeated()
            and not ArmyIsCivilian(index)
        then
            oldTeammates = oldTeammates + 1
            oldTeam[oldTeammates] = ally.Nickname
            -- Found a voting thread. We really do need a better way to handle team data...
            if ally.recallVotingThread then
                votingThreadBrain = ally
            end
        end
    end
    if votingThreadBrain then
        SPEW("Canceling recall voting for team " .. table.concat(oldTeam, ", ") .. " due to alliance break")
        votingThreadBrain.VoteCancelled = true
        coroutine.resume(votingThreadBrain.recallVotingThread)
        if IsAlly(votingThreadBrain, GetFocusArmy()) then
            SyncCancelRecallVote()
            SyncRecallStatus()
    end

---@return CannotRecallReason CannotRecallReason
---@return number? cooldown
    -- note that this doesn't always return the reason that currently has the longest cooldown, it
    -- returns the more "fundamental" one (i.e. the reason whose base cooldown is longest)
    -- this is more useful in reporting the reason, and isn't a problem when put in a loop
    local gateCooldown = (playerGatein or 0) + PlayerGateCooldown - gametime
    if gateCooldown > 0 then
        return "gate", gateCooldown
    local reqCooldown = lastPlayerRequest + PlayerRequestCooldown - gametime
    if reqCooldown > 0 then
        return "request", reqCooldown
    local voteCooldown = lastTeamVote + TeamVoteCooldown - gametime
    if voteCooldown > 0 then
        return "vote", voteCooldown
    return false
--- Returns the current reason an army cannot request recall and the cooldown of that reason, or
--- false
---@return CannotRecallReason
---@return number? cooldown no timeout/cooldown if absent
    if army == -1 then
        return "observer"
    end
        return "scenario"
        return "active", VoteTime
        if IsAlly(army, index) and not ally:IsDefeated() and not ArmyIsCivilian(index) then
            if ally.BrainType ~= "Human" then
                return "ai"
            end
    -- if someone adds a feature that gates in commanders at different times, that time should be
    -- added as an argument to this method call
---@param requestingArmy number
    local requestingBrain = GetArmyBrain(requestingArmy)
    requestingBrain.RecallVoteStartTime = GetGameTick()
    WaitTicks(VoteTime) -- may be interrupted if the vote closes or is canceled by an alliance break

    local focus = GetFocusArmy()
    if requestingBrain.VoteCancelled then
        if IsAlly(requestingArmy, focus) then
            SyncCancelRecallVote()
            SyncRecallStatus()
        end
        requestingBrain.VoteCancelled = nil
        requestingBrain.RecallVoteStartTime = nil
        requestingBrain.recallVotingThread = nil
        return
    end
    local team = {}
    -- this function is found in the recall params file, for those looking
        SyncCloseRecallVote(recallAccepted)
        -- the recall UI will handle the announcement in this case
        -- in this case though, we need to handle the announcement
        SyncAnnouncement {
            Team = requestingBrain.Nickname,
        }
    end
    local listTeam = team[1].Nickname
    for i = 2, table.getn(team) do
        listTeam = listTeam .. ", " .. team[i].Nickname
        SPEW("Recalling team " .. listTeam .. " at the request of " .. requestingBrain.Nickname .. " (vote passed " .. recallAcceptance .. " to " .. (teammates - recallAcceptance ) .. ")")
        for _, brain in team do
        SPEW("Not recalling team " .. listTeam .. " (vote failed " .. recallAcceptance .. " to " .. (teammates - recallAcceptance ) .. ")")
        requestingBrain.LastRecallRequestTime = gametick
    end
    if IsAlly(requestingArmy, focus) then
        SyncRecallStatus()
    requestingBrain.RecallVoteStartTime = nil
    requestingBrain.recallVotingThread = nil
---@param army number
---@param vote boolean
---@param lastVote boolean
---@return boolean # if further user sync should happen
            if army ~= index and IsAlly(army, index) and not ally:IsDefeated() then
    if focus == -1 or not IsAlly(focus, army) then
        return false -- don't update the recall panel for votes we aren't a part of or for observers
    SyncRecallVote(vote)
        SyncCannotRequestRecall("active")
---@param army number
---@param teammates number
local function ArmyRequestRecall(army, teammates)
    local brain = GetArmyBrain(army)
        brain.recallVotingThread = ForkThread(RecallVotingThread, army)
        if ArmyVoteRecall(army, true, false) then
            SyncOpenRecallVote(teammates + 1, army)
    else
        -- it's just us; recall our army
        SPEW("Recalling " .. brain.Nickname)
        brain:RecallAllCommanders()

    if not OkayToMessWithArmy(army) then
        return
    end
    local focus = GetFocusArmy()
            SyncCannotRequestRecall("scenario")
    -- determine team voting status
    local team = {}
                    SyncCannotRequestRecall("ai")
            lastVote = lastVote and allyHasVoted -- only the last vote if all allies have also voted
            isRequest = isRequest and not allyHasVoted -- only the last vote if no allies have voted
            team[teammates] = ally.Nickname
        local reason = ArmyRecallRequestCooldown(army)
        if reason then
                SyncCannotRequestRecall(reason)
        SPEW("Army " .. tostring(army) .. " is requesting recall for " .. table.concat(team, ','))
        ArmyRequestRecall(army, teammates)


---@param reason CannotRecallReason
function SyncCannotRequestRecall(reason)
    local recallSync = Sync.RecallRequest
    if not recallSync then
        Sync.RecallRequest = {CannotRequest = reason}
    else
        recallSync.CannotRequest = reason
    end
end

---@param result boolean
function SyncCloseRecallVote(result)
    local recallSync = Sync.RecallRequest
    if not recallSync then
        Sync.RecallRequest = {Close = result}
    else
        recallSync.Close = result
    end
end

function SyncCancelRecallVote()
    local recallSync = Sync.RecallRequest
    if not recallSync then
        Sync.RecallRequest = {Cancel = true}
    else
        recallSync.Cancel = true
    end
end

---@param vote boolean
function SyncRecallVote(vote)
    local recallSync = Sync.RecallRequest
    if not recallSync then
        recallSync = {}
        Sync.RecallRequest = recallSync
    end
    if vote then
        recallSync.Accept = (recallSync.Accept or 0) + 1
    else
        recallSync.Veto = (recallSync.Veto or 0) + 1
    end
end

---@param teamSize number
---@param army number
function SyncOpenRecallVote(teamSize, army)
    local recallSync = Sync.RecallRequest
    if not recallSync then
        recallSync = {}
        Sync.RecallRequest = recallSync
    end
    local focus = GetFocusArmy()
    recallSync.Open = VoteTime * 0.1
    recallSync.CanVote = focus ~= -1 and army ~= focus
    recallSync.Blocks = teamSize
end

local UserRecallStatusThread

local function SyncRecallStatusThread()
    local reason, cooldown = ArmyRecallRequestCooldown(GetFocusArmy())
    while reason do
        SyncCannotRequestRecall(reason)
        if not cooldown then
            UserRecallStatusThread = nil
            return
        end
        -- may be interrupted for various reasons, such as the focus army changing
        -- this will be fine, we'll pick up the proper cooldown reason anyway and loop again
        WaitTicks(cooldown)

        reason, cooldown = ArmyRecallRequestCooldown(GetFocusArmy())
    end
    SyncCannotRequestRecall(false)
    UserRecallStatusThread = nil
end

function SyncRecallStatus()
    if UserRecallStatusThread then
        coroutine.resume(UserRecallStatusThread) -- force update the existing thread
    else
        UserRecallStatusThread = ForkThread(SyncRecallStatusThread)
    end
end
local CannotRequestRecallReason = false
function GetCannotRequestRecallReason()
    return CannotRequestRecallReason
function SetCannotRequestRecallReason(canRequest)
    CannotRequestRecallReason = canRequest
    if focusArmy == -1 then
        focusArmy = import("/lua/ui/game/gamemain.lua").OriginalFocusArmy
        if focusArmy == -1 then
            return
        end
    end
    local reason = GetCannotRequestRecallReason()
    if not import("/lua/ui/campaign/campaignmanager.lua").campaignMode and reason ~= "observer" then
            local function OnAcceptRecall()
                SimCallback({
                    Func = "SetRecallVote",
                    Args = {
                        From = GetFocusArmy(),
                        Vote = true,
                    },
                })
                -- preemptively expect the sim to accept our recall and disable the button so we
                -- can't possiblely confuse the sim with more than one request
                -- note that if--for some reason (*ahem* due to a mod maybe)--the sim *doesn't*
                -- end up accepting it, then we'll be stuck until the sim sends a new update
                recallButton:Disable()
                SetCannotRequestRecallReason("active")
                Tooltip.AddButtonTooltip(recallButton, "dip_recall_request_dis_active")
                import("/lua/ui/game/tabs.lua").CollapseWindow()
            end
                    OnAcceptRecall,
            --:DisableHitTest()
        import("/lua/ui/game/diplomacy.lua").SetCannotRequestRecallReason(data.CannotRequest)
        panel:StartVote(data.Blocks, data.Open, data.CanVote, data.StartTime)
    elseif data.Cancel then
        panel:CancelVote()
    StartVote = function(self, blocks, duration, canVote, startingTime)
        self.startTime:Set(startingTime or GetGameTimeSeconds())
    CancelVote = function(self)
        self:SetCanVote(false)
        self.startTime:Set(-9999) -- make sure the OnFrame animation ends
        if self.reviewResultsThread then
            KillThread(self.reviewResultsThread)
            self.reviewResultsThread = nil
        end
        self:OnResultsReviewed()
    end;

                self.progressBar:Hide()